Merge branch 'develop' into bleach-to-nh3

This commit is contained in:
ALB.Leach 2026-01-23 15:29:44 +07:00 committed by GitHub
commit e4376fc067
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
249 changed files with 12280 additions and 8232 deletions

View file

@ -117,6 +117,8 @@ jobs:
fi
echo "Setting up environment..."
# Last python version in the array is the "default", so the 2nd parameter here is optional
if rm -rf ${GITHUB_WORKSPACE}/env && python"$2" -m venv ${GITHUB_WORKSPACE}/env; then
source ${GITHUB_WORKSPACE}/env/bin/activate
pip install --quiet --upgrade pip
@ -154,17 +156,17 @@ jobs:
# Save this script into a file for later use.
declare -f update_to_version > "$RUNNER_TEMP/migrate"
- name: Update to v14
run: |
source $RUNNER_TEMP/migrate
update_to_version 14 3.11
exit $?
- name: Update to v15
run: |
source $RUNNER_TEMP/migrate
update_to_version 15 3.13
exit $?
- name: Update to v16
run: |
source $RUNNER_TEMP/migrate
update_to_version 16
exit $?
- name: Update to last commit
run: |

View file

@ -12,7 +12,7 @@ jobs:
strategy:
fail-fast: false
matrix:
branch: ["develop"]
branch: ["develop", "version-16-hotfix"]
permissions:
contents: write
@ -27,6 +27,11 @@ jobs:
with:
python-version: "3.14"
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
- name: Run script to update POT file
run: |
bash ${GITHUB_WORKSPACE}/.github/helper/update_pot_file.sh

View file

@ -15,7 +15,7 @@ jobs:
strategy:
fail-fast: false
matrix:
version: ["14", "15"]
version: ["14", "15", "16"]
steps:
- uses: octokit/request-action@v2.x

View file

@ -57,7 +57,7 @@ jobs:
needs: checkrun
uses: ./.github/workflows/_base-migration.yml
with:
db-artifact-url: https://frappeframework.com/files/v13-frappe.sql.gz
db-artifact-url: https://frappe.io/files/v14-frappe.sql.gz
python-version: '3.14'
node-version: 24
fake-success: ${{ needs.checkrun.outputs.build != 'strawberry' }}

View file

@ -2,24 +2,24 @@ pull_request_rules:
- name: Auto-close PRs on stable branch
conditions:
- and:
- and:
- author!=surajshetty3416
- author!=deepeshgarg007
- author!=ankush
- author!=frappe-pr-bot
- author!=mergify[bot]
- or:
- base=version-16
- base=version-15
- base=version-14
- base=version-13
- base=version-12
- and:
- author!=surajshetty3416
- author!=deepeshgarg007
- author!=ankush
- author!=frappe-pr-bot
- author!=mergify[bot]
- or:
- base=version-16
- base=version-15
- base=version-14
- base=version-13
- base=version-12
actions:
close:
comment:
message: |
@{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on an appropriate hotfix branch.
https://github.com/frappe/erpnext/wiki/Pull-Request-Checklist#which-branch
message: |
@{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on an appropriate hotfix branch.
https://github.com/frappe/erpnext/wiki/Pull-Request-Checklist#which-branch
- name: backport to develop
conditions:
@ -31,16 +31,6 @@ pull_request_rules:
assignees:
- "{{ author }}"
- name: backport to version-13-hotfix
conditions:
- label="backport version-13-hotfix"
actions:
backport:
branches:
- version-13-hotfix
assignees:
- "{{ author }}"
- name: backport to version-14-hotfix
conditions:
- label="backport version-14-hotfix"
@ -61,3 +51,12 @@ pull_request_rules:
assignees:
- "{{ author }}"
- name: backport to version-16-hotfix
conditions:
- label="backport version-16-hotfix"
actions:
backport:
branches:
- version-16-hotfix
assignees:
- "{{ author }}"

View file

@ -1,19 +1,22 @@
{
"branches": ["develop", {"name": "version-14-beta", "channel": "beta", "prerelease": true}],
"branches": ["version-17"],
"plugins": [
"@semantic-release/commit-analyzer", {
"preset": "angular"
"preset": "angular",
"releaseRules": [
{"breaking": true, "release": false}
]
},
"@semantic-release/release-notes-generator",
[
"@semantic-release/exec", {
"prepareCmd": 'sed -ir "s/[0-9]*\.[0-9]*\.[0-9]*/${nextRelease.version}/" frappe/__init__.py'
"prepareCmd": 'sed -ir -E "s/\"[0-9]+\.[0-9]+\.[0-9]+\"/\"${nextRelease.version}\"/" frappe/__init__.py'
}
],
[
"@semantic-release/git", {
"assets": ["frappe/__init__.py"],
"message": "chore(release): Bumped to Version ${nextRelease.version}"
"message": "chore(release): Bumped to Version ${nextRelease.version}\n\n${nextRelease.notes}"
}
],
"@semantic-release/github"

View file

@ -79,7 +79,7 @@ context("Control Link", () => {
it("should unset invalid value", () => {
get_dialog_with_link().as("dialog");
cy.intercept("/api/method/frappe.client.validate_link*").as("validate_link");
cy.intercept("/api/method/frappe.client.validate_link_and_fetch*").as("validate_link");
cy.get(".frappe-control[data-fieldname=link] input").focus().as("input");
// Wait for dropdown to appear (request might be cached)
cy.get("@input").parent().findByRole("listbox").should("be.visible");
@ -92,7 +92,7 @@ context("Control Link", () => {
it("should be possible set empty value explicitly", () => {
get_dialog_with_link().as("dialog");
cy.intercept("/api/method/frappe.client.validate_link*").as("validate_link");
cy.intercept("/api/method/frappe.client.validate_link_and_fetch*").as("validate_link");
cy.get(".frappe-control[data-fieldname=link] input").focus().as("input");
// Wait for dropdown to appear (request might be cached)
@ -179,7 +179,7 @@ context("Control Link", () => {
it("should update dependant fields (via fetch_from)", () => {
cy.get("@todos").then((todos) => {
cy.visit(`/desk/todo/${todos[0]}`);
cy.intercept("/api/method/frappe.client.validate_link*").as("validate_link");
cy.intercept("/api/method/frappe.client.validate_link_and_fetch*").as("validate_link");
cy.fill_field("assigned_by", cy.config("testUser"), "Link");
cy.call("frappe.client.get_value", {
@ -203,7 +203,7 @@ context("Control Link", () => {
""
);
cy.window().its("cur_frm.doc.assigned_by").should("eq", null);
cy.window().its("cur_frm.doc.assigned_by").should("eq", undefined);
// set valid value again
cy.get("@input").clear().focus();

View file

@ -11,7 +11,6 @@ context("Customize Form", () => {
"Set by user": "prompt",
"By fieldname": "field:",
Expression: "",
"Expression (old style)": "format:",
Random: "hash",
"By script": "",
};

View file

@ -12,6 +12,9 @@ context("Dashboard Chart", () => {
cy.fill_field("chart_name", "Test Chart", "Data");
cy.fill_field("document_type", "Workspace Link", "Link");
// wait for link field events to complete
cy.wait(1000);
cy.get('[data-fieldname="filters_json"]').click();
cy.get(".modal-dialog", { timeout: 500 }).should("be.visible");

View file

@ -54,7 +54,7 @@ from .utils.jinja import (
render_template,
)
__version__ = "16.0.0-dev"
__version__ = "17.0.0-dev"
__title__ = "Frappe Framework"
if TYPE_CHECKING: # pragma: no cover
@ -196,7 +196,7 @@ def init(site: str, sites_path: str = ".", new_site: bool = False, force: bool =
local.cache = {}
local.form_dict = _dict()
local.preload_assets = {"style": [], "script": [], "icons": []}
local.session = _dict(user="Guest")
local.session = _dict(user="Guest", data=_dict())
local.dev_server = _dev_server # only for backwards compatibility
local.qb = get_query_builder(local.conf.db_type)
if not cache or not client_cache:

View file

@ -11,8 +11,8 @@ import frappe
from frappe import _
from frappe.modules.utils import get_doctype_app_map
from frappe.monitor import add_data_to_monitor
from frappe.pulse.app_heartbeat_event import capture_app_heartbeat
from frappe.utils.response import build_response
from frappe.utils.telemetry.pulse.app_heartbeat_event import capture_app_heartbeat
class ApiVersion(str, Enum):

View file

@ -25,6 +25,21 @@ PERMISSION_MAP = {
}
def get_bulk_operation_async_threshold(doctype: str | None = None) -> int:
conf = frappe.conf.get("bulk_operation_async_threshold", 20)
if isinstance(conf, dict):
value = conf.get(doctype, 20) if doctype else conf.get("*", 20)
else:
value = conf
return cint(value)
class FrappeValueError(ValueError):
http_status_code = 417
def handle_rpc_call(method: str, doctype: str | None = None):
from frappe.modules.utils import load_doctype_module
@ -121,8 +136,17 @@ def document_list(doctype: str) -> list[dict[str, Any]]:
start: int = cint(args.get("start", 0))
limit: int = cint(args.get("limit", 20))
group_by: str | None = args.get("group_by", None)
debug: bool = args.get("debug", False)
as_dict: bool = args.get("as_dict", True)
debug: bool = bool(args.get("debug", False))
as_dict: bool = bool(args.get("as_dict", True))
if fields and not isinstance(fields, list):
raise FrappeValueError("'fields' must be a list")
if filters and not isinstance(filters, (list, dict)):
raise FrappeValueError("'filters' must be a list or dictionary")
if order_by and not isinstance(order_by, str):
raise FrappeValueError("'order_by' must be a string")
if group_by and not isinstance(group_by, str):
raise FrappeValueError("'group_by' must be a string")
query = frappe.qb.get_query(
table=doctype,
@ -235,6 +259,294 @@ def execute_doc_method(doctype: str, name: str, method: str | None = None):
return result
def bulk_delete_docs(doctype: str):
"""Bulk delete multiple documents of the same doctype.
Request body should contain:
names: List of document names to delete
Returns:
deleted: List of successfully deleted document names
failed: List of failed deletions with error messages
total: Total number of documents attempted
success_count: Number of successful deletions
failure_count: Number of failed deletions
"""
names = frappe.form_dict.get("names")
if not isinstance(names, list):
raise FrappeValueError("'names' must be a list")
if len(names) > get_bulk_operation_async_threshold(doctype):
job = frappe.enqueue(
"frappe.api.v2.execute_bulk_delete_docs",
doctype=doctype,
names=names,
)
frappe.response.http_status_code = 202
return {"job_id": job.id}
return execute_bulk_delete_docs(doctype, names)
def execute_bulk_delete_docs(doctype: str, names: list[str | int]):
deleted = []
failed = []
for name in names:
if not isinstance(name, str | int):
failed.append({"name": name, "error": "'name' must be a string or integer"})
continue
if isinstance(name, int):
name = str(name)
savepoint = "bulk_delete_docs"
frappe.db.savepoint(savepoint)
try:
frappe.delete_doc(doctype, name, ignore_missing=False)
deleted.append(name)
except Exception as e:
frappe.db.rollback(save_point=savepoint)
failed.append({"name": name, "error": str(e)})
return {
"deleted": deleted,
"failed": failed,
"total": len(names),
"success_count": len(deleted),
"failure_count": len(failed),
}
def bulk_delete():
"""Bulk delete documents across multiple doctypes.
Request body should contain:
docs: List of {"doctype": str, "name": str} objects
Returns:
deleted: List of successfully deleted documents
failed: List of failed deletions with error messages
total: Total number of documents attempted
success_count: Number of successful deletions
failure_count: Number of failed deletions
"""
docs = frappe.form_dict.get("docs", [])
if not isinstance(docs, list):
raise FrappeValueError("'docs' must be a list")
if len(docs) > get_bulk_operation_async_threshold():
job = frappe.enqueue(
"frappe.api.v2.execute_bulk_delete",
docs=docs,
)
frappe.response.http_status_code = 202
return {"job_id": job.id}
return execute_bulk_delete(docs)
def execute_bulk_delete(docs: list):
deleted = []
failed = []
for item in docs:
doctype = None
name = None
savepoint = "bulk_delete"
frappe.db.savepoint(savepoint)
try:
if not isinstance(item, dict):
raise FrappeValueError("Each document must be a dictionary with 'doctype' and 'name' keys")
doctype = item.get("doctype")
name = item.get("name")
if not isinstance(doctype, str):
raise FrappeValueError("'doctype' must be a string")
if not isinstance(name, str | int):
raise FrappeValueError("'name' must be a string or integer")
if isinstance(name, int):
name = str(name)
frappe.delete_doc(doctype, name, ignore_missing=False)
deleted.append({"doctype": doctype, "name": name})
except Exception as e:
frappe.db.rollback(save_point=savepoint)
failed.append({"doctype": doctype, "name": name, "error": str(e)})
return {
"deleted": deleted,
"failed": failed,
"total": len(docs),
"success_count": len(deleted),
"failure_count": len(failed),
}
def bulk_update_docs(doctype: str):
"""Bulk update multiple documents of the same doctype.
Request body should contain:
docs: List of {"name": str, ...fields} objects where each object contains
the document name and the fields to update
Returns:
updated: List of successfully updated document names
failed: List of failed updates with error messages
total: Total number of documents attempted
success_count: Number of successful updates
failure_count: Number of failed updates
"""
docs = frappe.form_dict.get("docs")
if not isinstance(docs, list):
raise FrappeValueError("'docs' must be a list")
if len(docs) > get_bulk_operation_async_threshold(doctype):
job = frappe.enqueue(
"frappe.api.v2.execute_bulk_update_docs",
doctype=doctype,
docs=docs,
)
frappe.response.http_status_code = 202
return {"job_id": job.id}
return execute_bulk_update_docs(doctype, docs)
def execute_bulk_update_docs(doctype: str, docs: list):
updated = []
failed = []
for item in docs:
name = None
savepoint = "bulk_update_docs"
frappe.db.savepoint(savepoint)
try:
if not isinstance(item, dict):
raise FrappeValueError("Each update must be a dictionary with 'name' and field values")
name = item.get("name")
if not isinstance(name, str | int):
raise FrappeValueError("'name' must be a string or integer")
if isinstance(name, int):
name = str(name)
doc = frappe.get_doc(doctype, name, for_update=True)
item_copy = item.copy()
item_copy.pop("name")
item_copy.pop("flags", None)
doc.update(item_copy)
doc.save()
doc.apply_fieldlevel_read_permissions()
updated.append(name)
frappe.response.docs.append(doc.as_dict())
except Exception as e:
frappe.db.rollback(save_point=savepoint)
failed.append({"name": name, "error": str(e)})
return {
"updated": updated,
"failed": failed,
"total": len(docs),
"success_count": len(updated),
"failure_count": len(failed),
}
def bulk_update():
"""Bulk update documents across multiple doctypes.
Request body should contain:
docs: List of {"doctype": str, "name": str, ...fields} objects
Returns:
updated: List of successfully updated documents
failed: List of failed updates with error messages
total: Total number of documents attempted
success_count: Number of successful updates
failure_count: Number of failed updates
"""
docs = frappe.form_dict.get("docs")
if not isinstance(docs, list):
raise FrappeValueError("'docs' must be a list")
if len(docs) > get_bulk_operation_async_threshold():
job = frappe.enqueue(
"frappe.api.v2.execute_bulk_update",
docs=docs,
)
frappe.response.http_status_code = 202
return {"job_id": job.id}
return execute_bulk_update(docs)
def execute_bulk_update(docs: list):
updated = []
failed = []
for item in docs:
doctype = None
name = None
savepoint = "bulk_update"
frappe.db.savepoint(savepoint)
try:
if not isinstance(item, dict):
raise FrappeValueError(
"Each document must be a dictionary with 'doctype', 'name', and field values"
)
doctype = item.get("doctype")
name = item.get("name")
if not isinstance(doctype, str):
raise FrappeValueError("'doctype' must be a string")
if not isinstance(name, str | int):
raise FrappeValueError("'name' must be a string or integer")
if isinstance(name, int):
name = str(name)
doc = frappe.get_doc(doctype, name, for_update=True)
item_copy = item.copy()
item_copy.pop("doctype")
item_copy.pop("name")
item_copy.pop("flags", None)
doc.update(item_copy)
doc.save()
doc.apply_fieldlevel_read_permissions()
updated.append({"doctype": doctype, "name": name})
frappe.response.docs.append(doc.as_dict())
except Exception as e:
frappe.db.rollback(save_point=savepoint)
failed.append({"doctype": doctype, "name": name, "error": str(e)})
return {
"updated": updated,
"failed": failed,
"total": len(docs),
"success_count": len(updated),
"failure_count": len(failed),
}
def run_doc_method(method: str, document: dict[str, Any] | str, kwargs=None):
"""run a whitelisted controller method on in-memory document.
@ -248,6 +560,9 @@ def run_doc_method(method: str, document: dict[str, Any] | str, kwargs=None):
if isinstance(document, str):
document = frappe.parse_json(document)
if not isinstance(document, dict):
raise FrappeValueError("'document' must be a dictionary")
if kwargs is None:
kwargs = {}
@ -272,6 +587,8 @@ url_rules = [
Rule("/method/logout", endpoint=logout, methods=["POST"]),
Rule("/method/ping", endpoint=frappe.ping),
Rule("/method/upload_file", endpoint=upload_file, methods=["POST"]),
Rule("/method/bulk_delete", endpoint=bulk_delete, methods=["POST"]),
Rule("/method/bulk_update", endpoint=bulk_update, methods=["POST"]),
Rule("/method/<method>", endpoint=handle_rpc_call),
Rule(
"/method/run_doc_method",
@ -282,6 +599,8 @@ url_rules = [
# Document level APIs
Rule("/document/<doctype>", methods=["GET"], endpoint=document_list),
Rule("/document/<doctype>", methods=["POST"], endpoint=create_doc),
Rule("/document/<doctype>/bulk_delete", methods=["POST"], endpoint=bulk_delete_docs),
Rule("/document/<doctype>/bulk_update", methods=["POST"], endpoint=bulk_update_docs),
Rule("/document/<doctype>/<path:name>/", methods=["GET"], endpoint=read_doc),
Rule("/document/<doctype>/<path:name>/copy", methods=["GET"], endpoint=copy_doc),
Rule("/document/<doctype>/<path:name>/", methods=["PATCH", "PUT"], endpoint=update_doc),

View file

@ -48,7 +48,7 @@ import frappe.boot
import frappe.client
import frappe.core.doctype.file.file
import frappe.core.doctype.user.user
import frappe.database.mariadb.database # Load database related utils
import frappe.database.mariadb.mysqlclient # Load database related utils
import frappe.database.query
import frappe.desk.desktop # workspace
import frappe.desk.form.save

View file

@ -161,11 +161,8 @@ def load_desktop_data(bootinfo):
from frappe.desk.desktop import get_workspace_sidebar_items
bootinfo.workspaces = get_workspace_sidebar_items()
bootinfo.show_app_icons_as_folder = frappe.db.get_single_value(
"Desktop Settings", "show_app_icons_as_folder"
)
bootinfo.workspace_sidebar_item = get_sidebar_items()
allowed_pages = [d.name for d in bootinfo.workspaces.get("pages")]
bootinfo.workspace_sidebar_item = get_sidebar_items(allowed_pages)
bootinfo.module_wise_workspaces = get_controller("Workspace").get_module_wise_workspaces()
bootinfo.dashboards = frappe.get_all("Dashboard")
bootinfo.app_data = []
@ -536,7 +533,7 @@ def get_sentry_dsn():
return os.getenv("FRAPPE_SENTRY_DSN")
def get_sidebar_items():
def get_sidebar_items(allowed_workspaces):
from frappe import _
from frappe.desk.doctype.workspace_sidebar.workspace_sidebar import auto_generate_sidebar_from_module
@ -588,7 +585,7 @@ def get_sidebar_items():
if (
"My Workspaces" in sidebar_title
or si.type == "Section Break"
or w.is_item_allowed(si.link_to, si.link_type)
or w.is_item_allowed(si.link_to, si.link_type, allowed_workspaces)
):
sidebar_items[sidebar_title.lower()]["items"].append(workspace_sidebar)
add_user_specific_sidebar(sidebar_items)

View file

@ -9,7 +9,7 @@ import frappe.model
import frappe.utils
from frappe import _
from frappe.desk.reportview import validate_args
from frappe.model.utils import is_virtual_doctype
from frappe.desk.search import PAGE_LENGTH_FOR_LINK_VALIDATION, search_widget
from frappe.utils import attach_expanded_links, get_safe_filters
from frappe.utils.caching import http_cache
@ -77,7 +77,13 @@ def get_list(
@frappe.whitelist()
def get_count(doctype, filters=None, debug=False, cache=False):
return frappe.db.count(doctype, get_safe_filters(filters), debug, cache)
from frappe.desk.reportview import get_count
frappe.form_dict.doctype = doctype
frappe.form_dict.filters = get_safe_filters(filters)
frappe.form_dict.debug = debug
return get_count()
@frappe.whitelist()
@ -400,52 +406,95 @@ def is_document_amended(doctype: str, docname: str):
return False
@frappe.whitelist()
def validate_link(doctype: str, docname: str, fields=None):
if not isinstance(doctype, str):
frappe.throw(_("DocType must be a string"))
@frappe.whitelist(methods=["GET", "POST"])
def validate_link_and_fetch(
doctype: str,
docname: str,
fields_to_fetch: list[str] | str | None = None,
# search_widget parameters
query: str | None = None,
filters: dict | list | str | None = None,
**search_args,
):
if not docname:
frappe.throw(_("Document Name must not be empty"))
if not isinstance(docname, str):
frappe.throw(_("Document Name must be a string"))
meta = frappe.get_meta(doctype)
fields_to_fetch = frappe.parse_json(fields_to_fetch)
parent_doctype = None
if doctype != "DocType":
if frappe.get_meta(doctype).istable: # needed for links to child rows
parent_doctype = frappe.db.get_value(doctype, docname, "parenttype")
if not (
frappe.has_permission(doctype, "select", parent_doctype=parent_doctype)
or frappe.has_permission(doctype, "read", parent_doctype=parent_doctype)
):
frappe.throw(
_("You do not have Read or Select Permissions for {}").format(frappe.bold(doctype)),
frappe.PermissionError,
)
# only cache is no fields to fetch and request is GET
can_cache = not fields_to_fetch and frappe.request.method == "GET"
values = frappe._dict()
# Use search_widget to validate - ensures filters/custom queries are respected
# in addition to standard permission checks
# we match the exact docname for non-custom queries and rely on txt for custom queries
search_args.update(
as_dict=False,
# when relying on txt (custom queries), we want to match "A" with "A" only and not "A1", "BA" etc.
# so we set page_length to a conservative value within which exact match is expected to appear
page_length=PAGE_LENGTH_FOR_LINK_VALIDATION,
# translated doctypes are expected to be searchable with translated values, even for custom queries
# for non-custom queries, docname is always matched exactly so we don't translate it
txt=_(docname) if (query and meta.translated_doctype) else docname,
for_link_validation=True,
)
if is_virtual_doctype(doctype):
search_result = frappe.call(
search_widget,
doctype=doctype,
query=query,
filters=filters,
**search_args,
)
if not search_result:
return {} # does not exist or filtered out
values = None
is_virtual_dt = bool(meta.get("is_virtual"))
if is_virtual_dt:
try:
frappe.get_doc(doctype, docname)
values.name = docname
doc = frappe.get_doc(doctype, docname)
doc.check_permission("select" if frappe.only_has_select_perm(doctype) else "read")
values = {"name": doc.name}
except frappe.DoesNotExistError:
frappe.clear_last_message()
frappe.msgprint(
_("Document {0} {1} does not exist").format(frappe.bold(doctype), frappe.bold(docname)),
else:
# get value in the right case and type (str | int)
# for matching with search result
columns_to_fetch = ["name"]
if frappe.is_table(doctype):
columns_to_fetch.append("parenttype") # for child table permission check
values = frappe.db.get_value(doctype, docname, columns_to_fetch, as_dict=True)
if not values:
return {} # does not exist
name_to_compare = values["name"]
# this will be used to fetch fields later
parent_doctype = values.pop("parenttype", None)
# try to match name in search result
# if search_result is large, assume valid link (result may not appear in some custom queries)
if len(search_result) < PAGE_LENGTH_FOR_LINK_VALIDATION and not any(
item[0] == name_to_compare for item in search_result
):
return {} # no permission or filtered out
# don't cache or fetch for virtual doctypes
if is_virtual_dt:
return values
if not fields_to_fetch:
if can_cache:
frappe.local.response_headers.set(
"Cache-Control", "private,max-age=1800,stale-while-revalidate=7200"
)
return values
values.name = frappe.db.get_value(doctype, docname, cache=True)
fields = frappe.parse_json(fields)
if not values.name:
return values
if not fields:
frappe.local.response_headers.set("Cache-Control", "private,max-age=1800,stale-while-revalidate=7200")
return values
try:
values.update(get_value(doctype, fields, docname, parent=parent_doctype))
values.update(get_value(doctype, fields_to_fetch, docname, parent=parent_doctype))
except frappe.PermissionError:
frappe.clear_last_message()
frappe.msgprint(

View file

@ -1583,31 +1583,34 @@ def bypass_patch(context: CliCtxObj, patch_name: str, yes: bool):
frappe.destroy()
@click.command("create-desktop-icons-and-sidebar")
@click.command("sync-desktop-icons")
@pass_context
def create_icons_and_sidebar(context: CliCtxObj):
"""Create desktop icons and workspace sidebars."""
from frappe.desk.doctype.desktop_icon.desktop_icon import create_desktop_icons
from frappe.desk.doctype.workspace_sidebar.workspace_sidebar import (
create_workspace_sidebar_for_workspaces,
)
def sync_desktop_icons(context: CliCtxObj):
from frappe.model.sync import import_file_by_path
from frappe.modules.utils import get_app_level_directory_path
from frappe.utils import update_progress_bar
if not context.sites:
raise SiteNotSpecifiedError
files = []
app_level_folders = ["desktop_icon"]
for site in context.sites:
print("Sycning icons for " + site)
frappe.init(site)
frappe.connect()
try:
print("Creating Desktop Icons")
create_desktop_icons()
print("Creating Workspace Sidebars")
create_workspace_sidebar_for_workspaces()
# Saving it in a command need it
frappe.db.commit() # nosemgrep
except Exception as e:
print(f"Error creating icons {site}: {e}")
finally:
frappe.destroy()
for app_name in frappe.get_installed_apps():
for folder_name in app_level_folders:
directory_path = get_app_level_directory_path(folder_name, app_name)
if os.path.exists(directory_path):
icon_files = [
os.path.join(directory_path, filename) for filename in os.listdir(directory_path)
]
for doc_path in icon_files:
files.append(doc_path)
for i, doc_path in enumerate(files):
imported = import_file_by_path(doc_path, force=True, ignore_version=True)
if imported:
frappe.db.commit(chain=True)
update_progress_bar("Updating Desktop Icons", i, len(files))
commands = [
@ -1646,5 +1649,5 @@ commands = [
trim_database,
clear_log_table,
bypass_patch,
create_icons_and_sidebar,
sync_desktop_icons,
]

View file

@ -37,7 +37,7 @@ from frappe.installer import add_to_installed_apps, remove_app
from frappe.query_builder.utils import db_type_is
from frappe.tests import IntegrationTestCase, timeout
from frappe.tests.test_query_builder import run_only_if
from frappe.utils import add_to_date, get_bench_path, get_bench_relative_path, now
from frappe.utils import add_to_date, execute_in_shell, get_bench_path, get_bench_relative_path, now
from frappe.utils.backups import BackupGenerator, fetch_latest_backups
from frappe.utils.jinja_globals import bundled_asset
from frappe.utils.scheduler import enable_scheduler, is_scheduler_inactive
@ -1086,12 +1086,15 @@ class TestGunicornWorker(IntegrationTestCase):
self.addCleanup(self.kill_gunicorn)
def kill_gunicorn(self):
time.sleep(1)
time.sleep(2)
self.handle.send_signal(signal.SIGTERM)
try:
self.handle.communicate(timeout=1)
self.handle.communicate(timeout=2)
except subprocess.TimeoutExpired:
self.handle.kill()
pass
time.sleep(2)
execute_in_shell("pgrep gunicorn | xargs -L1 kill -9")
def test_gunicorn_ping_sync(self):
self.spawn_gunicorn()
@ -1108,13 +1111,15 @@ class TestGunicornWorker(IntegrationTestCase):
process = psutil.Process(self.handle.pid)
return sum(c.cpu_percent(1.0) for c in process.children(True)) + process.cpu_percent(1.0)
usage_threshold = 10
self.spawn_gunicorn(["--threads=2"])
self.assertLessEqual(get_total_usage(), 3)
self.assertLessEqual(get_total_usage(), usage_threshold)
# Wake up at least one thread, go idle and check again
path = f"http://{self.TEST_SITE}:{self.port}/api/method/ping"
self.assertEqual(requests.get(path).status_code, 200)
self.assertLessEqual(get_total_usage(), 3)
self.assertLessEqual(get_total_usage(), usage_threshold)
class TestRQWorker(IntegrationTestCase):

View file

@ -465,7 +465,13 @@ def run_ui_tests(
os.chdir(app_base_path)
node_bin = subprocess.getoutput("(cd ../frappe && yarn bin)")
node_bin = subprocess.run(
"(cd ../frappe && yarn bin)",
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
).stdout.strip()
cypress_path = f"{node_bin}/cypress"
drag_drop_plugin_path = f"{node_bin}/../@4tw/cypress-drag-drop"
real_events_plugin_path = f"{node_bin}/../cypress-real-events"

View file

@ -366,7 +366,7 @@ def contact_query(doctype, txt, searchfield, start, page_len, filters):
order by
if(locate(%(_txt)s, `tabContact`.full_name), locate(%(_txt)s, `tabContact`.company_name), 99999),
`tabContact`.idx desc, `tabContact`.full_name
limit %(start)s, %(page_len)s """,
limit %(page_len)s offset %(start)s """,
{
"txt": "%" + txt + "%",
"_txt": txt.replace("%", ""),

View file

@ -13,8 +13,8 @@
"idx": 0,
"is_public": 0,
"is_standard": 1,
"last_synced_on": "2025-10-30 21:36:33.646973",
"modified": "2025-10-30 21:37:11.340673",
"last_synced_on": "2026-01-12 00:01:03.263885",
"modified": "2026-01-12 00:03:10.123061",
"modified_by": "Administrator",
"module": "Core",
"name": "Background Job Activity",

View file

@ -0,0 +1,35 @@
{
"based_on": "",
"chart_name": "Notifications By Type",
"chart_type": "Group By",
"creation": "2025-09-08 12:07:04.576729",
"currency": "",
"docstatus": 0,
"doctype": "Dashboard Chart",
"document_type": "Notification Log",
"dynamic_filters_json": "[]",
"filters_json": "[]",
"group_by_based_on": "type",
"group_by_type": "Count",
"idx": 0,
"is_public": 0,
"is_standard": 1,
"last_synced_on": "2026-01-12 00:01:03.245282",
"modified": "2026-01-12 00:02:23.444272",
"modified_by": "Administrator",
"module": "Core",
"name": "Notifications By Type",
"number_of_groups": 0,
"owner": "Administrator",
"parent_document_type": "",
"roles": [],
"show_values_over_chart": 0,
"source": "",
"time_interval": "Yearly",
"timeseries": 0,
"timespan": "Last Year",
"type": "Pie",
"use_report_chart": 0,
"value_based_on": "",
"y_axis": []
}

View file

@ -50,6 +50,8 @@ def make(
send_after=None,
print_language=None,
now=False,
raw_html=False,
add_css=True,
**kwargs,
) -> dict[str, str]:
"""Make a new communication. Checks for email permissions for specified Document.
@ -69,10 +71,12 @@ def make(
:param send_me_a_copy: Send a copy to the sender (default **False**).
:param email_template: Template which is used to compose mail .
:param send_after: Send after the given datetime.
:param raw_html: Whether to use html version of email template
:param add_css: Add default CSS from hooks/email_css to the email template (default **True**)
"""
if kwargs:
from frappe.utils.commands import warn
from frappe.utils.commands import warn
if kwargs:
warn(
f"Options {kwargs} used in frappe.core.doctype.communication.email.make "
"are deprecated or unsupported",
@ -82,6 +86,20 @@ def make(
if doctype and name:
frappe.has_permission(doctype, doc=name, ptype="email", throw=True)
if (
raw_html
and email_template
and not frappe.get_cached_value("Email Template", email_template, "use_html")
):
warn(
_(
"Raw HTML can be used only with Email Templates having 'Use HTML' checked. "
"Proceeding with plain text email."
),
category=UserWarning,
)
raw_html = False
return _make(
doctype=doctype,
name=name,
@ -107,6 +125,8 @@ def make(
send_after=send_after,
print_language=print_language,
now=now,
raw_html=raw_html,
add_css=add_css,
)
@ -135,6 +155,8 @@ def _make(
send_after=None,
print_language=None,
now=False,
raw_html=False,
add_css=True,
) -> dict[str, str]:
"""Internal method to make a new communication that ignores Permission checks."""
@ -165,7 +187,9 @@ def _make(
"send_after": send_after,
}
)
comm.flags.skip_add_signature = not add_signature
comm.flags.skip_add_signature = not add_signature or (
raw_html and frappe.get_cached_value("Email Template", email_template, "use_html")
)
comm.insert(ignore_permissions=True)
# if not committed, delayed task doesn't find the communication
@ -190,6 +214,8 @@ def _make(
print_letterhead=print_letterhead,
print_language=print_language,
now=now,
raw_html=raw_html,
add_css=add_css,
)
emails_not_sent_to = comm.exclude_emails_list(include_sender=send_me_a_copy)

View file

@ -258,6 +258,8 @@ class CommunicationEmailMixin:
print_letterhead=None,
is_inbound_mail_communcation=None,
print_language=None,
raw_html=False,
add_css=True,
) -> dict:
outgoing_email_account = self.get_outgoing_email_account()
if not outgoing_email_account:
@ -307,6 +309,8 @@ class CommunicationEmailMixin:
"is_notification": (self.sent_or_received == "Received"),
"print_letterhead": print_letterhead,
"send_after": self.send_after,
"raw_html": raw_html,
"add_css": add_css,
}
def send_email(
@ -318,6 +322,8 @@ class CommunicationEmailMixin:
is_inbound_mail_communcation=None,
print_language=None,
now=False,
raw_html=False,
add_css=True,
):
if input_dict := self.sendmail_input_dict(
print_html=print_html,
@ -326,5 +332,7 @@ class CommunicationEmailMixin:
print_letterhead=print_letterhead,
is_inbound_mail_communcation=is_inbound_mail_communcation,
print_language=print_language,
raw_html=raw_html,
add_css=add_css,
):
frappe.sendmail(now=now, **input_dict)

View file

@ -6,18 +6,32 @@ def execute():
batch_size = 10_000
while True:
frappe.db.sql(
"""
update `tabCommunication Link` cl
inner join `tabCommunication` c on cl.parent = c.name
set cl.communication_date = c.communication_date
where cl.communication_date is null
and c.communication_date is not null
limit %s
""",
frappe.db.multisql(
{
"mariadb": """
update `tabCommunication Link` cl
inner join `tabCommunication` c on cl.parent = c.name
set cl.communication_date = c.communication_date
where cl.communication_date is null
and c.communication_date is not null
limit %s
""",
"*": """
UPDATE `tabCommunication Link`
SET communication_date = sub.communication_date
FROM (
SELECT cl.name, c.communication_date
FROM `tabCommunication Link` cl
JOIN `tabCommunication` c ON cl.parent = c.name
WHERE cl.communication_date IS NULL
AND c.communication_date IS NOT NULL
LIMIT %s
) AS sub
WHERE `tabCommunication Link`.name = sub.name
""",
},
(batch_size,),
)
frappe.db.commit()
if not frappe.db.sql(

View file

@ -1153,7 +1153,8 @@ def build_fields_dict_for_column_matching(parent_doctype):
doctypes = [(parent_doctype, None)] + [(df.options, df) for df in parent_meta.get_table_fields()]
for doctype, table_df in doctypes:
translated_table_label = _(table_df.label) if table_df else None
table_ref = (table_df.label or table_df.fieldname) if table_df else None
translated_table_label = _(table_ref) if table_ref else None
# name field
name_df = frappe._dict(
@ -1175,7 +1176,7 @@ def build_fields_dict_for_column_matching(parent_doctype):
else:
name_headers = (
f"{table_df.fieldname}.name", # fieldname
f"ID ({table_df.label})", # label
f"ID ({table_ref})", # label
"{} ({})".format(_("ID"), translated_table_label), # translated label
)
@ -1229,7 +1230,7 @@ def build_fields_dict_for_column_matching(parent_doctype):
# fieldname
f"{table_df.fieldname}.{df.fieldname}",
# label
f"{label} ({table_df.label})",
f"{label} ({table_ref})",
# translated label
f"{translated_label} ({translated_table_label})",
):

View file

@ -1,7 +1,7 @@
# Copyright (c) 2019, Frappe Technologies and Contributors
# License: MIT. See LICENSE
import frappe
from frappe.core.doctype.data_import.importer import Importer
from frappe.core.doctype.data_import.importer import Importer, build_fields_dict_for_column_matching
from frappe.tests import IntegrationTestCase
from frappe.tests.test_query_builder import db_type_is, unimplemented_for
from frappe.utils import format_duration, getdate
@ -146,6 +146,22 @@ class TestImporter(IntegrationTestCase):
self.assertEqual(updated_doc.table_field_1[0].child_description, "child description")
self.assertEqual(updated_doc.table_field_1_again[0].child_title, "child title again")
def test_data_import_without_label(self):
"""Test fallback to fieldname when label is not set for a table."""
meta = frappe.get_meta(doctype_name)
table_field = meta.get_field("table_field_1")
original_label = table_field.label
table_field.label = None
fields_dict = build_fields_dict_for_column_matching(doctype_name)
expected_key = "Child Title (table_field_1)"
self.assertIn(
expected_key, fields_dict, f"Fallback failed: '{expected_key}' not found in mapping dict"
)
expected_id_key = "ID (table_field_1)"
self.assertIn(expected_id_key, fields_dict, "ID fallback failed")
table_field.label = original_label # maintain sanity in test env
def get_importer(self, doctype, import_file, update=False, use_sniffer=False):
data_import = frappe.new_doc("Data Import")
data_import.import_type = "Insert New Records" if not update else "Update Existing Records"

View file

@ -72,11 +72,11 @@
"mandatory_depends_on",
"read_only_depends_on",
"display",
"alignment",
"print_width",
"width",
"max_height",
"columns",
"icon",
"column_break_22",
"description",
"documentation_url",
@ -476,6 +476,13 @@
"max_height": "3rem",
"options": "JS"
},
{
"depends_on": "eval:in_list([\"Data\", \"Int\", \"Float\", \"Currency\", \"Percent\"], doc.fieldtype)",
"fieldname": "alignment",
"fieldtype": "Select",
"label": "Alignment",
"options": "\nLeft\nCenter\nRight"
},
{
"fieldname": "column_break_38",
"fieldtype": "Column Break"
@ -626,12 +633,6 @@
"fieldtype": "Select",
"label": "Button Color",
"options": "\nDefault\nPrimary\nInfo\nSuccess\nWarning\nDanger"
},
{
"depends_on": "eval: doc.fieldtype == \"Tab Break\"",
"fieldname": "icon",
"fieldtype": "Icon",
"label": "Icon"
}
],
"grid_page_length": 50,
@ -639,7 +640,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-12-23 14:16:30.951385",
"modified": "2026-01-06 01:37:29.723265",
"modified_by": "Administrator",
"module": "Core",
"name": "DocField",

View file

@ -17,6 +17,7 @@ class DocField(Document):
allow_bulk_edit: DF.Check
allow_in_quick_entry: DF.Check
allow_on_submit: DF.Check
alignment: DF.Literal["", "Left", "Center", "Right"]
bold: DF.Check
button_color: DF.Literal["", "Default", "Primary", "Info", "Success", "Warning", "Danger"]
collapsible: DF.Check
@ -126,7 +127,6 @@ class DocField(Document):
def get_link_doctype(self):
"""Return the Link doctype for the `docfield` (if applicable).
* If fieldtype is Link: Return "options".
* If fieldtype is Table MultiSelect: Return "options" of the Link field in the Child Table.
"""

View file

@ -0,0 +1,5 @@
// Copyright (c) {year}, {app_publisher} and contributors
// For license information, please see license.txt
// frappe.treeview_settings["{doctype}"] = {{
// }};

View file

@ -362,19 +362,7 @@ class DocType(Document):
continue # Invalid expression
link_df = new_meta.get_field(link_fieldname)
if frappe.db.db_type == "postgres":
update_query = """
UPDATE `tab{doctype}`
SET `{fieldname}` = source.`{source_fieldname}`
FROM `tab{link_doctype}` as source
WHERE `{link_fieldname}` = source.name
"""
if df.not_nullable:
update_query += "AND `{fieldname}`=''"
else:
update_query += "AND ifnull(`{fieldname}`, '')=''"
else:
if frappe.db.db_type == "mariadb":
update_query = """
UPDATE `tab{doctype}` as target
INNER JOIN `tab{link_doctype}` as source
@ -386,6 +374,18 @@ class DocType(Document):
else:
update_query += "WHERE ifnull(`target`.`{fieldname}`, '')=''"
else:
update_query = """
UPDATE `tab{doctype}`
SET `{fieldname}` = source.`{source_fieldname}`
FROM `tab{link_doctype}` as source
WHERE `{link_fieldname}` = source.name
"""
if df.not_nullable:
update_query += "AND `{fieldname}`=''"
else:
update_query += "AND ifnull(`{fieldname}`, '')=''"
self.flags.update_fields_to_fetch_queries.append(
update_query.format(
link_doctype=link_df.options,
@ -877,6 +877,9 @@ class DocType(Document):
make_boilerplate("controller.js", self.as_dict())
# make_boilerplate("controller_list.js", self.as_dict())
if self.is_tree:
make_boilerplate("controller_tree.js", self.as_dict())
if self.has_web_view:
templates_path = frappe.get_module_path(
frappe.scrub(self.module), "doctype", frappe.scrub(self.name), "templates"
@ -1838,34 +1841,84 @@ def validate_permissions(doctype, for_remove=False, alert=False):
def check_permission_dependency(d):
if d.cancel and not d.submit:
frappe.throw(_("{0}: Cannot set Cancel without Submit").format(get_txt(d)))
frappe.throw(
_("{0}: The 'Cancel' permission cannot be granted without the 'Submit' permission.").format(
get_txt(d)
)
)
if (d.submit or d.cancel or d.amend) and not d.write:
frappe.throw(_("{0}: Cannot set Submit, Cancel, Amend without Write").format(get_txt(d)))
if d.amend and not d.write:
frappe.throw(_("{0}: Cannot set Amend without Cancel").format(get_txt(d)))
frappe.throw(
_(
"{0}: The 'Submit', 'Cancel', and 'Amend' permissions cannot be granted without the 'Write' permission."
).format(get_txt(d))
)
if d.amend and not d.create:
frappe.throw(
_("{0}: The 'Amend' permission cannot be granted without the 'Create' permission.").format(
get_txt(d)
)
)
if d.get("import") and not d.create:
frappe.throw(_("{0}: Cannot set Import without Create").format(get_txt(d)))
frappe.throw(
_("{0}: The 'Import' permission cannot be granted without the 'Create' permission.").format(
get_txt(d)
)
)
def remove_rights_for_single(d):
if not issingle:
return
if d.report:
frappe.msgprint(_("Report cannot be set for Single types"))
d.report = 0
if d.get("report"):
d.set("report", 0)
frappe.msgprint(
_(
"{0}: The 'Report' permission was removed because it cannot be granted for a 'single' DocType."
).format(get_txt(d))
)
if d.get("import"):
d.set("import", 0)
frappe.msgprint(
_(
"{0}: The 'Import' permission was removed because it cannot be granted for a 'single' DocType."
).format(get_txt(d))
)
if d.get("export"):
d.set("export", 0)
frappe.msgprint(
_(
"{0}: The 'Export' permission was removed because it cannot be granted for a 'single' DocType."
).format(get_txt(d))
)
def check_if_submittable(d):
if d.submit and not issubmittable:
frappe.throw(_("{0}: Cannot set Assign Submit if not Submittable").format(get_txt(d)))
elif d.amend and not issubmittable:
frappe.throw(_("{0}: Cannot set Assign Amend if not Submittable").format(get_txt(d)))
if issubmittable:
return
if d.submit:
frappe.throw(
_("{0}: The 'Submit' permission cannot be granted for a non-submittable DocType.").format(
get_txt(d)
)
)
if d.amend:
frappe.throw(
_("{0}: The 'Amend' permission cannot be granted for a non-submittable DocType.").format(
get_txt(d)
)
)
def check_if_importable(d):
if d.get("import") and not isimportable:
frappe.throw(_("{0}: Cannot set import as {1} is not importable").format(get_txt(d), doctype))
frappe.throw(
_("{0}: The 'Import' permission cannot be granted for a non-importable DocType.").format(
get_txt(d)
)
)
def validate_permission_for_all_role(d):
if frappe.session.user == "Administrator":
@ -1875,7 +1928,7 @@ def validate_permissions(doctype, for_remove=False, alert=False):
if d.role in AUTOMATIC_ROLES:
frappe.throw(
_(
"Row # {0}: Non administrator user can not set the role {1} to the custom doctype"
"Row # {0}: Non-administrator users cannot add the role {1} to a custom DocType."
).format(d.idx, frappe.bold(_(d.role))),
title=_("Permissions Error"),
)
@ -1885,7 +1938,7 @@ def validate_permissions(doctype, for_remove=False, alert=False):
if d.role in roles:
frappe.throw(
_(
"Row # {0}: Non administrator user can not set the role {1} to the custom doctype"
"Row # {0}: Non-administrator users cannot add the role {1} to a custom DocType."
).format(d.idx, frappe.bold(_(d.role))),
title=_("Permissions Error"),
)

View file

@ -6,6 +6,7 @@ import json
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils.caching import redis_cache
class InvalidAppOrder(frappe.ValidationError):
@ -152,24 +153,16 @@ def get_installed_app_order() -> list[str]:
return frappe.get_installed_apps(_ensure_on_bench=True)
@frappe.request_cache
def get_setup_wizard_completed_apps():
"""Get list of apps that have completed setup wizard"""
return frappe.get_all(
"Installed Application",
filters={"has_setup_wizard": 1, "is_setup_complete": 1},
pluck="app_name",
)
apps: InstalledApplications = frappe.client_cache.get_doc("Installed Applications")
return [a.app_name for a in apps.installed_applications if a.has_setup_wizard and a.is_setup_complete]
@frappe.request_cache
def get_setup_wizard_not_required_apps():
"""Get list of apps that do not require setup wizard"""
return frappe.get_all(
"Installed Application",
filters={"has_setup_wizard": 0},
pluck="app_name",
)
apps: InstalledApplications = frappe.client_cache.get_doc("Installed Applications")
return [a.app_name for a in apps.installed_applications if not a.has_setup_wizard]
@frappe.request_cache
@ -190,17 +183,14 @@ def get_apps_with_incomplete_dependencies(current_app):
return pending_apps
@frappe.request_cache
def get_setup_wizard_pending_apps(apps=None):
"""Get list of apps that have completed setup wizard"""
filters = {"has_setup_wizard": 1, "is_setup_complete": 0}
apps: InstalledApplications = frappe.client_cache.get_doc("Installed Applications")
pending_apps = [
a.app_name for a in apps.installed_applications if a.has_setup_wizard and not a.is_setup_complete
]
if apps:
filters["app_name"] = ["in", apps]
pending_apps = [a for a in pending_apps if a in apps]
return frappe.get_all(
"Installed Application",
filters=filters,
order_by="idx",
pluck="app_name",
)
return pending_apps

View file

@ -58,7 +58,7 @@
},
{
"fieldname": "error_message",
"fieldtype": "Text",
"fieldtype": "Code",
"label": "Error Message",
"no_copy": 1,
"print_hide": 1,

View file

@ -116,7 +116,8 @@ class TestRQJob(IntegrationTestCase):
frappe.enqueue(self.BG_JOB, sleep=1, queue=q)
_, stderr = execute_in_shell(
"bench worker-pool --queue short,default --burst --num-workers=4", check_exit_code=True
"bench worker-pool --queue short,default --burst --num-workers=4",
check_exit_code=True,
)
self.assertIn("quitting", cstr(stderr))
@ -178,7 +179,7 @@ class TestRQJob(IntegrationTestCase):
LAST_MEASURED_USAGE += 2
# Observed higher usage on 3.14. Temporarily raising the limit
LAST_MEASURED_USAGE += 5
LAST_MEASURED_USAGE += 6
self.assertLessEqual(rss, LAST_MEASURED_USAGE * 1.05, msg)

View file

@ -41,7 +41,7 @@ frappe.ui.form.on("Success Action", {
frm.action_multicheck = frappe.ui.form.make_control({
parent: next_actions_wrapper,
df: {
label: "Next Actions",
label: __("Next Actions"),
fieldname: "next_actions_multicheck",
fieldtype: "MultiCheck",
options: action_multicheck_options,

View file

@ -59,6 +59,7 @@
"view_switcher",
"form_settings_section",
"form_sidebar",
"form_navigation_buttons",
"timeline",
"dashboard",
"show_absolute_datetime_in_timeline",
@ -636,7 +637,7 @@
"fieldname": "desk_theme",
"fieldtype": "Select",
"label": "Desk Theme",
"options": "Automatic\nLight\nDark"
"options": "Light\nDark\nAutomatic"
},
{
"fieldname": "module_profile",
@ -850,6 +851,12 @@
"is_virtual": 1,
"label": "Active Sessions",
"options": "User Session Display"
},
{
"default": "0",
"fieldname": "form_navigation_buttons",
"fieldtype": "Check",
"label": "Navigation Buttons"
}
],
"icon": "fa fa-user",
@ -903,7 +910,7 @@
}
],
"make_attachments_public": 1,
"modified": "2025-12-13 12:53:46.486021",
"modified": "2026-01-12 16:04:21.542524",
"modified_by": "Administrator",
"module": "Core",
"name": "User",

View file

@ -48,6 +48,7 @@ desk_properties = (
"bulk_actions",
"view_switcher",
"form_sidebar",
"form_navigation_buttons",
"timeline",
"dashboard",
)
@ -84,7 +85,7 @@ class User(Document):
default_app: DF.Literal[None]
default_workspace: DF.Link | None
defaults: DF.Table[DefaultValue]
desk_theme: DF.Literal["Automatic", "Light", "Dark"]
desk_theme: DF.Literal["Light", "Dark", "Automatic"]
document_follow_frequency: DF.Literal["Hourly", "Daily", "Weekly"]
document_follow_notify: DF.Check
email: DF.Data
@ -96,6 +97,7 @@ class User(Document):
follow_created_documents: DF.Check
follow_liked_documents: DF.Check
follow_shared_documents: DF.Check
form_navigation_buttons: DF.Check
form_sidebar: DF.Check
full_name: DF.Data | None
gender: DF.Link | None
@ -555,7 +557,7 @@ class User(Document):
if custom_template:
from frappe.email.doctype.email_template.email_template import get_email_template
email_template = get_email_template(custom_template, args)
email_template = get_email_template(custom_template, args, sender=sender)
subject = email_template.get("subject")
content = email_template.get("message")

View file

@ -3,12 +3,137 @@
import copy
import frappe
from frappe.core.doctype.version.version import get_diff
from frappe.tests import IntegrationTestCase
from frappe.core.doctype.version.version import (
_as_string,
_generate_html_diff,
_should_generate_html_diff,
get_diff,
)
from frappe.tests import IntegrationTestCase, UnitTestCase
from frappe.tests.utils import make_test_objects
class TestHTMLDiff(UnitTestCase):
def test_generate_html_diff_produces_table(self):
"""Test HTML diff generates a table with content."""
result = _generate_html_diff("line1\nline2", "line1\nmodified")
self.assertIsNotNone(result)
self.assertIn("<table", result)
self.assertIn("line1", result)
def test_generate_html_diff_escapes_html(self):
"""Test HTML output is properly escaped and safe."""
old_value = "<script>alert('xss')</script>\nline2"
new_value = "<div>injected</div>\nline2"
result = _generate_html_diff(old_value, new_value)
self.assertIsNotNone(result)
# Raw script/div tags should be escaped, not executable
self.assertNotIn("<script>alert", result)
self.assertNotIn("<div>injected", result)
# Escaped versions should be present
self.assertIn("&lt;script&gt;", result)
self.assertIn("&lt;div&gt;", result)
def test_should_generate_html_diff_multiline(self):
"""Test should_generate_html_diff returns True for multiline text."""
self.assertTrue(_should_generate_html_diff("line1\nline2", "line1\nmodified"))
self.assertTrue(_should_generate_html_diff("single", "multi\nline"))
self.assertTrue(_should_generate_html_diff("multi\nline", "single"))
def test_should_generate_html_diff_long_text(self):
"""Test should_generate_html_diff returns True for text > 80 characters."""
self.assertTrue(_should_generate_html_diff("a" * 81, "b"))
self.assertTrue(_should_generate_html_diff("a", "b" * 81))
self.assertTrue(_should_generate_html_diff("a" * 81, "b" * 81))
def test_should_generate_html_diff_short_text(self):
"""Test should_generate_html_diff returns False for short single-line text."""
self.assertFalse(_should_generate_html_diff("short", "text"))
self.assertFalse(_should_generate_html_diff("a" * 80, "b" * 80)) # Exactly 80 chars
def test_should_generate_html_diff_empty_values(self):
"""Test should_generate_html_diff returns False when either value is empty."""
self.assertFalse(_should_generate_html_diff("", "short"))
self.assertFalse(_should_generate_html_diff("short", ""))
self.assertFalse(_should_generate_html_diff("", ""))
# Even long/multiline text returns False if the other value is empty
self.assertFalse(_should_generate_html_diff("", "a" * 81))
self.assertFalse(_should_generate_html_diff("multi\nline", ""))
def test_as_string_converts_values(self):
"""Test _as_string converts values to strings correctly."""
self.assertEqual(_as_string("text"), "text")
self.assertEqual(_as_string(None), "")
self.assertEqual(_as_string(""), "")
self.assertEqual(_as_string(0), "0")
class TestVersion(IntegrationTestCase):
def test_onload_generates_html_diffs_for_multiline(self):
"""Test onload generates HTML diffs for multiline changes."""
version = frappe.get_doc(
doctype="Version",
ref_doctype="ToDo",
docname="test-doc",
data=frappe.as_json({"changed": [["description", "line1\nline2", "line1\nmodified"]]}),
)
version.onload()
html_diffs = version.get_onload().get("html_diffs")
self.assertIsNotNone(html_diffs)
self.assertIn("description", html_diffs)
self.assertIn("<table", html_diffs["description"])
def test_onload_generates_html_diffs_for_long_text(self):
"""Test onload generates HTML diffs for text > 80 characters."""
version = frappe.get_doc(
doctype="Version",
ref_doctype="ToDo",
docname="test-doc",
data=frappe.as_json({"changed": [["notes", "x" * 81, "y" * 81]]}),
)
version.onload()
html_diffs = version.get_onload().get("html_diffs")
self.assertIsNotNone(html_diffs)
self.assertIn("notes", html_diffs)
def test_onload_no_html_diffs_for_simple_changes(self):
"""Test onload doesn't generate HTML diffs for simple short changes."""
version = frappe.get_doc(
doctype="Version",
ref_doctype="ToDo",
docname="test-doc",
data=frappe.as_json({"changed": [["status", "Open", "Closed"]]}),
)
version.onload()
html_diffs = version.get_onload().get("html_diffs")
self.assertIsNone(html_diffs)
def test_onload_handles_empty_data(self):
"""Test onload handles empty or missing data gracefully."""
version = frappe.get_doc(
doctype="Version",
ref_doctype="ToDo",
docname="test-doc",
data=None,
)
# Should not raise an error
version.onload()
self.assertIsNone(version.get_onload().get("html_diffs"))
version.data = frappe.as_json({"changed": []})
version.onload()
self.assertIsNone(version.get_onload().get("html_diffs"))
def test_get_diff(self):
frappe.set_user("Administrator")
test_records = make_test_objects("Event", reset=True)

View file

@ -1,12 +1,23 @@
frappe.ui.form.on("Version", "refresh", function (frm) {
$(
frappe.render_template("version_view", { doc: frm.doc, data: JSON.parse(frm.doc.data) })
).appendTo(frm.fields_dict.table_html.$wrapper.empty());
frm.add_custom_button(__("Show all Versions"), function () {
frappe.set_route("List", "Version", {
ref_doctype: frm.doc.ref_doctype,
docname: frm.doc.docname,
frappe.ui.form.on("Version", {
refresh: function (frm) {
frm.add_custom_button(__("Show all Versions"), function () {
frappe.set_route("List", "Version", {
ref_doctype: frm.doc.ref_doctype,
docname: frm.doc.docname,
});
});
});
frm.trigger("render_version_view");
},
render_version_view: async function (frm) {
await frappe.model.with_doctype(frm.doc.ref_doctype);
$(
frappe.render_template("version_view", {
doc: frm.doc,
data: JSON.parse(frm.doc.data),
})
).appendTo(frm.fields_dict.table_html.$wrapper.empty());
},
});

View file

@ -1,6 +1,7 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import difflib
import json
import frappe
@ -74,6 +75,29 @@ class Version(Document):
def get_data(self):
return json.loads(self.data)
def onload(self):
"""Generate HTML diffs for multiline changes on document load."""
if not self.data:
return
data = self.get_data()
changed = data.get("changed", [])
if not changed:
return
html_diffs = {}
for item in changed:
if len(item) >= 3:
fieldname, old_str, new_str = item[0], _as_string(item[1]), _as_string(item[2])
if not _should_generate_html_diff(old_str, new_str):
continue
html_diff = _generate_html_diff(old_str, new_str)
if html_diff:
html_diffs[fieldname] = html_diff
if html_diffs:
self.set_onload("html_diffs", html_diffs)
def get_diff(old, new, for_child=False, compare_cancelled=False):
"""Get diff between 2 document objects
@ -203,3 +227,32 @@ def get_diff(old, new, for_child=False, compare_cancelled=False):
def on_doctype_update():
frappe.db.add_index("Version", ["ref_doctype", "docname"])
def _generate_html_diff(old_str: str, new_str: str) -> str | None:
"""Generate HTML diff for the given old and new strings."""
old_lines = old_str.splitlines(keepends=True)
new_lines = new_str.splitlines(keepends=True)
differ = difflib.HtmlDiff(wrapcolumn=80)
html_diff = differ.make_table(
old_lines,
new_lines,
fromdesc=frappe._("Original"),
todesc=frappe._("New"),
context=True,
numlines=3,
)
return html_diff
def _should_generate_html_diff(old_str: str, new_str: str) -> bool:
"""Determine if HTML diff should be generated for the given values."""
return (
old_str and new_str and ("\n" in old_str or "\n" in new_str or len(old_str) > 80 or len(new_str) > 80)
)
def _as_string(value: str | None) -> str:
"""Convert the given value to a string."""
return cstr(value) if value is not None else ""

View file

@ -1,3 +1,52 @@
<style>
.version-diff-container {
margin-bottom: 1rem;
}
.version-diff-container h5 {
margin-bottom: 0.5rem;
}
.version-html-diff table.diff {
width: 100%;
border-collapse: collapse;
font-family: monospace;
font-size: 12px;
}
.version-html-diff table.diff td {
padding: 2px 6px;
border: 1px solid var(--border-color);
vertical-align: top;
}
.version-html-diff table.diff .diff_header {
background-color: var(--subtle-fg);
text-align: right;
padding: 2px 6px;
color: var(--text-muted);
font-weight: normal;
width: 40px;
}
.version-html-diff table.diff .diff_next {
background-color: var(--subtle-fg);
width: 10px;
}
.version-html-diff table.diff .diff_add {
background-color: var(--diff-added);
}
.version-html-diff table.diff .diff_chg {
background-color: var(--diff-changed);
}
.version-html-diff table.diff .diff_sub {
background-color: var(--diff-removed);
}
.version-html-diff table.diff th {
background-color: var(--subtle-fg);
padding: 6px;
border: 1px solid var(--border-color);
font-weight: 500;
}
.version-html-diff table.diff colgroup {
display: none;
}
</style>
<div class="version-info">
{% if data.comment %}
<h4>{{ __("Comment") + " (" + data.comment_type }})</h4>
@ -5,8 +54,19 @@
{% endif %}
{% const getEscapedValue = (v) => v === null ? "null" : frappe.utils.escape_html(v) %}
{% const htmlDiffs = (doc.__onload && doc.__onload.html_diffs) || {} %}
{% if data.changed && data.changed.length %}
<h4>{{ __("Values Changed") }}</h4>
{% for item in data.changed %}
{% if htmlDiffs[item[0]] %}
<div class="version-diff-container">
<h5>{{ frappe.meta.get_label(doc.ref_doctype, item[0]) }}</h5>
<div class="version-html-diff">{{ htmlDiffs[item[0]] }}</div>
</div>
{% endif %}
{% endfor %}
{% var hasSimpleChanges = data.changed.some(item => !htmlDiffs[item[0]]) %}
{% if hasSimpleChanges %}
<table class="table table-bordered">
<thead>
<tr>
@ -17,15 +77,18 @@
</thead>
<tbody>
{% for item in data.changed %}
{% if !htmlDiffs[item[0]] %}
<tr>
<td>{{ frappe.meta.get_label(doc.ref_doctype, item[0]) }}</td>
<td class="diff-remove">{{ getEscapedValue(item[1]) }}</td>
<td class="diff-add">{{ getEscapedValue(item[2]) }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
{% endif %}
{% endif %}
{% var _keys = ["added", "removed"]; %}
{% for key in _keys %}

View file

@ -0,0 +1,26 @@
{
"aggregate_function_based_on": "",
"creation": "2026-01-11 23:59:34.870238",
"currency": "INR",
"docstatus": 0,
"doctype": "Number Card",
"document_type": "RQ Worker",
"dynamic_filters_json": "[]",
"filters_json": "[]",
"function": "Count",
"idx": 0,
"is_public": 0,
"is_standard": 1,
"label": "Active RQ Worker",
"modified": "2026-01-11 23:59:34.870238",
"modified_by": "Administrator",
"module": "Core",
"name": "Active RQ Worker",
"owner": "Administrator",
"parent_document_type": "",
"report_function": "Sum",
"show_full_number": 0,
"show_percentage_stats": 1,
"stats_time_interval": "Daily",
"type": "Document Type"
}

View file

@ -0,0 +1,27 @@
{
"aggregate_function_based_on": "",
"color": "#CB2929",
"creation": "2026-01-11 23:49:55.987084",
"currency": "INR",
"docstatus": 0,
"doctype": "Number Card",
"document_type": "Error Log",
"dynamic_filters_json": "[]",
"filters_json": "[]",
"function": "Count",
"idx": 0,
"is_public": 0,
"is_standard": 1,
"label": "Error Logs",
"modified": "2026-01-11 23:56:36.628717",
"modified_by": "Administrator",
"module": "Core",
"name": "Error Logs",
"owner": "Administrator",
"parent_document_type": "",
"report_function": "Sum",
"show_full_number": 0,
"show_percentage_stats": 1,
"stats_time_interval": "Daily",
"type": "Document Type"
}

View file

@ -0,0 +1,26 @@
{
"aggregate_function_based_on": "",
"creation": "2026-01-11 23:55:30.429516",
"currency": "INR",
"docstatus": 0,
"doctype": "Number Card",
"document_type": "Scheduled Job Type",
"dynamic_filters_json": "[]",
"filters_json": "[]",
"function": "Count",
"idx": 0,
"is_public": 0,
"is_standard": 1,
"label": "Scheduled Jobs",
"modified": "2026-01-11 23:55:30.429516",
"modified_by": "Administrator",
"module": "Core",
"name": "Scheduled Jobs",
"owner": "Administrator",
"parent_document_type": "",
"report_function": "Sum",
"show_full_number": 0,
"show_percentage_stats": 1,
"stats_time_interval": "Daily",
"type": "Document Type"
}

View file

@ -309,6 +309,7 @@ frappe.PermissionEngine = class PermissionEngine {
.attr("data-doctype", d.parent);
checkbox.find("label").css("text-transform", "capitalize");
checkbox.find("label").css("align-items", "center");
return checkbox;
}
@ -415,7 +416,7 @@ frappe.PermissionEngine = class PermissionEngine {
add_delete_button(row, d) {
$(
`<button class='btn btn-danger btn-remove-perm btn-xs'>${frappe.utils.icon(
"delete"
"x"
)}</button>`
)
.appendTo($(`<td class="pt-4">`).appendTo(row))

View file

@ -1,41 +0,0 @@
{
"app": "frappe",
"charts": [
{
"chart_name": "Background Job Activity",
"label": "Background Job Activity"
},
{
"chart_name": "Notifications By Type",
"label": "Notification Summary"
}
],
"content": "[{\"id\":\"-bxX6Dwxxy\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Background Job Activity\",\"col\":12}},{\"id\":\"gccD2r7Ut3\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Notification Summary\",\"col\":12}}]",
"creation": "2025-09-08 11:33:57.533875",
"custom_blocks": [],
"docstatus": 0,
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "monitor-check",
"idx": 0,
"indicator_color": "green",
"is_hidden": 0,
"label": "System",
"link_type": "DocType",
"links": [],
"modified": "2025-10-30 18:22:58.416219",
"modified_by": "Administrator",
"module": "Core",
"name": "System",
"number_cards": [],
"owner": "Administrator",
"parent_page": "",
"public": 1,
"quick_lists": [],
"roles": [],
"sequence_id": 27.0,
"shortcuts": [],
"title": "System",
"type": "Workspace"
}

View file

@ -1,6 +1,12 @@
{
"charts": [],
"content": "[{\"id\":\"b7abeqw4NZ\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"User\",\"col\":3}},{\"id\":\"eghSJPhZRC\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Role\",\"col\":3}},{\"id\":\"uAzl_lT_C0\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Permission Manager\",\"col\":3}},{\"id\":\"oFB4l28FMU\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"NMpIkExl3i\",\"type\":\"card\",\"data\":{\"card_name\":\"Users\",\"col\":4}},{\"id\":\"VepG3durKm\",\"type\":\"card\",\"data\":{\"card_name\":\"Logs\",\"col\":4}},{\"id\":\"S9FeWt7xXE\",\"type\":\"card\",\"data\":{\"card_name\":\"Permissions\",\"col\":4}}]",
"app": "frappe",
"charts": [
{
"chart_name": "Login",
"label": "Login Activity"
}
],
"content": "[{\"id\":\"T_8h_1kB6j\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Login Activity\",\"col\":12}},{\"id\":\"Y9G8gIH9lP\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"System Users\",\"col\":4}},{\"id\":\"78JTmWaYfY\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Website Users\",\"col\":4}},{\"id\":\"vAh1zw5jLk\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Failed Login Attempts\",\"col\":4}}]",
"creation": "2020-03-02 15:12:16.754449",
"custom_blocks": [],
"docstatus": 0,
@ -12,14 +18,6 @@
"is_hidden": 0,
"label": "Users",
"links": [
{
"hidden": 0,
"is_query_report": 0,
"label": "Logs",
"link_count": 0,
"onboard": 0,
"type": "Card Break"
},
{
"dependencies": "",
"hidden": 0,
@ -42,14 +40,6 @@
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Permissions",
"link_count": 0,
"onboard": 0,
"type": "Card Break"
},
{
"dependencies": "",
"hidden": 0,
@ -105,65 +95,26 @@
"onboard": 0,
"report_ref_doctype": "DocShare",
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Users",
"link_count": 4,
"link_type": "DocType",
"onboard": 0,
"type": "Card Break"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "User",
"link_count": 0,
"link_to": "User",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Role",
"link_count": 0,
"link_to": "Role",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "Role Profile",
"link_count": 0,
"link_to": "Role Profile",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{
"hidden": 0,
"is_query_report": 0,
"label": "Module Profile",
"link_count": 0,
"link_to": "Module Profile",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
}
],
"modified": "2024-08-19 11:48:35.908082",
"modified": "2026-01-11 23:30:10.696298",
"modified_by": "Administrator",
"module": "Core",
"name": "Users",
"number_cards": [],
"number_cards": [
{
"label": "Website Users",
"number_card_name": "Total Website Users"
},
{
"label": "System Users",
"number_card_name": "Users"
},
{
"label": "Failed Login Attempts",
"number_card_name": "Failed Login Attempts"
}
],
"owner": "Administrator",
"parent_page": "",
"public": 1,
@ -171,26 +122,7 @@
"restrict_to_domain": "",
"roles": [],
"sequence_id": 13.0,
"shortcuts": [
{
"doc_view": "",
"label": "User",
"link_to": "User",
"stats_filter": "[]",
"type": "DocType"
},
{
"doc_view": "",
"label": "Role",
"link_to": "Role",
"stats_filter": "[]",
"type": "DocType"
},
{
"label": "Permission Manager",
"link_to": "permission-manager",
"type": "Page"
}
],
"title": "Users"
}
"shortcuts": [],
"title": "Users",
"type": "Workspace"
}

View file

@ -46,6 +46,7 @@
"print_hide",
"print_hide_if_no_value",
"print_width",
"alignment",
"no_copy",
"allow_on_submit",
"in_list_view",
@ -302,6 +303,13 @@
"no_copy": 1,
"print_hide": 1
},
{
"depends_on": "eval:['Data', 'Int', 'Float', 'Currency', 'Percent'].includes(doc.fieldtype)",
"fieldname": "alignment",
"fieldtype": "Select",
"label": "Alignment",
"options": "\nLeft\nCenter\nRight"
},
{
"default": "0",
"fieldname": "no_copy",

View file

@ -24,6 +24,7 @@ class CustomField(Document):
allow_in_quick_entry: DF.Check
allow_on_submit: DF.Check
alignment: DF.Literal["", "Left", "Center", "Right"]
bold: DF.Check
button_color: DF.Literal["", "Default", "Primary", "Info", "Success", "Warning", "Danger"]
collapsible: DF.Check

View file

@ -768,6 +768,7 @@ docfield_properties = {
"permlevel": "Int",
"width": "Data",
"print_width": "Data",
"alignment": "Select",
"non_negative": "Check",
"reqd": "Check",
"unique": "Check",

View file

@ -65,6 +65,7 @@
"print_hide",
"print_hide_if_no_value",
"print_width",
"alignment",
"columns",
"width",
"is_custom_field"
@ -356,6 +357,13 @@
"print_width": "50px",
"width": "50px"
},
{
"depends_on": "eval:in_list(['Data', 'Int', 'Float', 'Currency', 'Percent'], doc.fieldtype)",
"fieldname": "alignment",
"fieldtype": "Select",
"label": "Alignment",
"options": "\nLeft\nCenter\nRight"
},
{
"depends_on": "eval:parent.istable",
"description": "Number of columns for a field in a Grid (Total Columns in a grid should be less than 11)",

View file

@ -16,6 +16,7 @@ class CustomizeFormField(Document):
allow_bulk_edit: DF.Check
allow_in_quick_entry: DF.Check
allow_on_submit: DF.Check
alignment: DF.Literal["", "Left", "Center", "Right"]
bold: DF.Check
button_color: DF.Literal["", "Default", "Primary", "Info", "Success", "Warning", "Danger"]
collapsible: DF.Check

View file

@ -1413,8 +1413,11 @@ class Database:
return self.is_missing_column(e) or self.is_table_missing(e)
def multisql(self, sql_dict, values=(), **kwargs):
"""
Chooses which query to execute based on the current database type, falling back to a wildcard query.
"""
current_dialect = self.db_type or "mariadb"
query = sql_dict.get(current_dialect)
query = sql_dict.get(current_dialect) or sql_dict.get("*")
return self.sql(query, values, **kwargs)
def delete(self, doctype: str, filters: dict | list | None = None, debug=False, **kwargs):

View file

@ -10,6 +10,7 @@ from pypika.terms import AggregateFunction, ArithmeticExpression, Star, Term, Va
import frappe
from frappe import _
from frappe.boot import get_additional_filters_from_hooks
from frappe.database.operator_map import NESTED_SET_OPERATORS, OPERATOR_MAP
from frappe.database.utils import (
DefaultOrderBy,
@ -155,8 +156,10 @@ FUNCTION_CALL_PATTERN = re.compile(r"^\s*[a-zA-Z_][a-zA-Z0-9_]*\s*\(", flags=re.
# - `tabTable Name`.`field` (spaces in table name)
# - `tabTable-Field`.`field` (hyphens in table name)
# - Any of above with aliases: ... as alias
# - Single-quoted aliases with colons (used by reportview child fields):
# - ... as 'Child:field'
ALLOWED_FIELD_PATTERN = re.compile(
r"^(?:(`[\w\s-]+`|\w+)\.)?(`\w+`|\w+)(?:\s+as\s+(?:`[\w\s-]+`|\w+))?$",
r"^(?:(`[\w\s-]+`|\w+)\.)?(`\w+`|\w+)(?:\s+as\s+(?:`[\w\s-]+`|'[\w\s:-]+'|\w+))?$",
flags=re.ASCII | re.IGNORECASE,
)
@ -529,6 +532,16 @@ class Engine:
"""Builds a pypika Criterion object for a simple filter condition."""
import operator as builtin_operator
"""Check hooks for custom_operator definitions"""
additional_filters_config = get_additional_filters_from_hooks()
if operator.lower() in additional_filters_config:
f = frappe._dict(doctype=doctype or self.doctype, fieldname=field, operator=operator, value=value)
from frappe.model.db_query import get_additional_filter_field
resolved = get_additional_filter_field(additional_filters_config, f, value)
operator = resolved.get("operator")
value = resolved.get("value", value)
_field = self._validate_and_prepare_filter_field(field, doctype)
if isinstance(value, Field):
@ -554,8 +567,17 @@ class Engine:
_value = _apply_date_field_filter_conversion(_value, _operator, doctype or self.doctype, field)
# For Datetime fields with date values and 'between' operator, convert to datetime range to match db_query
if _operator.lower() == "between" and isinstance(_value, list | tuple) and len(_value) == 2:
_value = _apply_datetime_field_filter_conversion(_value, doctype or self.doctype, field)
if _operator.lower() == "between":
if isinstance(_value, list | tuple) and len(_value) == 2:
_value = _apply_datetime_field_filter_conversion(_value, doctype or self.doctype, field)
elif isinstance(_value, str):
from frappe.model.db_query import get_between_date_filter
target_meta = frappe.get_meta(doctype or self.doctype)
df = target_meta.get_field(field)
_value = tuple(
v.strip().strip("'") for v in get_between_date_filter(_value, df).split(" AND ")
)
if not _value and isinstance(_value, list | tuple | set):
_value = ("",)
@ -726,13 +748,23 @@ class Engine:
else:
# Assume it's a simple filter [field, op, value] etc.
field, value, operator, doctype = None, None, None, None
additional_filters_config = get_additional_filters_from_hooks()
# Determine structure based on length and types
if len(condition) == 3 and isinstance(condition[1], str) and condition[1].lower() in OPERATOR_MAP:
if (
len(condition) == 3
and isinstance(condition[1], str)
and (
condition[1].lower() in OPERATOR_MAP or condition[1].lower() in additional_filters_config
)
):
# [field, operator, value]
field, operator, value = condition
elif (
len(condition) == 4 and isinstance(condition[2], str) and condition[2].lower() in OPERATOR_MAP
len(condition) == 4
and isinstance(condition[2], str)
and (
condition[2].lower() in OPERATOR_MAP or condition[2].lower() in additional_filters_config
)
):
# [doctype, field, operator, value]
doctype, field, operator, value = condition
@ -963,7 +995,7 @@ class Engine:
parts = re.split(r"\s+as\s+", field, flags=re.IGNORECASE)
if len(parts) > 1:
field_part = parts[0].strip()
alias = parts[1].strip().strip('`"') # Remove potential quotes from alias
alias = parts[1].strip().strip("`\"'") # Remove potential quotes from alias
match = FIELD_PARSE_REGEX.match(field_part)
@ -1707,7 +1739,7 @@ class DynamicTableField:
parts = re.split(r"\s+as\s+", field, flags=re.IGNORECASE)
if len(parts) > 1:
field_part = parts[0].strip()
alias = parts[-1].strip().strip('`"') # Get last part as alias
alias = parts[-1].strip().strip("`\"'") # Get last part as alias
field = field_part # Use the part before alias for further parsing
child_match = None
@ -1833,13 +1865,11 @@ class LinkTableField(DynamicTableField):
table = frappe.qb.DocType(self.doctype)
main_table = frappe.qb.DocType(self.parent_doctype)
if not query.is_joined(table):
clause = table.name == getattr(main_table, self.link_fieldname)
query = query.left_join(table).on(table.name == getattr(main_table, self.link_fieldname))
if engine and engine.apply_permissions:
if condition := engine.get_permission_conditions(self.doctype, table):
clause &= condition
query = query.where(condition)
query = query.left_join(table).on(clause)
return query

View file

@ -193,7 +193,12 @@ def drop_index_if_exists(table: str, index: str):
return
try:
frappe.db.sql_ddl(f"ALTER TABLE `{table}` DROP INDEX `{index}`")
if frappe.db.db_type == "postgres":
# Postgres drops indexes with DROP INDEX, not ALTER TABLE ... DROP INDEX
safe_index = index.replace('"', '""')
frappe.db.sql_ddl(f'DROP INDEX IF EXISTS "{safe_index}"')
else:
frappe.db.sql_ddl(f"ALTER TABLE `{table}` DROP INDEX `{index}`")
except Exception as e:
frappe.log_error("Failed to drop index")
click.secho(f"x Failed to drop index {index} from {table}\n {e!s}", fg="red")

View file

@ -0,0 +1,34 @@
{
"based_on": "communication_date",
"chart_name": "Login Activity",
"chart_type": "Count",
"creation": "2025-08-28 16:48:49.946848",
"currency": "INR",
"docstatus": 0,
"doctype": "Dashboard Chart",
"document_type": "Activity Log",
"dynamic_filters_json": "[]",
"filters_json": "[[\"Activity Log\",\"status\",\"=\",\"Success\",false]]",
"group_by_type": "Count",
"idx": 1,
"is_public": 0,
"is_standard": 1,
"last_synced_on": "2026-01-11 23:34:36.361407",
"modified": "2026-01-11 23:37:58.619758",
"modified_by": "Administrator",
"module": "Desk",
"name": "Login Activity",
"number_of_groups": 0,
"owner": "Administrator",
"parent_document_type": "",
"roles": [],
"show_values_over_chart": 0,
"source": "",
"time_interval": "Daily",
"timeseries": 1,
"timespan": "Last Week",
"type": "Line",
"use_report_chart": 0,
"value_based_on": "",
"y_axis": []
}

View file

@ -458,14 +458,6 @@ def get_workspace_sidebar_items():
pages = []
private_pages = []
# get additional settings from Work Settings
try:
workspace_visibilty = loads(
frappe.db.get_single_value("Workspace Settings", "workspace_visibility_json") or "{}"
)
except JSONDecodeError:
workspace_visibilty = {}
# Filter Page based on Permission
for page in all_pages:
try:
@ -477,9 +469,6 @@ def get_workspace_sidebar_items():
private_pages.append(page)
page["label"] = _(page.get("name"))
if page["name"] in workspace_visibilty:
page["visibility"] = workspace_visibilty[page["name"]]
if not page["app"] and page["module"]:
page["app"] = frappe.db.get_value("Module Def", page["module"], "app_name") or get_module_app(
page["module"]
@ -502,9 +491,6 @@ def get_workspace_sidebar_items():
pages.append(next((x for x in all_pages if x["title"] == "Welcome Workspace"), None))
return {
"workspace_setup_completed": frappe.db.get_single_value(
"Workspace Settings", "workspace_setup_completed"
),
"pages": pages,
"has_access": has_access,
"has_create_access": frappe.has_permission(doctype="Workspace", ptype="create"),

View file

@ -218,7 +218,7 @@ def get_chart_config(chart, filters, timespan, timegrain, from_date, to_date):
else get_period(r[0], timegrain)
for r in result
],
"datasets": [{"name": chart.name, "values": [r[1] for r in result]}],
"datasets": [{"name": _(chart.name), "values": [r[1] for r in result]}],
}
@ -292,7 +292,7 @@ def get_group_by_chart_config(chart, filters) -> dict | None:
if data:
return {
"labels": [item.get("name", "Not Specified") for item in data],
"datasets": [{"name": chart.name, "values": [item["count"] for item in data]}],
"datasets": [{"name": _(chart.name), "values": [item["count"] for item in data]}],
}
return None

View file

@ -47,25 +47,30 @@ class DesktopIcon(Document):
def on_trash(self):
clear_desktop_icons_cache()
if frappe.conf.developer_mode and self.standard and self.app:
self.delete_desktop_icon_file()
delete_desktop_icon_file(self.app, self.label)
def check_for_restrict_removal(self):
if self.restrict_removal:
frappe.throw(_("Cannot delete Desktop Icon '{0}' as it is restricted").format(self.label))
def on_update(self):
self.export_desktop_icon()
def after_rename(self, old, new, merge):
delete_desktop_icon_file(self.app, old)
self.export_desktop_icon()
def export_desktop_icon(self):
allow_export = (
self.standard and self.app and not frappe.flags.in_import and frappe.conf.developer_mode
)
if allow_export:
self.export_desktop_icon()
def export_desktop_icon(self):
folder_path = create_directory_on_app_path("desktop_icon", self.app)
file_path = os.path.join(folder_path, f"{frappe.scrub(self.label)}.json")
doc_export = self.as_dict(no_nulls=True, no_private_properties=True)
strip_default_fields(self, doc_export)
# if self.parent_icon:
# print(self.parent_icon)
# doc_export["parent_icon"] = frappe.db.get_value("Desktop Icon", self.parent_icon, "label")
with open(file_path, "w+") as icon_file_doc:
icon_file_doc.write(frappe.as_json(doc_export) + "\n")
folder_path = create_directory_on_app_path("desktop_icon", self.app)
file_path = os.path.join(folder_path, f"{frappe.scrub(self.label)}.json")
doc_export = self.as_dict(no_nulls=True, no_private_properties=True)
strip_default_fields(self, doc_export)
with open(file_path, "w+") as icon_file_doc:
icon_file_doc.write(frappe.as_json(doc_export) + "\n")
def delete_desktop_icon_file(self):
folder_path = create_directory_on_app_path("desktop_icon", self.app)
@ -81,16 +86,13 @@ class DesktopIcon(Document):
else:
try:
items = bootinfo.workspace_sidebar_item[self.label.lower()]["items"]
#
if len(items) == 0:
return False
if len(items) and all(item["type"] == "Section Break" for item in items):
return False
return True
except KeyError:
return False
return True
def check_app_permission(self):
for a in frappe.get_installed_apps():
@ -121,6 +123,13 @@ class DesktopIcon(Document):
clear_desktop_icons_cache()
def delete_desktop_icon_file(app, label):
folder_path = create_directory_on_app_path("desktop_icon", app)
file_path = os.path.join(folder_path, f"{frappe.scrub(label)}.json")
if os.path.exists(file_path):
os.remove(file_path)
def get_workspace_names(workspaces):
workspace_list = []
for w in workspaces["pages"]:
@ -150,85 +159,37 @@ def get_desktop_icons(user=None, bootinfo=None):
"logo_url",
"hidden",
"name",
"sidebar",
]
active_domains = frappe.get_active_domains()
DocType = frappe.qb.DocType("DocType")
if active_domains:
blocked_condition = (
(DocType.restrict_to_domain.isnull())
| (DocType.restrict_to_domain == "")
| (DocType.restrict_to_domain.notin(active_domains))
)
else:
blocked_condition = (DocType.restrict_to_domain.isnull()) | (DocType.restrict_to_domain == "")
blocked_doctypes = [
d.get("name")
for d in frappe.qb.from_(DocType).select(DocType.name).where(blocked_condition).run(as_dict=True)
"restrict_removal",
"icon_image",
]
standard_icons = frappe.get_all("Desktop Icon", fields=fields, filters={"standard": 1})
standard_map = {}
for icon in standard_icons:
if icon._doctype in blocked_doctypes:
icon.blocked = 1
standard_map[icon.module_name] = icon
user_icons = frappe.get_all("Desktop Icon", fields=fields, filters={"standard": 0, "owner": user})
user_icons = user_icons + standard_icons
# for icon in user_icons:
# standard_icon = standard_map.get(icon.module_name, None)
# update hidden property
for icon in user_icons:
standard_icon = standard_map.get(icon.module_name, None)
# # override properties from standard icon
# if standard_icon:
# for key in ("route", "label", "color", "icon", "link"):
# if standard_icon.get(key):
# icon[key] = standard_icon.get(key)
if icon._doctype in blocked_doctypes:
icon.blocked = 1
# if standard_icon.blocked:
# icon.hidden = 1
# override properties from standard icon
if standard_icon:
for key in ("route", "label", "color", "icon", "link"):
if standard_icon.get(key):
icon[key] = standard_icon.get(key)
# # flag for modules_select dialog
# icon.hidden_in_standard = 1
if standard_icon.blocked:
icon.hidden = 1
# flag for modules_select dialog
icon.hidden_in_standard = 1
elif standard_icon.force_show:
icon.hidden = 0
# add missing standard icons (added via new install apps?)
user_icon_names = [icon.module_name for icon in user_icons]
for standard_icon in standard_icons:
if standard_icon.module_name not in user_icon_names:
# if blocked, hidden too!
if standard_icon.blocked:
standard_icon.hidden = 1
standard_icon.hidden_in_standard = 1
user_icons.append(standard_icon)
user_blocked_modules = frappe.get_lazy_doc("User", user).get_blocked_modules()
for icon in user_icons:
if icon.module_name in user_blocked_modules:
icon.hidden = 1
# elif standard_icon.force_show:
# icon.hidden = 0
# sort by idx
user_icons.sort(key=lambda a: a.idx)
# translate
# for d in user_icons:
# if d.label:
# d.label = _(d.label, context=d.parent)
# includes
permitted_icons = []
permitted_parent_labels = set()
if bootinfo:
for s in user_icons:
icon = frappe.get_doc("Desktop Icon", s)
@ -260,10 +221,9 @@ def create_desktop_icons_from_workspace():
for w in workspaces:
icon = frappe.new_doc("Desktop Icon")
icon.link_type = "Workspace"
icon.link_type = "Workspace Sidebar"
icon.label = w.name
icon.icon_type = "Link"
icon.standard = 1
icon.link_to = w.name
icon.icon = w.icon
if w.module:
@ -309,7 +269,6 @@ def create_desktop_icons_from_installed_apps():
icon = frappe.new_doc("Desktop Icon")
icon.label = app_title
icon.link_type = "External"
icon.standard = 1
icon.idx = index
icon.icon_type = "App"
icon.app = a
@ -323,3 +282,23 @@ def create_desktop_icons_from_installed_apps():
def create_desktop_icons():
create_desktop_icons_from_installed_apps()
create_desktop_icons_from_workspace()
def create_user_icons(user, data):
user_settings = json.loads(data)
new_icons = user_settings.get("icons_to_create")
if new_icons:
new_icons = json.loads(user_settings.get("icons_to_create"))
if new_icons:
for icon in new_icons:
try:
desktop_icon = frappe.new_doc("Desktop Icon")
desktop_icon.update(icon)
desktop_icon.owner = user
desktop_icon.save()
except Exception as e:
frappe.log_error("Error in syncing icons", e)
user_settings.pop("icons_to_create", None)
frappe.cache.hset("_user_settings", f"{'Desktop Icon'}::{user}", json.dumps(user_settings))
return json.dumps(user_settings)
return data

View file

@ -0,0 +1,8 @@
// Copyright (c) 2026, Frappe Technologies and contributors
// For license information, please see license.txt
// frappe.ui.form.on("Desktop Layout", {
// refresh(frm) {
// },
// });

View file

@ -0,0 +1,55 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "field:user",
"creation": "2026-01-18 02:17:17.304705",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"user",
"layout"
],
"fields": [
{
"fieldname": "user",
"fieldtype": "Link",
"label": "User",
"options": "User",
"unique": 1
},
{
"fieldname": "layout",
"fieldtype": "Code",
"label": "Layout",
"options": "JSON"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-01-18 02:45:37.287424",
"modified_by": "Administrator",
"module": "Desk",
"name": "Desktop Layout",
"naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"rows_threshold_for_grid_search": 20,
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View file

@ -0,0 +1,50 @@
# Copyright (c) 2026, Frappe Technologies and contributors
# For license information, please see license.txt
import json
import frappe
from frappe.model.document import Document
class DesktopLayout(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
layout: DF.Code | None
user: DF.Link | None
# end: auto-generated types
pass
@frappe.whitelist()
def save_layout(user, layout, new_icons):
if not user:
user = frappe.session.user
layout = json.loads(layout)
new_icons = json.loads(new_icons)
desktop_layout = None
try:
desktop_layout = frappe.get_doc("Desktop Layout", frappe.session.user)
except frappe.DoesNotExistError:
frappe.clear_last_message()
desktop_layout = frappe.new_doc("Desktop Layout")
desktop_layout.user = frappe.session.user
if layout:
desktop_layout.layout = json.dumps(layout)
desktop_layout.save()
for icon in new_icons:
desktop_icon = frappe.new_doc("Desktop Icon")
desktop_icon.update(icon)
desktop_icon.owner = frappe.session.user
desktop_icon.save()
return {"layout": layout}

View file

@ -0,0 +1,20 @@
# Copyright (c) 2026, Frappe Technologies and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase
# On IntegrationTestCase, the doctype test records and all
# link-field test record dependencies are recursively loaded
# Use these module variables to add/remove to/from that list
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
class IntegrationTestDesktopLayout(IntegrationTestCase):
"""
Integration tests for DesktopLayout.
Use this class for testing interactions between multiple components.
"""
pass

View file

@ -5,36 +5,22 @@
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"icon_style",
"navbar_style",
"show_app_icons_as_folder"
"icon_style"
],
"fields": [
{
"default": "Subtle",
"default": "Solid",
"fieldname": "icon_style",
"fieldtype": "Select",
"label": "Icon Style",
"options": "Subtle\nSolid"
},
{
"fieldname": "navbar_style",
"fieldtype": "Select",
"label": "Navbar Style",
"options": "Awesomebar\nmacOS Launchpad\nBrand Logo\nBrand Logo with Search\nTimeless Launchpad\nApps with Search"
},
{
"default": "0",
"fieldname": "show_app_icons_as_folder",
"fieldtype": "Check",
"label": "Show App Icons As Folder"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-12-12 07:11:57.947973",
"modified": "2026-01-12 14:14:52.189474",
"modified_by": "Administrator",
"module": "Desk",
"name": "Desktop Settings",

View file

@ -15,15 +15,6 @@ class DesktopSettings(Document):
from frappe.types import DF
icon_style: DF.Literal["Subtle", "Solid"]
navbar_style: DF.Literal[
"Awesomebar",
"macOS Launchpad",
"Brand Logo",
"Brand Logo with Search",
"Timeless Launchpad",
"Apps with Search",
]
show_app_icons_as_folder: DF.Check
# end: auto-generated types
pass

View file

@ -221,8 +221,8 @@ def quick_kanban_board(doctype, board_name, field_name, project=None):
def get_order_for_column(board, colname):
filters = [[board.reference_doctype, board.field_name, "=", colname]]
if board.filters:
filters.append(frappe.parse_json(board.filters)[0])
if board.filters and (parsed_filters := frappe.parse_json(board.filters)):
filters.append(parsed_filters[0])
return frappe.as_json(frappe.get_list(board.reference_doctype, filters=filters, pluck="name"))

View file

@ -203,7 +203,7 @@ class SystemHealthReport(Document):
# Exclude "maybe" curently executing job
upper_threshold = add_to_date(None, minutes=-30, as_datetime=True)
mariadb_query = """
query = """
SELECT scheduled_job_type,
AVG(CASE WHEN status != 'Complete' THEN 1 ELSE 0 END) * 100 AS failure_rate
FROM `tabScheduled Job Log`
@ -231,25 +231,10 @@ class SystemHealthReport(Document):
LIMIT 5
"""
sqlite_query = """
SELECT scheduled_job_type,
AVG(CASE WHEN status != 'Complete' THEN 1 ELSE 0 END) * 100 AS failure_rate
FROM `tabScheduled Job Log`
WHERE
creation > %(lower_threshold)s
AND modified > %(lower_threshold)s
AND creation < %(upper_threshold)s
GROUP BY scheduled_job_type
HAVING failure_rate > 0
ORDER BY failure_rate DESC
LIMIT 5
"""
failing_jobs = frappe.db.multisql(
{
"mariadb": mariadb_query,
"postgres": postgres_query,
"sqlite": sqlite_query,
"*": query,
},
{"lower_threshold": lower_threshold, "upper_threshold": upper_threshold},
as_dict=True,

View file

@ -127,8 +127,6 @@ class Workspace(Document):
def on_trash(self):
if self.public and not is_workspace_manager():
frappe.throw(_("You need to be Workspace Manager to delete a public workspace."))
self.delete_desktop_icon()
self.delete_workspace_sidebar()
self.delete_from_my_workspaces()
def delete_from_my_workspaces(self):
@ -145,25 +143,6 @@ class Workspace(Document):
if self.module and frappe.conf.developer_mode:
delete_folder(self.module, "Workspace", self.title)
def delete_desktop_icon(self):
if self.public:
desktop_icon = frappe.get_all(
"Desktop Icon",
filters=[{"link_type": "Workspace"}, {"link_to": self.name}],
limit=1,
pluck="name",
)
if desktop_icon:
frappe.delete_doc("Desktop Icon", desktop_icon[0])
def delete_workspace_sidebar(self):
if self.public:
workspace_sidebar = frappe.get_all(
"Workspace Sidebar", filters=[{"name": self.name}], limit=1, pluck="name"
)
if workspace_sidebar:
frappe.delete_doc("Workspace Sidebar", workspace_sidebar[0])
@staticmethod
def get_module_wise_workspaces():
workspaces = frappe.get_all(
@ -328,7 +307,8 @@ def new_page(new_page):
# add to workspace sidebar items
if not doc.public:
add_to_my_workspace(doc)
return {"workspace_pages": get_workspace_sidebar_items(), "sidebar_items": get_sidebar_items()}
workspaces = get_workspace_sidebar_items()
return {"workspace_pages": workspaces, "sidebar_items": get_sidebar_items(workspaces)}
@frappe.whitelist()

View file

@ -1,9 +0,0 @@
# Copyright (c) 2024, Frappe Technologies and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase
class TestWorkspaceSettings(IntegrationTestCase):
pass

View file

@ -1,37 +0,0 @@
// Copyright (c) 2024, Frappe Technologies and contributors
// For license information, please see license.txt
frappe.ui.form.on("Workspace Settings", {
setup(frm) {
frm.hide_full_form_button = true;
frm.docfields = [];
frm.workspace_map = {};
let workspace_visibilty = JSON.parse(frm.doc.workspace_visibility_json || "{}");
// build fields from workspaces
let cnt = 0,
column_added = false;
for (let page of frappe.boot.allowed_workspaces) {
if (page.public) {
frm.workspace_map[page.name] = page;
cnt++;
frm.docfields.push({
fieldtype: "Check",
fieldname: page.name,
label: page.title + (page.parent_page ? ` (${page.parent_page})` : ""),
initial_value: workspace_visibilty[page.name] !== 0, // not set is also visible
});
}
}
frappe.temp = frm;
},
validate(frm) {
frm.doc.workspace_visibility_json = JSON.stringify(frm.dialog.get_values());
frm.doc.workspace_setup_completed = 1;
},
after_save(frm) {
// reload page to show latest sidebar
frappe.app.sidebar.reload();
},
});

View file

@ -1,66 +0,0 @@
{
"actions": [],
"allow_rename": 1,
"creation": "2024-08-02 14:20:30.177818",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"select_workspaces_section",
"workspace_visibility_json",
"workspace_setup_completed"
],
"fields": [
{
"fieldname": "select_workspaces_section",
"fieldtype": "Section Break",
"label": "Select Workspaces"
},
{
"fieldname": "workspace_visibility_json",
"fieldtype": "JSON",
"in_list_view": 1,
"label": "Workspace Visibility",
"reqd": 1
},
{
"default": "0",
"fieldname": "workspace_setup_completed",
"fieldtype": "Check",
"label": "Workspace Setup Completed"
}
],
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2024-09-03 21:29:54.127014",
"modified_by": "Administrator",
"module": "Desk",
"name": "Workspace Settings",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"delete": 1,
"email": 1,
"print": 1,
"read": 1,
"role": "Workspace Manager",
"share": 1,
"write": 1
}
],
"quick_entry": 1,
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View file

@ -1,41 +0,0 @@
# Copyright (c) 2024, Frappe Technologies and contributors
# For license information, please see license.txt
import json
import frappe
from frappe.model.document import Document
class WorkspaceSettings(Document):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
workspace_setup_completed: DF.Check
workspace_visibility_json: DF.JSON
# end: auto-generated types
pass
def on_update(self):
frappe.clear_cache()
@frappe.whitelist()
def set_sequence(sidebar_items):
if not WorkspaceSettings("Workspace Settings").has_permission():
frappe.throw_permission_error()
cnt = 1
for item in json.loads(sidebar_items):
frappe.db.set_value("Workspace", item.get("name"), "sequence_id", cnt)
frappe.db.set_value("Workspace", item.get("name"), "parent_page", item.get("parent") or "")
cnt += 1
frappe.clear_cache()
frappe.toast(frappe._("Updated"))

View file

@ -3,6 +3,10 @@
frappe.ui.form.on("Workspace Sidebar", {
refresh(frm) {
if (frm.doc.standard && !frappe.boot.developer_mode) {
frm.set_intro("This is a standard sidebar and cannot be edited");
frm.set_read_only();
}
if (!frm.is_new()) {
frm.add_custom_button(__(`View Sidebar`), () => {
if (frm.doc.items[0].link_type === "DocType") {
@ -27,10 +31,10 @@ frappe.ui.form.on("Workspace Sidebar Item", {
let row = locals[cdt][cdn];
let grid = frm.fields_dict.items.grid;
let link_to = row.link_to;
if (link_to) {
let row_obj = grid.get_grid_row(cdn);
if (link_to && row.link_type === "DocType" && row_obj) {
frappe.model.with_doctype(link_to, function () {
let meta = frappe.get_meta(link_to);
let row_obj = grid.get_grid_row(cdn);
let field_obj = row_obj.get_field("navigate_to_tab");
let tab_fieldnames = meta.fields
.filter((field) => field.fieldtype === "Tab Break")
@ -41,3 +45,34 @@ frappe.ui.form.on("Workspace Sidebar Item", {
}
},
});
frappe.ui.form.on("Workspace Sidebar Item", {
form_render(frm, cdt, cdn) {
const row = locals[cdt][cdn];
let grid = frm.fields_dict.items.grid;
let row_obj = grid.get_grid_row(cdn);
let link_to = row.link_to;
if (!row_obj) return;
grid.update_docfield_property("filters", "hidden", 1);
const field = row_obj.get_field("filter_area");
if (!field) return;
let filter_group = new frappe.ui.FilterGroup({
parent: $(field.wrapper),
doctype: link_to,
on_change: () => {
frm.dirty();
let fieldname = "filters";
let value = JSON.stringify(filter_group.get_filters());
frappe.model.set_value(cdt, cdn, fieldname, value);
},
});
$(field.wrapper).find(".filter-area").css("margin-bottom", "10px");
$(field.wrapper)
.find(".filter-area")
.prepend("<label class='control-label'>Filters</label>");
if (row.filters) {
filter_group.add_filters_to_filter_group(JSON.parse(row.filters));
}
},
});

View file

@ -8,10 +8,13 @@
"field_order": [
"title",
"header_icon",
"app",
"for_user",
"items",
"module"
"module",
"column_break_pukb",
"standard",
"app",
"section_break_vdyo",
"items"
],
"fields": [
{
@ -33,6 +36,7 @@
"label": "Header Icon"
},
{
"depends_on": "eval: doc.standard == 1",
"fieldname": "app",
"fieldtype": "Autocomplete",
"label": "App",
@ -49,12 +53,26 @@
"fieldtype": "Text",
"hidden": 1,
"label": "Module"
},
{
"default": "0",
"fieldname": "standard",
"fieldtype": "Check",
"label": "Standard"
},
{
"fieldname": "column_break_pukb",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_vdyo",
"fieldtype": "Section Break"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-12-10 20:20:26.518699",
"modified": "2026-01-10 22:12:40.504715",
"modified_by": "Administrator",
"module": "Desk",
"name": "Workspace Sidebar",

View file

@ -29,6 +29,7 @@ class WorkspaceSidebar(Document):
for_user: DF.Link | None
items: DF.Table[WorkspaceSidebarItem]
module: DF.Text | None
standard: DF.Check
title: DF.Data | None
# end: auto-generated types
@ -37,6 +38,7 @@ class WorkspaceSidebar(Document):
if not frappe.flags.in_migrate:
self.user = frappe.get_user()
self.can_read = self.get_cached("user_perm_can_read", self.get_can_read_items)
self.allowed_modules = self.get_cached("user_allowed_modules", self.get_allowed_modules)
self.allowed_pages = get_allowed_pages(cache=True)
self.allowed_reports = get_allowed_reports(cache=True)
@ -48,33 +50,31 @@ class WorkspaceSidebar(Document):
self.user.build_permissions()
def before_save(self):
allow_export = self.app and not frappe.flags.in_import and frappe.conf.developer_mode
if allow_export:
self.export_sidebar()
self.export_sidebar()
self.set_module()
def export_sidebar(self):
folder_path = create_directory_on_app_path("workspace_sidebar", self.app)
file_path = os.path.join(folder_path, f"{frappe.scrub(self.title)}.json")
doc_export = self.as_dict(no_nulls=True, no_private_properties=True)
doc_export = strip_default_fields(self, doc_export)
with open(file_path, "w+") as doc_file:
doc_file.write(frappe.as_json(doc_export) + "\n")
def delete_file(self):
folder_path = create_directory_on_app_path("workspace_sidebar", self.app)
file_path = os.path.join(folder_path, f"{frappe.scrub(self.title)}.json")
if os.path.exists(file_path):
os.remove(file_path)
allow_export = self.app and not frappe.flags.in_import and frappe.conf.developer_mode
if allow_export:
folder_path = create_directory_on_app_path("workspace_sidebar", self.app)
file_path = os.path.join(folder_path, f"{frappe.scrub(self.title)}.json")
doc_export = self.as_dict(no_nulls=True, no_private_properties=True)
doc_export = strip_default_fields(self, doc_export)
with open(file_path, "w+") as doc_file:
doc_file.write(frappe.as_json(doc_export) + "\n")
def on_trash(self):
if is_workspace_manager():
if frappe.conf.developer_mode and self.app:
self.delete_file()
delete_file(self.app, self.title)
else:
frappe.throw(_("You need to be Workspace Manager to delete a public workspace."))
def is_item_allowed(self, name, item_type):
def after_rename(self, old, new, merge):
delete_file(self.app, old)
self.export_sidebar()
def is_item_allowed(self, name, item_type, allowed_workspaces):
if frappe.session.user == "Administrator":
return True
@ -96,6 +96,8 @@ class WorkspaceSidebar(Document):
return True
if item_type == "url":
return True
if item_type == "workspace":
return name in allowed_workspaces
def get_cached(self, cache_key, fallback_fn):
value = frappe.cache.get_value(cache_key, user=frappe.session.user)
@ -127,6 +129,19 @@ class WorkspaceSidebar(Document):
if counts and counts.most_common(1)[0]:
return counts.most_common(1)[0][0]
def get_allowed_modules(self):
if not self.user.allow_modules:
self.user.build_permissions()
return self.user.allow_modules
def delete_file(app, title):
folder_path = create_directory_on_app_path("workspace_sidebar", app)
file_path = os.path.join(folder_path, f"{frappe.scrub(title)}.json")
if os.path.exists(file_path):
os.remove(file_path)
def is_workspace_manager():
return "Workspace Manager" in frappe.get_roles()
@ -313,7 +328,7 @@ def choose_top_doctypes(doctype_names):
try:
doctype_count_map = {}
for doctype in doctype_names:
if not is_single_doctype(doctype):
if not is_single_doctype(doctype) and not frappe.get_meta(doctype).is_virtual:
doctype_count_map[doctype] = frappe.db.count(doctype)
top_doctypes = [
name

View file

@ -25,6 +25,7 @@
"column_break_jexf",
"display_depends_on",
"section_break_whjq",
"filter_area",
"filters",
"route_options"
],
@ -45,7 +46,7 @@
},
{
"default": "DocType",
"depends_on": "eval: doc.type == 'Link' || doc.indent == 1",
"depends_on": "eval: doc.type == 'Link'",
"fieldname": "link_type",
"fieldtype": "Select",
"in_list_view": 1,
@ -53,7 +54,7 @@
"options": "DocType\nPage\nReport\nWorkspace\nDashboard\nURL"
},
{
"depends_on": "eval: doc.link_type != \"URL\" && doc.type == 'Link' || doc.indent == 1",
"depends_on": "eval: doc.link_type != \"URL\" && doc.type == 'Link'",
"fieldname": "link_to",
"fieldtype": "Dynamic Link",
"in_list_view": 1,
@ -61,7 +62,7 @@
"options": "link_type"
},
{
"depends_on": "eval: doc.type == \"Link\"",
"depends_on": "eval: doc.type == \"Link\" || doc.type == \"Section Break\"",
"fieldname": "icon",
"fieldtype": "Icon",
"in_list_view": 1,
@ -162,13 +163,18 @@
"fieldname": "navigate_to_tab",
"fieldtype": "Autocomplete",
"label": "Tab"
},
{
"fieldname": "filter_area",
"fieldtype": "HTML",
"label": "Filter Area"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-12-29 17:11:16.069665",
"modified": "2026-01-12 15:35:56.930873",
"modified_by": "Administrator",
"module": "Desk",
"name": "Workspace Sidebar Item",

View file

@ -155,7 +155,7 @@ def close_all_assignments(doctype, name, ignore_permissions=False):
assignments = frappe.get_all(
"ToDo",
fields=["allocated_to", "name"],
filters=dict(reference_type=doctype, reference_name=name, status=("!=", "Cancelled")),
filters=dict(reference_type=doctype, reference_name=name, status=("not in", ["Cancelled", "Closed"])),
)
if not assignments:
return False

View file

@ -334,8 +334,7 @@ def get_communication_data(
return frappe.db.multisql(
{
"sqlite": sqlite_query,
"postgres": query,
"mariadb": query,
"*": query,
},
dict(
doctype=doctype,

View file

@ -7,13 +7,13 @@
"doctype": "Number Card",
"document_type": "Activity Log",
"dynamic_filters_json": "[]",
"filters_json": "[[\"Activity Log\",\"status\",\"=\",\"Failed\"]]",
"filters_json": "[[\"Activity Log\",\"status\",\"=\",\"Failed\",false]]",
"function": "Count",
"idx": 0,
"is_public": 0,
"is_standard": 1,
"label": "Failed Login Attempts",
"modified": "2025-08-31 19:21:55.040453",
"modified": "2026-01-11 23:37:25.824490",
"modified_by": "Administrator",
"module": "Desk",
"name": "Failed Login Attempts",

View file

@ -7,13 +7,13 @@
"doctype": "Number Card",
"document_type": "Web Form",
"dynamic_filters_json": "[]",
"filters_json": "[[\"Web Form\",\"published\",\"=\",1]]",
"filters_json": "[[\"Web Form\",\"published\",\"=\",1,false]]",
"function": "Count",
"idx": 0,
"idx": 1,
"is_public": 0,
"is_standard": 1,
"label": "Published Web Forms",
"modified": "2025-09-08 11:23:24.431998",
"modified": "2026-01-11 23:36:48.565273",
"modified_by": "Administrator",
"module": "Desk",
"name": "Published Web Forms",

View file

@ -0,0 +1,26 @@
{
"aggregate_function_based_on": "",
"creation": "2025-08-21 04:10:39.412970",
"currency": "INR",
"docstatus": 0,
"doctype": "Number Card",
"document_type": "User",
"dynamic_filters_json": "[]",
"filters_json": "[[\"User\",\"user_type\",\"=\",\"Website User\"]]",
"function": "Count",
"idx": 1,
"is_public": 0,
"is_standard": 1,
"label": "Total Website Users",
"modified": "2026-01-11 23:37:03.758465",
"modified_by": "Administrator",
"module": "Desk",
"name": "Total Website Users",
"owner": "Administrator",
"parent_document_type": "",
"report_function": "Sum",
"show_full_number": 0,
"show_percentage_stats": 1,
"stats_time_interval": "Daily",
"type": "Document Type"
}

View file

@ -0,0 +1,27 @@
{
"aggregate_function_based_on": "",
"color": "#29CD42",
"creation": "2025-08-21 01:13:53.957596",
"currency": "INR",
"docstatus": 0,
"doctype": "Number Card",
"document_type": "User",
"dynamic_filters_json": "[]",
"filters_json": "[[\"User\",\"user_type\",\"=\",\"System User\",false]]",
"function": "Count",
"idx": 2,
"is_public": 0,
"is_standard": 1,
"label": "Total System Users",
"modified": "2026-01-11 23:37:07.673546",
"modified_by": "Administrator",
"module": "Desk",
"name": "Users",
"owner": "Administrator",
"parent_document_type": "",
"report_function": "Sum",
"show_full_number": 0,
"show_percentage_stats": 1,
"stats_time_interval": "Daily",
"type": "Document Type"
}

View file

@ -7,6 +7,7 @@
--folder-icon-background-color: var(--surface-gray-1);
--desktop-modal-radius: 30px;
--desktop-icon-line-height: 115%;
--navbar-height: 52px;
}
[data-theme="dark"]{
--folder-icon-background-color: #2b2b2b;
@ -27,7 +28,7 @@
width: 100%;
padding: 10px 20px 10px 20px;
box-sizing: border-box;
height: 52px;
height: var(--navbar-height);
position: sticky;
top: 0px;
z-index: 1030;
@ -103,7 +104,7 @@
padding: 13px 16px 12px 16px;
position: relative;
}
.desktop-icon.edit-mode .hide-button {
.desktop-icon.desktop-edit-mode .hide-button {
display: flex;
}
.icon-container:has(.app-logo) {
@ -234,7 +235,7 @@
}
.modal-body .icons{
margin-top: 0px;
place-self: start;
place-self: anchor-center;
& .desktop-icon{
height: 126px;
width: 127px;
@ -337,7 +338,7 @@
left: 108px;
}
.edit-mode{
.desktop-edit-mode{
border: 1px dashed var(--outline-gray-2);
border-radius: 20px;
}
@ -395,7 +396,9 @@
}
.desktop-modal-body {
width: 90vw;
width: calc(100vw - 20px);
padding-left: 0px !important;
padding-right: 0px !important;
> .icons-container {
width: 100%;
overflow: hidden !important;
@ -404,10 +407,8 @@
padding: 0;
> .icons {
position: relative;
right: 6%;
column-gap: 4px;
row-gap: 8px;
column-gap: 6px;
row-gap: 6px;
@media screen and (max-width: 380px) {
--desktop-icon-container: 100px;
@ -446,9 +447,86 @@
bottom: 4%;
right: 4%;
z-index: 100;
opacity: 0.1;
opacity: 6%;
border-radius: 10px;
}
.desktop-edit:hover{
opacity: 1;
transition: opacity 0.3s;
}
[data-theme="dark"] .desktop-edit{
background-color: var(--surface-gray-3);
opacity: 1;
}
[data-theme="dark"] .desktop-edit:hover{
opacity: 0.8;
transition: opacity 0.3s;
}
.desktop-navbar-modal-search:hover{
outline: 1px solid var(--surface-gray-3);
}
.desktop-pane{
position: absolute;
top: var(--navbar-height);
right: 0px;
width: 300px;
border-left: 1px solid var(--border-color);
background-color: rgba(255,255,255, 1);
height: 100vh;
}
.pane-header{
display: flex;
font-size: var(--text-lg);
padding: var(--padding-md);
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border-color);
}
.pane-icons-area{
.icons-container {
height: 100%;
margin-top: 0px;
}
.icons{
height: 100%;
overflow: scroll;
}
}
.add-new-icon{
cursor: pointer;
gap: 0px;
justify-content: center;
}
.title-widget{
display: inline-block;
position: relative;
}
.title-input-label{
position: absolute;
top: 0px;
color: var(--neutral-white);
line-height: 22px;
z-index: 1;
pointers-events: none;
width: 100%;
text-align: center;
}
.title-input-wrapper{
position: relative;
display: inline-block;
}
.title-input-wrapper input{
border: 1px solid transparent;
width: 100%;
height: 100%;
background: none;
color: var(--neutral-white);
}

View file

@ -23,9 +23,37 @@
</span>
</button>
</div>
<div class="desktop-avatar">
<div class="flex" style="gap:16px; align-items: center;">
<div class="desktop-notifications">
<div class="dropdown dropdown-notifications">
<button class="btn-reset nav-link text-muted" data-toggle="dropdown" >
<svg
class="icon icon-md"
>
<use href="#icon-bell"></use>
</svg>
</button>
<div
style="top: unset"
class="dropdown-menu dropdown-menu-right notifications-list"
role="menu"
>
<div class="notification-list-header">
<div class="header-items"></div>
<div class="header-actions"></div>
</div>
<div class="notification-list-body">
<div class="panel-notifications"></div>
<div class="panel-events"></div>
<div class="panel-changelog-feed"></div>
</div>
</div>
</div>
</div>
<div class="desktop-avatar">
</div>
</div>
</div>
</header>
<div class="desktop-container">
</div>
@ -37,4 +65,5 @@
{{ _("Save") }}
</button>
</div>
<div class="hidden" id="desktop-layout">{{ desktop_layout }}</pre>
</div>

View file

@ -1,6 +1,7 @@
frappe.desktop_utils = {};
frappe.desktop_grids = [];
frappe.desktop_icons_objects = [];
frappe.new_icons = [];
$.extend(frappe.desktop_utils, {
modal: null,
modal_stack: [],
@ -42,6 +43,9 @@ function get_route(desktop_icon) {
let item = {};
if (desktop_icon.link_type == "External" && desktop_icon.link) {
route = window.location.origin + desktop_icon.link;
if (desktop_icon.link.startsWith("http") || desktop_icon.link.startsWith("https")) {
route = desktop_icon.link;
}
} else {
let sidebar = frappe.boot.workspace_sidebar_item[desktop_icon.label.toLowerCase()];
if (desktop_icon.link_type == "Workspace Sidebar" && sidebar) {
@ -63,10 +67,12 @@ function get_route(desktop_icon) {
route = frappe.utils.generate_route(args);
} else if (first_link.link_type == "Workspace") {
let workspaces = frappe.workspaces[frappe.router.slug(first_link.link_to)];
if (workspaces.public) {
route = "/desk/" + frappe.router.slug(first_link.link_to);
} else {
route = "/desk/private/" + frappe.router.slug(workspaces.title);
if (workspaces) {
if (workspaces.public) {
route = "/desk/" + frappe.router.slug(first_link.link_to);
} else {
route = "/desk/private/" + frappe.router.slug(workspaces.title);
}
}
if (first_link.route) {
@ -85,6 +91,9 @@ function get_route(desktop_icon) {
type: first_link.link_type,
name: first_link.link_to,
tab: first_link.tab,
route_options: {
sidebar: desktop_icon.label,
},
});
}
}
@ -93,19 +102,18 @@ function get_route(desktop_icon) {
return route;
}
function get_desktop_icon_by_label(title, filters) {
function get_desktop_icon_by_label(title, filters, force) {
if (force === undefined) force = false;
let icons = frappe.desktop_icons;
if (frappe.pages["desktop"].desktop_page.edit_mode) {
if (!force && frappe.pages["desktop"].desktop_page.edit_mode) {
icons = frappe.new_desktop_icons;
}
if (!filters) {
return icons.find((f) => f.label === title && f.hidden != 1);
return icons.find((f) => f.label === title);
} else {
return icons.find((f) => {
return (
f.label === title &&
Object.keys(filters).every((key) => f[key] === filters[key]) &&
f.hidden != 1
f.label === title && Object.keys(filters).every((key) => f[key] === filters[key])
);
});
}
@ -117,52 +125,70 @@ function get_desktop_icon_by_idx(idx, parent_icon) {
function save_desktop(icons) {
// saving in localStorage;
localStorage.setItem(`${frappe.session.user}:desktop`, JSON.stringify(icons));
frappe.toast("Desktop Saved");
frappe.pages["desktop"].desktop_page.update();
frappe.pages["desktop"].desktop_page.save_layout(icons, frappe.new_icons);
}
function reset_to_default() {
localStorage.setItem(`${frappe.session.user}:desktop`, null);
frappe.model.user_settings.save("Desktop Icon", "icons_to_create", null);
frappe.model.user_settings.save("Desktop Icon", "desktop_layout", null);
}
frappe.pages["desktop"].on_page_show = function () {
frappe.pages["desktop"].desktop_page.setup();
};
function toggle_icons(icons) {
icons.forEach((i) => {
$(i).parent().parent().show();
});
}
frappe.desktop_utils.get_folder_icons = function (folder_name) {
let icons_in_folder = [];
let icons = frappe.desktop_icons;
if (frappe.pages["desktop"].desktop_page.edit_mode) {
icons = frappe.new_desktop_icons;
}
icons.forEach((icon) => {
if (icon.parent_icon == folder_name) {
icons_in_folder.push(icon.label);
}
});
return icons_in_folder;
};
function add_icons_to_folder(folder_name, items) {
let folder = get_desktop_icon_by_label(folder_name);
items.forEach((item) => {
let icon = get_desktop_icon_by_label(item);
icon.parent_icon = folder.label;
});
frappe.pages["desktop"].desktop_page.update();
}
class DesktopPage {
constructor(page) {
this.page = page;
this.edit_mode = false;
this.prepare();
this.make(page);
this.setup_events();
}
update() {
this.prepare();
this.make();
this.make(this.page);
this.setup();
}
update() {
this.make(this.page);
this.setup();
}
prepare() {
this.apps_icons = [];
this.hidden_icons = [];
this.folders = [];
const icon_map = {};
frappe.desktop_icons =
JSON.parse(localStorage.getItem(`${frappe.session.user}:desktop`)) ||
frappe.boot.desktop_icons;
let icons = this.edit_mode ? frappe.new_desktop_icons : frappe.desktop_icons;
const all_icons = icons.filter((icon) => {
if (icon.hidden != 1) {
icon.child_icons = [];
icon_map[icon.label] = icon;
if (icon.icon_type == "Folder") {
this.folders.push(icon.label);
}
return true;
} else {
this.hidden_icons.push(icon);
}
return false;
});
@ -176,20 +202,50 @@ class DesktopPage {
}
});
}
setup_events() {
this.wrapper.find(".hide-button").on("click", function (event) {
event.preventDefault();
event.stopImmediatePropagation();
let desktop_label = event.currentTarget.parentElement.dataset.id;
let desktop_icon = get_desktop_icon_by_label(desktop_label);
desktop_icon.hidden = 1;
frappe.pages["desktop"].desktop_page.update();
get_saved_layout() {
let keywords = ["null", "undefined"];
if (keywords.includes(localStorage.getItem(`${frappe.session.user}:desktop`))) {
return null;
}
return JSON.parse(localStorage.getItem(`${frappe.session.user}:desktop`));
}
sync_layout() {
const me = this;
let saved_layout = JSON.parse(localStorage.getItem(`${frappe.session.user}:desktop`));
if (!this.data && saved_layout) {
this.save_layout(saved_layout);
} else if (Object.keys(this.data).length != 0) {
frappe.desktop_icons = this.data;
} else {
frappe.desktop_icons = frappe.boot.desktop_icons;
}
}
save_layout(layout, new_icons) {
const me = this;
frappe.call({
method: "frappe.desk.doctype.desktop_layout.desktop_layout.save_layout",
args: {
user: frappe.session.user,
layout: JSON.stringify(layout),
new_icons: JSON.stringify(new_icons),
},
callback: function (r) {
me.data = r.message.layout;
me.make(me.page);
me.setup();
frappe.new_icons = [];
},
});
}
make() {
this.page.page_head.hide();
$(this.page.body).empty();
$(frappe.render_template("desktop")).appendTo(this.page.body);
if (!this.data) {
this.data = JSON.parse($("#desktop-layout").text());
}
this.sync_layout();
this.prepare();
this.wrapper = this.page.body.find(".desktop-container");
this.icon_grid = new DesktopIconGrid({
wrapper: this.wrapper,
@ -199,7 +255,7 @@ class DesktopPage {
col: 3,
},
});
this.setup_editing_mode();
this.setup_context_menu();
if (this.edit_mode) {
this.start_editing_layout();
}
@ -207,15 +263,16 @@ class DesktopPage {
setup() {
this.setup_avatar();
this.setup_notifications();
this.setup_navbar();
this.setup_awesomebar();
this.setup_editing_mode();
this.handle_route_change();
this.setup_events();
this.setup_edit_button();
}
setup_edit_button() {
if (this.edit_mode || frappe.is_mobile()) return;
const me = this;
$(".desktop-edit").remove();
this.$desktop_edit_button = $(
"<button class='btn btn-reset desktop-edit'></button>"
).appendTo(document.body);
@ -223,17 +280,18 @@ class DesktopPage {
frappe.utils.icon("square-pen", "md", "", "", "", "", "white")
);
this.$desktop_edit_button.on("click", () => {
frappe.new_desktop_icons = JSON.parse(JSON.stringify(frappe.desktop_icons));
me.start_editing_layout();
me.$desktop_edit_button.hide();
});
}
setup_editing_mode() {
setup_context_menu() {
const me = this;
let menu_items = [
{
label: "Edit Layout",
icon: "edit",
onClick: function () {
me.$desktop_edit_button.hide();
frappe.new_desktop_icons = JSON.parse(JSON.stringify(frappe.desktop_icons));
me.start_editing_layout();
},
@ -255,23 +313,28 @@ class DesktopPage {
}
stop_editing_layout(action) {
this.edit_mode = false;
$(".desktop-icon").removeClass("edit-mode");
$(".desktop-icon").not(".folder-icon .desktop-icon").removeClass("desktop-edit-mode");
$(".desktop-wrapper").removeAttr("data-mode");
$(".add-new-icon").remove();
this.desktop_pane.hide();
if (action === "cancel") {
frappe.new_desktop_icons = null;
this.update();
return;
}
// submit
save_desktop(frappe.new_desktop_icons);
}
start_editing_layout() {
this.edit_mode = true;
$(".desktop-icon").addClass("edit-mode");
const me = this;
this.desktop_pane = new IconsPane();
$(".desktop-wrapper").attr("data-mode", "Edit");
$(".desktop-edit").remove();
frappe.desktop_icons_objects.forEach((icon) => {
icon.edit_mode = true;
});
frappe.desktop_grids.forEach((desktop_grid) => {
if (!desktop_grid.no_dragging) {
desktop_grid.grids.forEach((grid) => {
@ -279,21 +342,58 @@ class DesktopPage {
});
}
});
frappe.desktop_icons_objects.forEach((icon_object) => {
icon_object.setup_dragging();
this.add_new_icons_to_grid();
if (this.edit_mode) {
this.setup_edit_buttons();
this.desktop_pane.show();
}
}
add_new_icons_to_grid() {
let grid = $($(".desktop-container .icons").get(0));
this.add_new_icon = `<div class="desktop-icon desktop-edit-mode add-new-icon" title="Add New Icon">
${frappe.utils.icon("plus", "lg")}
New Icon
</div>`;
grid.append(this.add_new_icon);
$(".add-new-icon").on("click", function () {
frappe.ui.form.make_quick_entry(
"Desktop Icon",
function (icon) {
frappe.new_desktop_icons.push(icon);
frappe.new_icons.push(icon);
frappe.pages["desktop"].desktop_page.update();
},
"",
"",
null,
true,
true
);
});
if (this.edit_mode) this.setup_edit_buttons();
}
setup_edit_buttons() {
const me = this;
this.$edit_button = $(".edit-mode-buttons");
this.$edit_button.find(".discard").on("click", function () {
me.stop_editing_layout("cancel");
me.delete_new_icons();
$($(".desktop-container .icons").get(0)).find(".add-new-icon").remove();
});
this.$edit_button.find(".save").on("click", function () {
me.stop_editing_layout("submit");
});
}
setup_notifications() {
this.notifications = new frappe.ui.Notifications({
wrapper: $(".desktop-notifications"),
full_height: false,
});
}
delete_new_icons() {
frappe.new_icons = [];
}
setup_avatar() {
$(".desktop-avatar").html(frappe.avatar(frappe.session.user, "avatar-medium"));
let is_dark = document.documentElement.getAttribute("data-theme") === "dark";
@ -301,7 +401,7 @@ class DesktopPage {
{
icon: "edit",
label: "Edit Profile",
url: `/update-profile/${frappe.session.user}`,
url: `/desk/user/${frappe.session.user}`,
},
{
icon: is_dark ? "sun" : "moon",
@ -311,9 +411,18 @@ class DesktopPage {
},
},
{
icon: "lock",
label: "Reset Password",
url: "/update-password",
icon: "info",
label: "About",
onClick: function () {
return frappe.ui.toolbar.show_about();
},
},
{
icon: "support",
label: "Frappe Support",
onClick: function () {
window.open("https://support.frappe.io/help", "_blank");
},
},
{
icon: "rotate-ccw",
@ -376,44 +485,25 @@ class DesktopPage {
if (frappe.get_route()[0] == "desktop" || frappe.get_route()[0] == "")
me.setup_navbar();
else {
me.$desktop_edit_button.remove();
$(".navbar").show();
frappe.desktop_utils.close_desktop_modal();
// stop edit mode if route changes and cleanup
me.edit_mode = false;
$(".desktop-icon").removeClass("edit-mode");
$(".desktop-wrapper").removeAttr("data-mode");
$(".desktop-edit").remove();
}
});
}
// setup_icon_search() {
// let all_icons = $(".icon-title");
// let icons_to_show = [];
// $(".desktop-container .icons").append(
// "<div class='no-apps-message hidden'> No apps found </div>"
// );
// $(".desktop-search-wrapper > #navbar-search").on("input", function (e) {
// let search_query = $(e.target).val().toLowerCase();
// console.log(search_query);
// icons_to_show = [];
// all_icons.each(function (index, element) {
// $(element).parent().parent().hide();
// let label = $(element).text().toLowerCase();
// if (label.includes(search_query)) {
// icons_to_show.push(element);
// }
// });
// if (icons_to_show.length == 0) {
// $(".desktop-container .icons").find(".no-apps-message").removeClass("hidden");
// } else {
// $(".desktop-container .icons").find(".no-apps-message").addClass("hidden");
// }
// toggle_icons(icons_to_show);
// });
// }
}
class DesktopIconGrid {
constructor(opts) {
$.extend(this, opts);
this.init();
}
static folder_count = 0;
init() {
this.icons = [];
this.icons_html = [];
// this.page_size = {
@ -428,7 +518,16 @@ class DesktopIconGrid {
this.make();
frappe.desktop_grids.push(this);
}
add_folder() {
DesktopIconGrid.folder_count++;
let icon = frappe.model.get_new_doc("Desktop Icon");
icon.icon_type = "Folder";
icon.label = `Untitled ${DesktopIconGrid.folder_count}`;
icon.idx = 100000;
frappe.new_desktop_icons.push(icon);
frappe.new_icons.push(icon);
return icon;
}
prepare() {
this.total_pages = 1;
this.icons_data = this.icons_data.sort((a, b) => {
@ -443,6 +542,9 @@ class DesktopIconGrid {
make() {
const me = this;
this.icons_container = $(`<div class="icons-container"></div>`).appendTo(this.wrapper);
if (this.compact) {
this.icons_container.css("margin-top", "0px");
}
for (let i = 0; i < this.total_pages; i++) {
let template = `<div class="icons"></div>`;
@ -454,9 +556,6 @@ class DesktopIconGrid {
}
this.grids.push($(template).appendTo(this.icons_container));
this.make_icons(this.icons_data_by_page, this.grids[i]);
// if (!this.no_dragging) {
// this.setup_reordering(this.grids[i]);
// }
}
if (!this.in_folder && this.total_pages > 1) {
this.add_page_indicators();
@ -581,14 +680,31 @@ class DesktopIconGrid {
}
make_icons(icons_data, grid) {
icons_data.forEach((icon) => {
let icon_obj = new DesktopIcon(icon, this.in_folder);
let icon_obj = new DesktopIcon(icon, this.in_folder, this);
let icon_html = icon_obj.get_desktop_icon_html();
this.icons.push(icon_obj);
this.icons_html.push(icon_html);
this.setup_actions_on_icon(icon_obj);
grid.append(icon_html);
});
this.setup_tooltip();
}
setup_actions_on_icon(icon) {
if (this.edit_mode) {
icon.edit_mode = true;
}
if (this.is_pane) {
icon.in_pane = true;
}
}
setup_tooltip() {
$('[data-toggle="tooltip"]').tooltip({
placement: "bottom",
});
}
remove_label_tooltip() {
$('[data-toggle="tooltip"]').tooltip("disable");
}
setup_reordering(grid) {
const me = this;
this.hoverTarget = null;
@ -601,10 +717,22 @@ class DesktopIconGrid {
sort: true, // keep sorting normally
dragoverBubble: true,
group: {
name: "desktop",
name: this.name || "desktop",
put: true,
pull: true,
},
onAdd(evt) {
if (Sortable.get(evt.from).option("group").name == "hidden-icons-grid") {
let icon_name = $(evt.item).attr("data-id");
let icon = get_desktop_icon_by_label(icon_name, {}, true);
icon.index = evt.newIndex;
icon.hidden = 0;
frappe.new_desktop_icons.push(icon);
let hidden_icons = frappe.pages.desktop.desktop_page.hidden_icons;
let added_icon_index = hidden_icons.findIndex((d) => d.label == icon_name);
hidden_icons.splice(added_icon_index, 1);
}
},
onStart(evt) {
frappe.desktop_utils.dragged_item = evt.item;
},
@ -615,9 +743,6 @@ class DesktopIconGrid {
});
dataTransfer.setData("text/plain", JSON.stringify(icon.icon_data)); // `dataTransfer` object of HTML5 DragEvent
},
onMove() {
return frappe.desktop_utils.allow_move || false;
},
onEnd: function (evt) {
if (frappe.desktop_utils.in_folder_creation) return;
if (evt.oldIndex !== evt.newIndex) {
@ -650,6 +775,10 @@ class DesktopIconGrid {
});
}
}
update_grid(icons) {
this.wrapper.empty();
this.init();
}
reorder_icons(reordered_icons, filters) {
reordered_icons.forEach((d, idx) => {
let icon = get_desktop_icon_by_label(d);
@ -665,7 +794,7 @@ class DesktopIconGrid {
}
}
class DesktopIcon {
constructor(icon, in_folder) {
constructor(icon, in_folder, grid_obj) {
this.icon_data = icon;
this.icon_title = this.icon_data.label;
this.icon_subtitle = "";
@ -673,6 +802,8 @@ class DesktopIcon {
this.in_folder = in_folder;
this.icon_data.in_folder = in_folder;
this.link_type = this.icon_data.link_type;
this._edit_mode = false;
this.in_pane = false;
if (this.icon_type != "Folder" && !this.icon_data.sidebar) {
this.icon_route = get_route(this.icon_data);
}
@ -691,12 +822,65 @@ class DesktopIcon {
this.parent_icon = this.icon_data.icon;
this.setup_click();
this.render_folder_thumbnail();
this.grid = grid_obj;
Object.defineProperty(this, "edit_mode", {
get: function () {
return this._edit_mode;
},
set: function (value) {
if (value) {
this.icon.addClass("desktop-edit-mode");
if (this.in_folder) {
this.icon.removeClass("desktop-edit-mode");
}
this.grid.remove_label_tooltip();
this.setup_dragging();
this.setup_edit_menu();
this.setup_hide_button();
this.icon.removeAttr("href");
} else {
this.icon.addClass("desktop-edit-mode");
this.setup_click();
}
this._edit_mode = value;
},
});
Object.defineProperty(this, "in_pane", {
get: function () {
return this._in_pane;
},
set: function (value) {
this._in_pane = value;
if (value) {
this.icon.find(".hide-button").html(frappe.utils.icon("plus"));
this.icon.find(".hide-button").attr("data-mode", "add");
this.setup_hide_button();
} else {
this.icon.find(".hide-button").html(frappe.utils.icon("x"));
this.icon.find(".hide-button").attr("data-mode", "hide");
}
},
});
frappe.desktop_icons_objects.push(this);
}
// this.child_icons = this.get_desktop_icon(this.icon_title).child_icons;
// this.child_icons_data = this.get_child_icons_data();
}
setup_hide_button() {
this.icon.find(".hide-button").on("click", function (event) {
event.preventDefault();
event.stopImmediatePropagation();
let desktop_label = event.currentTarget.parentElement.dataset.id;
let desktop_icon = get_desktop_icon_by_label(desktop_label);
if (event.target.parentElement.dataset.mode == "hide") {
desktop_icon.hidden = 1;
} else {
desktop_icon.hidden = 0;
}
frappe.pages["desktop"].desktop_page.update();
});
}
validate_icon() {
// validate if my workspaces are empty
if (this.icon_data.label == "My Workspaces") {
@ -707,7 +891,6 @@ class DesktopIcon {
if (this.icon_data.child_icons.length == 0) return false;
}
return true;
// validate if folder has no child
}
get_child_icons_data() {
return this.icon_data.child_icons.sort((a, b) => a.idx - b.idx);
@ -715,45 +898,119 @@ class DesktopIcon {
get_desktop_icon_html() {
return this.icon;
}
setup_edit_menu() {
const me = frappe.pages["desktop"].desktop_page;
let icon_data = this.icon_data;
const icon = this;
frappe.ui.create_menu({
parent: this.icon,
right_click: true,
menu_items: [
{
label: "Edit",
icon: "edit",
condition: function () {
return icon_data.standard != 1;
},
onClick: function () {
frappe.ui.form.make_quick_entry(
"Desktop Icon",
function (icon) {
let old_index = frappe.new_desktop_icons.findIndex(
(d_icon) => d_icon.label == icon.label
);
if (old_index !== -1) {
frappe.new_desktop_icons.splice(old_index, 1);
}
frappe.new_desktop_icons.push(icon);
frappe.new_icons.push(icon.name);
frappe.pages["desktop"].desktop_page.update();
},
function (dialog) {
dialog.set_df_property("label", "read_only", 1);
dialog.fields.forEach((field) => {
field.default = icon_data[field.fieldname];
});
dialog.script_manager.trigger("refresh");
},
icon_data,
null
);
},
},
{
label: "Create Folder",
icon: "folder",
onClick: function () {
let folder = me.grid.add_folder();
add_icons_to_folder(folder.label, [icon_data.label]);
},
},
{
label: "Add To Folder",
icon: "folder-open",
condition: function () {
return me.folders.length > 0;
},
items: me.folders.map((name) => {
return {
label: name,
onClick: function () {
add_icons_to_folder(this.label, [icon_data.label]);
},
};
}),
},
],
});
}
setup_click() {
const me = this;
if (this.child_icons?.length && (this.icon_type == "App" || this.icon_type == "Folder")) {
$(this.icon).on("click", () => {
let modal = frappe.desktop_utils.create_desktop_modal(me);
modal.setup(me.icon_title, me.child_icons, 4);
let $title = modal.modal.find(".modal-title");
let title = new InlineEditor($title, this.icon_data.label, function (
old_value,
new_value
) {
let icon = get_desktop_icon_by_label(old_value);
let folder_icons = frappe.desktop_utils.get_folder_icons(old_value);
if (icon) {
icon.label = new_value;
}
add_icons_to_folder(new_value, folder_icons);
frappe.pages["desktop"].desktop_page.update();
});
modal.show();
});
if (this.icon_type == "App") {
$($(this.icon_caption_area).children()[1]).html(
`${this.child_icons.length} Workspaces`
);
let content = `${this.child_icons.length} Workspaces`;
$($(this.icon_caption_area).children()[1]).html(__(content));
}
} else {
this.icon.attr("href", this.icon_route);
}
if (this.icon_data.sidebar) {
const me = this;
this.icon.on("click", function () {
if (me.icon_data.sidebar == "My Workspaces") {
let sidebar_name = me.icon_data.sidebar.toLowerCase();
if (frappe.boot.workspace_sidebar_item[sidebar_name].items.length == 0) {
frappe.toast("No Private Workspaces for user");
} else {
let workspace_name =
frappe.boot.workspace_sidebar_item[sidebar_name].items[0]["link_to"];
frappe.set_route("Workspaces", "private", workspace_name);
}
}
});
if (this.icon_route && this.icon_route.startsWith("http")) {
this.icon.attr("target", "_blank");
}
if (this.icon_route) {
this.icon.attr("href", this.icon_route);
} else {
this.icon.on("click", function (event) {
frappe.msgprint(
__(
"Icon is not correctly configured please check the workspace sidebar to it"
)
);
});
}
}
}
render_folder_thumbnail() {
let condition =
frappe.boot.show_app_icons_as_folder &&
this.icon_type == "App" &&
this.child_icons.length > 0;
if (this.icon_type == "Folder" || condition) {
if (this.icon_type == "Folder") {
if (!this.folder_wrapper) this.folder_wrapper = this.icon.find(".icon-container");
this.folder_wrapper.html("");
this.folder_grid = new DesktopIconGrid({
@ -794,51 +1051,6 @@ class DesktopIcon {
}
}
});
this.icon.on("dragstart", function (event) {
frappe.desktop_utils.dragged_item = event.target;
});
this.icon.on("dragover", function (event) {
console.log(event.target);
if (frappe.desktop_utils.dragged_item == event.target.parentElement) return;
if (
event.target == frappe.desktop_utils.dragged_item ||
frappe.desktop_utils.dragged_item.contains(event.target)
) {
return;
}
if (event.target.parentElement.classList.contains("icon-container")) {
frappe.desktop_utils.allow_move = false;
frappe.desktop_utils.in_folder_creation = true;
let icon_list = [];
icon_list.push(
get_desktop_icon_by_label(event.target.parentElement.parentElement.dataset.id)
);
icon_list.push(
get_desktop_icon_by_label(frappe.desktop_utils.dragged_item.dataset.id)
);
let icon = {
label: "Untitled Folder",
icon_type: "Folder",
child_icons: icon_list,
};
let modal = frappe.desktop_utils.create_desktop_modal(icon);
modal.setup(icon.label, icon_list, 4);
$(event.target.parentElement).addClass("folder-icon");
$(event.target.parentElement).empty();
modal.show();
frappe.boot.desktop_icons.push(icon);
icon_list.forEach((icon) => {
let desktop_icon = frappe.utils.get_desktop_icon_by_label(icon.label);
desktop_icon.parent_icon = "Untitled Folder";
frappe.new_desktop_icons.splice(frappe.boot.desktop_icons.indexOf(icon), 1);
frappe.new_desktop_icons.push(desktop_icon);
});
} else {
frappe.desktop_utils.allow_move = true;
}
});
}
}
@ -907,3 +1119,91 @@ class DesktopModal {
this.modal.modal("hide");
}
}
class IconsPane {
constructor() {
this.wrapper = $($(".desktop-container .icons-container").get(0));
}
show() {
this.wrapper.removeClass("hidden");
if (this.grid) {
this.grid.icons_data = frappe.pages.desktop.desktop_page.hidden_icons;
this.grid.update_grid();
return;
}
this.wrapper.append(
"<span style='margin-top: 10px; margin-bottom: 20px'>Removed Icons</span>"
);
this.grid = new DesktopIconGrid({
name: "hidden-icons-grid",
wrapper: this.wrapper,
icons_data: frappe.pages.desktop.desktop_page.hidden_icons,
row_size: 6,
edit_mode: true,
compact: true,
is_pane: true,
});
this.setup();
}
hide() {
this.wrapper.addClass("hidden");
}
setup() {
this.setup_close_button();
}
setup_close_button() {
const me = this;
this.wrapper.find(".close-button").on("click", function () {
me.hide();
});
}
}
class InlineEditor {
constructor(container, initialValue = "", onRename = () => {}) {
this.container = container;
this.initialValue = initialValue;
this.onRename = onRename;
this.render();
this.bindEvents();
}
render() {
this.container.html(`
<div class="title-widget">
<div class="title-input-label">
<span>${__(this.initialValue)}</span>
</div>
<div class="title-input-wrapper">
<input class="title-input">
</div>
</div>
`);
this.input = this.container.find(".title-input");
this.label = this.container.find(".title-input-label");
}
bindEvents() {
this.container.on("click", () => {
this.label.css("visibility", "hidden");
this.input.focus().select();
});
this.input.on("keydown", (event) => {
if (event.key === "Enter") {
const newValue = this.input.val().trim();
this.input.css("display", "none");
this.label.css("visibility", "visible");
this.label.find("span").text(newValue);
this.onRename(this.initialValue, newValue, this);
}
});
this.input.on("blur", () => {
this.label.css("visibility", "visible");
});
}
}

View file

@ -13,8 +13,9 @@ def get_context(context):
if not brand_logo:
brand_logo = frappe.get_hooks("app_logo_url", app_name="frappe")[0]
context.brand_logo = brand_logo
context.desktop_icons = get_desktop_icons()
context.current_user = frappe.session.user
# check if system is mac or not
context.is_mac = sys.platform == "darwin"
try:
context.desktop_layout = frappe.get_doc("Desktop Layout", frappe.session.user).layout or {}
except frappe.DoesNotExistError:
frappe.clear_last_message()
context.desktop_layout = {}
return context

View file

@ -286,11 +286,13 @@ def create_or_update_user(args): # nosemgrep
if user := frappe.db.get_value("User", email, ["first_name", "last_name"], as_dict=True):
if user.first_name != first_name or user.last_name != last_name:
User = frappe.qb.DocType("User")
(
frappe.qb.update("User")
.set("first_name", first_name)
.set("last_name", last_name)
.set("full_name", args.get("full_name"))
frappe.qb.update(User)
.set(User.first_name, first_name)
.set(User.last_name, last_name)
.set(User.full_name, args.get("full_name"))
.where(User.name == email)
).run()
else:
_mute_emails, frappe.flags.mute_emails = frappe.flags.mute_emails, True

View file

@ -16,6 +16,8 @@ from frappe.utils import cint, cstr, escape_html, unique
from frappe.utils.caching import http_cache
from frappe.utils.data import make_filter_tuple
PAGE_LENGTH_FOR_LINK_VALIDATION = 25_000
def sanitize_searchfield(searchfield: str):
if not searchfield:
@ -76,6 +78,7 @@ def search_widget(
ignore_user_permissions: bool = False,
*,
link_fieldname: str | None = None,
for_link_validation: bool = False,
):
if ignore_user_permissions:
if reference_doctype and link_fieldname:
@ -103,6 +106,9 @@ def search_widget(
if not query and doctype in standard_queries:
query = standard_queries[doctype][-1]
if filters is None:
filters = {}
if query: # Query = custom search query i.e. python function
try:
is_whitelisted(frappe.get_attr(query))
@ -134,15 +140,17 @@ 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):
if "include_disabled" in filters:
if filters["include_disabled"] == 1:
include_disabled = True
filters.pop("include_disabled")
filters = [make_filter_tuple(doctype, key, value) for key, value in filters.items()]
elif filters is None:
filters = []
if for_link_validation:
filters.append([doctype, "name", "=", txt])
or_filters = []
# build from doctype
@ -189,7 +197,7 @@ def search_widget(
# `idx` is number of times a document is referred, check link_count.py
order_by = f"idx desc, {order_by_based_on_meta}"
if not meta.translated_doctype:
if not for_link_validation and not meta.translated_doctype:
_txt = frappe.db.escape((txt or "").replace("%", "").replace("@", ""))
# locate returns 0 if string is not found, convert 0 to null and then sort null to end in order by
_relevance_expr = {"DIV": [1, {"NULLIF": [{"LOCATE": [_txt, "name"]}, 0]}]}
@ -214,29 +222,30 @@ def search_widget(
strict=False,
)
if meta.translated_doctype:
# Filtering the values array so that query is included in very element
values = (
result
for result in values
if any(
re.search(f"{re.escape(txt)}.*", _(cstr(value)) or "", re.IGNORECASE)
for value in (result.values() if as_dict else result)
if not for_link_validation:
if meta.translated_doctype:
# Filtering the values array so that query is included in very element
values = (
result
for result in values
if any(
re.search(f"{re.escape(txt)}.*", _(cstr(value)) or "", re.IGNORECASE)
for value in (result.values() if as_dict else result)
)
)
)
# Sorting the values array so that relevant results always come first
# This will first bring elements on top in which query is a prefix of element
# Then it will bring the rest of the elements and sort them in lexicographical order
values = sorted(values, key=lambda x: relevance_sorter(x, txt, as_dict))
# Sorting the values array so that relevant results always come first
# This will first bring elements on top in which query is a prefix of element
# Then it will bring the rest of the elements and sort them in lexicographical order
values = sorted(values, key=lambda x: relevance_sorter(x, txt, as_dict))
# remove _relevance from results
if not meta.translated_doctype:
if as_dict:
for r in values:
r.pop("_relevance", None)
else:
values = [r[:-1] for r in values]
# remove _relevance from results
if not meta.translated_doctype:
if as_dict:
for r in values:
r.pop("_relevance", None)
else:
values = [r[:-1] for r in values]
return values
@ -329,8 +338,8 @@ def build_for_autosuggest(res: list[tuple], doctype: str) -> list[LinkSearchResu
item = list(item)
if len(item) == 1:
item = [item[0], item[0]]
label = item[1] # use title as label
item[1] = item[0] # show name in description instead of title
label = _(item[1]) if meta.translated_doctype else item[1]
item[1] = item[0]
if len(item) >= 3 and item[2] == label:
# remove redundant title ("label") value
@ -342,7 +351,9 @@ def build_for_autosuggest(res: list[tuple], doctype: str) -> list[LinkSearchResu
results.append(autosuggest_row)
else:
results.extend({"value": item[0], "description": to_string(item[1:])} for item in res)
for item in res:
label = _(item[0]) if meta.translated_doctype else item[0]
results.append({"value": item[0], "description": to_string(item[1:]), "label": label})
return results
@ -382,7 +393,7 @@ def get_names_for_mentions(search_term):
def get_users_for_mentions():
return frappe.get_all(
"User",
fields=["name as id", "full_name as value"],
fields=["name as id", "full_name as value", "email"],
filters={
"name": ["not in", ("Administrator", "Guest")],
"allowed_in_mentions": True,

View file

@ -1,21 +0,0 @@
{
"app": "frappe",
"creation": "2025-11-25 13:27:21.246918",
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "folder-open",
"icon_type": "Link",
"idx": 0,
"label": "Productivity",
"link_to": "Productivity",
"link_type": "Workspace Sidebar",
"modified": "2026-01-01 20:07:01.152305",
"modified_by": "Administrator",
"name": "Productivity",
"owner": "Administrator",
"parent_icon": "Framework",
"restrict_removal": 0,
"roles": [],
"standard": 1
}

View file

@ -150,6 +150,8 @@ def sendmail(
email_read_tracker_url=None,
x_priority: Literal[1, 3, 5] = 3,
email_headers=None,
raw_html=False,
add_css=True,
) -> EmailQueue | None:
"""Send email using user's default **Email Account** or global default **Email Account**.
@ -179,6 +181,8 @@ def sendmail(
:param with_container: Wraps email inside a styled container
:param x_priority: 1 = HIGHEST, 3 = NORMAL, 5 = LOWEST
:param email_headers: Additional headers to be added in the email, e.g. {"X-Custom-Header": "value"} or {"Custom-Header": "value"}. Automatically prepends "X-" to the header name if not present.
:param raw_html: Whether to treat email template as a complete HTML file
:param add_css: Whether to add CSS from hooks/email_css to the email template
"""
from frappe.utils.jinja import get_email_from_template
@ -238,11 +242,13 @@ def sendmail(
email_read_tracker_url=email_read_tracker_url,
x_priority=x_priority,
email_headers=email_headers,
raw_html=raw_html,
add_css=add_css,
)
# build email queue and send the email if send_now is True.
q = builder.process(send_now=False)
if now:
if now and q:
frappe.db.after_commit.add(q.send)
return q

View file

@ -25,7 +25,8 @@
"expose_recipients",
"attachments",
"retry",
"email_account"
"email_account",
"raw_html"
],
"fields": [
{
@ -148,13 +149,21 @@
"fieldtype": "Code",
"label": "Unsubscribe Params",
"read_only": 1
},
{
"default": "0",
"description": "Raw HTML emails are rendered as complete Jinja templates. Otherwise, emails are wrapped in the standard.html email template, which inserts brand_logo, header and footer.",
"fieldname": "raw_html",
"fieldtype": "Check",
"label": "Send As Raw HTML",
"read_only": 1
}
],
"icon": "fa fa-envelope",
"idx": 1,
"in_create": 1,
"links": [],
"modified": "2025-03-07 15:56:13.341958",
"modified": "2026-01-06 05:45:35.503215",
"modified_by": "Administrator",
"module": "Email",
"name": "Email Queue",
@ -175,4 +184,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View file

@ -60,6 +60,7 @@ class EmailQueue(Document):
message: DF.Code | None
message_id: DF.SmallText | None
priority: DF.Int
raw_html: DF.Check
recipients: DF.Table[EmailQueueRecipient]
reference_doctype: DF.Link | None
reference_name: DF.Data | None
@ -518,6 +519,8 @@ class QueueBuilder:
email_read_tracker_url=None,
x_priority: Literal[1, 3, 5] = 3,
email_headers=None,
raw_html=False,
add_css=True,
):
"""Add email to sending queue (Email Queue)
@ -545,6 +548,8 @@ class QueueBuilder:
:param email_read_tracker_url: A URL for tracking whether an email is read by the recipient.
:param x_priority: 1 = HIGHEST, 3 = NORMAL, 5 = LOWEST
:param email_headers: Additional headers to be added in the email, e.g. {"X-Custom-Header": "value"} or {"Custom-Header": "value"}. Automatically prepends "X-" to the header name if not present.
:param raw_html: Whether to treat email template as a complete HTML file
:param add_css: Add default CSS from hooks/email_css to the email template (default True)
"""
self._unsubscribe_method = unsubscribe_method
@ -582,6 +587,8 @@ class QueueBuilder:
self.print_letterhead = print_letterhead
self.email_read_tracker_url = email_read_tracker_url
self.email_headers = email_headers
self.raw_html = raw_html
self.add_css = add_css
@property
def unsubscribe_method(self):
@ -638,6 +645,8 @@ class QueueBuilder:
email_account=email_account,
unsubscribe_link=self.unsubscribe_message(),
with_container=self.with_container,
raw_html=self.raw_html,
add_css=self.add_css,
)
def should_include_unsubscribe_link(self):
@ -843,6 +852,8 @@ class QueueBuilder:
"show_as_bcc": ",".join(self.final_bcc()),
"email_account": email_account_name or None,
"email_read_tracker_url": self.email_read_tracker_url,
"raw_html": self.raw_html,
"add_css": self.add_css,
}
if include_recipients:

View file

@ -37,19 +37,37 @@ class EmailTemplate(Document):
def get_formatted_response(self, doc):
return frappe.render_template(self.response_, doc)
def get_formatted_email(self, doc):
def get_formatted_email(self, doc, sender=None):
if isinstance(doc, str):
doc = json.loads(doc)
if self.use_html:
doc = self.inject_email_account(doc, sender)
return {
"subject": self.get_formatted_subject(doc),
"message": self.get_formatted_response(doc),
}
def inject_email_account(self, doc, sender=None):
from frappe.email.doctype.email_account.email_account import EmailAccount
from frappe.email.email_body import get_footer, get_signature
if sender:
kwargs = {"match_by_email": sender}
else:
kwargs = {"match_by_doctype": doc.get("doctype")}
if email_account := EmailAccount.find_outgoing(**kwargs):
doc.update(
{"email_signature": get_signature(email_account), "email_footer": get_footer(email_account)}
)
return doc
@frappe.whitelist()
def get_email_template(template_name, doc):
def get_email_template(template_name, doc, sender=None):
"""Return the processed HTML of a email template with the given doc"""
email_template = frappe.get_doc("Email Template", template_name)
return email_template.get_formatted_email(doc)
return email_template.get_formatted_email(doc, sender=sender)

View file

@ -381,29 +381,38 @@ def get_formatted_html(
unsubscribe_link: frappe._dict | None = None,
sender=None,
with_container=False,
raw_html=False,
add_css=True,
):
email_account = email_account or EmailAccount.find_outgoing(match_by_email=sender)
rendered_email = frappe.get_template("templates/emails/standard.html").render(
{
"brand_logo": get_brand_logo(email_account) if with_container or header else None,
"with_container": with_container,
"site_url": get_url(),
"header": get_header(header),
"content": message,
"footer": get_footer(email_account, footer),
"title": subject,
"print_html": print_html,
"subject": subject,
}
)
params = {
"site_url": get_url(),
"title": subject,
"print_html": print_html,
"subject": subject,
}
if raw_html:
rendered_email = frappe.render_template(message, params)
else:
params.update(
{
"brand_logo": get_brand_logo(email_account) if with_container or header else None,
"with_container": with_container,
"header": get_header(header),
"content": message,
"footer": get_footer(email_account, footer),
}
)
rendered_email = frappe.get_template("templates/emails/standard.html").render(params)
html = scrub_urls(rendered_email)
if unsubscribe_link:
html = html.replace("<!--unsubscribe link here-->", unsubscribe_link.html)
return inline_style_in_html(html)
return inline_style_in_html(html, add_css=add_css)
@frappe.whitelist()
@ -418,17 +427,20 @@ def get_email_html(template, args, subject, header=None, with_container=False):
return get_formatted_html(subject, email[0], header=header, with_container=with_container)
def inline_style_in_html(html):
def inline_style_in_html(html, add_css=True):
"""Convert email.css and html to inline-styled html."""
from premailer import Premailer
from frappe.utils.jinja_globals import bundled_asset
# get email css files from hooks
css_files = frappe.get_hooks("email_css")
css_files = [bundled_asset(path) for path in css_files]
css_files = [path.lstrip("/") for path in css_files]
css_files = [css_file for css_file in css_files if os.path.exists(os.path.abspath(css_file))]
if add_css:
# get email css files from hooks
css_files = frappe.get_hooks("email_css")
css_files = [bundled_asset(path) for path in css_files]
css_files = [path.lstrip("/") for path in css_files]
css_files = [css_file for css_file in css_files if os.path.exists(os.path.abspath(css_file))]
else:
css_files = None
p = Premailer(
html=html, external_styles=css_files, strip_important=False, allow_loading_external_files=True

View file

@ -8,7 +8,7 @@ app_publisher = "Frappe Technologies"
app_description = "Full stack web framework with Python, Javascript, MariaDB, Redis, Node"
app_license = "MIT"
app_logo_url = "/assets/frappe/images/frappe-framework-logo.svg"
develop_version = "15.x.x-develop"
develop_version = "17.x.x-develop"
app_home = "/app/build"
app_email = "developers@frappe.io"
@ -217,7 +217,7 @@ scheduler_events = {
"frappe.automation.doctype.reminder.reminder.send_reminders",
"frappe.model.utils.link_count.update_link_count",
"frappe.search.sqlite_search.build_index_if_not_exists",
"frappe.pulse.client.send_queued_events",
"frappe.utils.telemetry.pulse.client.send_queued_events",
],
# 10 minutes
"0/10 * * * *": [

View file

@ -225,7 +225,7 @@ def get_openid_configuration():
@frappe.whitelist(allow_guest=True)
def introspect_token(token=None, token_type_hint=None):
def introspect_token(token: str, token_type_hint=None):
if token_type_hint not in ["access_token", "refresh_token"]:
token_type_hint = "access_token"
try:

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: developers@frappe.io\n"
"POT-Creation-Date: 2025-12-21 09:35+0000\n"
"PO-Revision-Date: 2026-01-01 22:27\n"
"PO-Revision-Date: 2026-01-07 23:51\n"
"Last-Translator: developers@frappe.io\n"
"Language-Team: Persian\n"
"MIME-Version: 1.0\n"
@ -552,7 +552,18 @@ msgid "<pre>* * * * *\n"
"* - Any value\n"
"/ - Step values\n"
"</pre>\n"
msgstr ""
msgstr "<pre>* * * * *\n"
"┬ ┬ ┬ ┬ ┬\n"
"│ │ │ │ │\n"
"│ │ │ │ └ روز هفته (0 - 6) (0 یکشنبه است)\n"
"│ │ │ └───── ماه (1 - 12)\n"
"│ │ └─────── روز ماه (1 - 31)\n"
"│ └───────────────── ساعت (0 - 23)\n"
"└───────────────────── دقیقه (0 - 59)\n\n"
"---\n\n"
"* - هر مقداری\n"
"/ - مقادیر گام\n"
"</pre>\n"
#. Content of the 'Example' (HTML) field in DocType 'Workflow Transition'
#: frappe/workflow/doctype/workflow_transition/workflow_transition.json
@ -5421,7 +5432,7 @@ msgstr "گزینه‌های تماس، مانند «پرسمان فروش، در
#. Label of the contacts (Small Text) field in DocType 'OAuth Client'
#: frappe/integrations/doctype/oauth_client/oauth_client.json
msgid "Contacts"
msgstr "مخاطب"
msgstr "مخاطبین"
#: frappe/utils/change_log.py:362
msgid "Contains {0} security fix"
@ -6088,7 +6099,7 @@ msgstr "گزارش سفارشی"
#: frappe/desk/desktop.py:525
msgid "Custom Reports"
msgstr "گزارش های سفارشی"
msgstr "گزارشهای سفارشی"
#. Name of a DocType
#: frappe/core/doctype/custom_role/custom_role.json
@ -12026,7 +12037,7 @@ msgstr "پنهان کردن پاورقی"
#. 'System Settings'
#: frappe/core/doctype/system_settings/system_settings.json
msgid "Hide footer in auto email reports"
msgstr "پاورقی را در گزارش های ایمیل خودکار مخفی کنید"
msgstr "پاورقی را در گزارشهای ایمیل خودکار مخفی کنید"
#. Label of the hide_footer_signup (Check) field in DocType 'Website Settings'
#: frappe/website/doctype/website_settings/website_settings.json
@ -16366,7 +16377,7 @@ msgstr "موزیلا از :has() پشتیبانی نمی‌کند، بنابرا
#: frappe/desk/page/setup_wizard/install_fixtures.py:43
msgid "Mr"
msgstr ""
msgstr "آقا"
#: frappe/desk/page/setup_wizard/install_fixtures.py:47
msgid "Mrs"
@ -18159,7 +18170,7 @@ msgstr "فقط یک {0} را می‌توان به عنوان اصلی تنظیم
#: frappe/desk/reportview.py:360
msgid "Only reports of type Report Builder can be deleted"
msgstr "فقط گزارش هایی از نوع Report Builder قابل حذف هستند"
msgstr "فقط گزارشهایی از نوع Report Builder قابل حذف هستند"
#: frappe/desk/reportview.py:331
msgid "Only reports of type Report Builder can be edited"
@ -21780,11 +21791,11 @@ msgstr "گزارش:"
#: frappe/core/doctype/system_settings/system_settings.json
#: frappe/public/js/frappe/ui/toolbar/search_utils.js:591
msgid "Reports"
msgstr "گزارش ها"
msgstr "گزارشها"
#: frappe/patches/v14_0/update_workspace2.py:50
msgid "Reports & Masters"
msgstr "گزارش ها و مستندات"
msgstr "گزارشها و مستندات"
#: frappe/public/js/frappe/views/reports/query_report.js:940
msgid "Reports already in Queue"
@ -23079,7 +23090,7 @@ msgstr "مشاهده تمام فعالیت ها"
#: frappe/public/js/frappe/views/reports/query_report.js:866
msgid "See all past reports."
msgstr "مشاهده تمام گزارش های گذشته"
msgstr "مشاهده تمام گزارشهای گذشته"
#: frappe/public/js/frappe/form/form.js:1247
#: frappe/website/doctype/contact_us_settings/contact_us_settings.js:4
@ -24996,11 +25007,11 @@ msgstr "سبک چاپ استاندارد قابل تغییر نیست. لطفا
#: frappe/desk/reportview.py:357
msgid "Standard Reports cannot be deleted"
msgstr "گزارش های استاندارد را نمی‌توان حذف کرد"
msgstr "گزارشهای استاندارد را نمی‌توان حذف کرد"
#: frappe/desk/reportview.py:328
msgid "Standard Reports cannot be edited"
msgstr "گزارش های استاندارد قابل ویرایش نیستند"
msgstr "گزارشهای استاندارد قابل ویرایش نیستند"
#. Label of the standard_menu_items (Section Break) field in DocType 'Portal
#. Settings'
@ -31485,7 +31496,7 @@ msgstr "گزارش {0}"
#: frappe/public/js/frappe/views/reports/query_report.js:967
msgid "{0} Reports"
msgstr "{0} گزارش ها"
msgstr "{0} گزارشها"
#: frappe/public/js/frappe/views/kanban/kanban_settings.js:26
msgid "{0} Settings"

View file

@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: developers@frappe.io\n"
"POT-Creation-Date: 2025-12-21 09:35+0000\n"
"PO-Revision-Date: 2025-12-24 20:23\n"
"PO-Revision-Date: 2026-01-19 02:39\n"
"Last-Translator: developers@frappe.io\n"
"Language-Team: Hungarian\n"
"MIME-Version: 1.0\n"
@ -32,7 +32,7 @@ msgstr "\"Cégtörténet\""
#: frappe/core/doctype/data_export/exporter.py:202
msgid "\"Parent\" signifies the parent table in which this row must be added"
msgstr "\"Szülő\" jelenti azt a szülő táblát, amelyhez ezt a sort hozzá kell adni"
msgstr "\"Szülő\" jelenti azt a fő táblát, amelyhez ezt a sort hozzá kell adni"
#. Description of the 'Team Members Heading' (Data) field in DocType 'About Us
#. Settings'
@ -133,7 +133,15 @@ msgid "0 - too guessable: risky password.\n"
"3 - safely unguessable: moderate protection from offline slow-hash scenario.\n"
"<br>\n"
"4 - very unguessable: strong protection from offline slow-hash scenario."
msgstr ""
msgstr "0 - túl kitalálható: kockázatos jelszó.\n"
"<br>\n"
"1 - nagyon kitalálható: védelem a korlátozott online támadások ellen. \n"
"<br>\n"
"2 - némileg kitalálható: védelem a nem korlátozott online támadások ellen.\n"
"<br>\n"
"3 - biztonságosan kitalálhatatlan: mérsékelt védelem az offline lassú hash forgatókönyv ellen.\n"
"<br>\n"
"4 - nagyon kitalálhatatlan: erős védelem az offline lassú hash forgatókönyv ellen."
#. Description of the 'Priority' (Int) field in DocType 'Web Page'
#: frappe/website/doctype/web_page/web_page.json
@ -590,10 +598,10 @@ msgstr "<h4>Email válasz példa</h4>\n\n"
"A {{ name }} tranzakció túllépte az esedékességi határidőt. Kérjük, tegye meg a szükséges intézkedéseket.\n\n"
"Részletek\n\n"
"- Ügyfél: {{ customer }}\n"
"- Amount: {{ grand_total }}\n"
"- Összeg: {{ grand_total }}\n"
"</pre>\n\n"
"<h4>Hogyan kaphatom meg a mezőneveket?</h4>\n\n"
"<p>Az e-mail sablonban használható mezőnevek annak a dokumentumnak a mezői, amelyből az e-mailt küldi. Bármely dokumentum mezőit megtudhatja a Beállítások &gt; Formanyomtatványnézet testreszabása és a dokumentum típusának kiválasztása (pl. Értékesítési számla) segítségével.</p>\n\n"
"<p>Az e-mail sablonban használható mezőnevek annak a dokumentumnak a mezői, amelyből az e-mailt küldi. Bármely dokumentum mezőit megtudhatja a Beállítások &gt; Űrlap Testraszabás és a dokumentum típusának kiválasztása (pl. Értékesítési számla) segítségével.</p>\n\n"
"<h4>Sablonkészítés</h4>\n\n"
"<p>A sablonok összeállítása a Jinja Templating Language segítségével történik. Ha többet szeretne megtudni a Jinjáról, <a class=\"strong\" href=\"http://jinja.pocoo.org/docs/dev/templates/\">olvassa el ezt a dokumentációt</a>.</p>\n"
@ -890,7 +898,7 @@ msgstr "API"
#. Label of the api_access (Section Break) field in DocType 'User'
#: frappe/core/doctype/user/user.json
msgid "API Access"
msgstr "API hozzáférés"
msgstr "API Hozzáférés"
#. Label of the api_endpoint (Data) field in DocType 'Social Login Key'
#: frappe/integrations/doctype/social_login_key/social_login_key.json
@ -918,7 +926,7 @@ msgstr "Az API végpont argumentumainak érvényes JSON-ként kell lenniük"
#: frappe/integrations/doctype/google_settings/google_settings.json
#: frappe/integrations/doctype/push_notification_settings/push_notification_settings.json
msgid "API Key"
msgstr "API kulcs"
msgstr "API Kulcs"
#. Description of the 'Authentication' (Section Break) field in DocType 'Push
#. Notification Settings'
@ -959,7 +967,7 @@ msgstr "API Kérés Napló"
#: frappe/email/doctype/email_account/email_account.json
#: frappe/integrations/doctype/push_notification_settings/push_notification_settings.json
msgid "API Secret"
msgstr "API Secret"
msgstr "API Jelszó"
#. Option for the 'Default Sort Order' (Select) field in DocType 'DocType'
#. Option for the 'Sort Order' (Select) field in DocType 'Customize Form'
@ -1462,7 +1470,7 @@ msgstr "Új Fül Hozzáadása"
#: frappe/utils/password_strength.py:191
msgid "Add numbers or special characters."
msgstr ""
msgstr "Számok vagy speciális karakterek hozzáadása."
#: frappe/public/js/print_format_builder/PrintFormatSection.vue:125
msgid "Add page break"
@ -1610,7 +1618,7 @@ msgstr "Címek és Kapcsolatok"
#. Description of a DocType
#: frappe/custom/doctype/client_script/client_script.json
msgid "Adds a custom client script to a DocType"
msgstr "Egyéni ügyfélszkript hozzáadása egy DocType-hoz"
msgstr "Egyéni kliens szkript hozzáadása egy DocType-hoz"
#. Description of a DocType
#: frappe/custom/doctype/custom_field/custom_field.json
@ -2676,7 +2684,7 @@ msgstr "Kérdez"
#: frappe/public/js/frappe/form/templates/form_sidebar.html:68
msgid "Assign"
msgstr ""
msgstr "Hozzárendel"
#. Label of the assign_condition (Code) field in DocType 'Assignment Rule'
#: frappe/automation/doctype/assignment_rule/assignment_rule.json
@ -3432,7 +3440,7 @@ msgstr "Háttérnyomtatás (>25 dokumentum esetén szükséges)"
#: frappe/core/doctype/system_settings/system_settings.json
#: frappe/desk/doctype/system_health_report/system_health_report.json
msgid "Background Workers"
msgstr "Háttér Munkások"
msgstr "Háttérmunkások"
#: frappe/desk/page/backups/backups.js:28
msgid "Backup Encryption Key"
@ -4465,7 +4473,7 @@ msgstr "Levélfej Módosítása"
#. Label of the change_password (Section Break) field in DocType 'User'
#: frappe/core/doctype/user/user.json
msgid "Change Password"
msgstr "Változtass Jelszót"
msgstr "Jelszó Módosítás"
#: frappe/public/js/print_format_builder/print_format_builder.bundle.js:27
msgid "Change Print Format"
@ -4870,7 +4878,7 @@ msgstr "Ügyfél Metaadatok"
#: frappe/custom/doctype/doctype_layout/doctype_layout.json
#: frappe/website/doctype/web_page/web_page.js:103
msgid "Client Script"
msgstr "Ügyfél Szkript"
msgstr "Kliens Szkript"
#. Label of the client_secret (Password) field in DocType 'Connected App'
#. Label of the client_secret (Password) field in DocType 'Google Settings'
@ -5212,7 +5220,7 @@ msgstr "A közneveket és vezetékneveket könnyű kitalálni."
#: frappe/utils/password_strength.py:190
msgid "Common words are easy to guess."
msgstr ""
msgstr "Gyakori szavakat könnyű kitalálni."
#. Name of a DocType
#. Option for the 'Communication Type' (Select) field in DocType
@ -5907,7 +5915,7 @@ msgstr "Új Kanban Tábla Létrehozása"
#: frappe/public/js/frappe/list/list_filter.js:101
msgid "Create Saved Filter"
msgstr ""
msgstr "Szűrő Mentése"
#: frappe/core/doctype/user/user.js:271
msgid "Create User Email"
@ -5961,7 +5969,7 @@ msgstr "Hozzon létre munkafolyamatot vizuálisan a Munkafolyamat Készítő seg
#: frappe/core/doctype/comment/comment.json
#: frappe/public/js/frappe/views/file/file_view.js:371
msgid "Created"
msgstr "Létrehozva"
msgstr "Létrehozva ekkor"
#. Label of the created_at (Datetime) field in DocType 'Submission Queue'
#: frappe/core/doctype/submission_queue/submission_queue.json
@ -9152,7 +9160,7 @@ msgstr "Közösségi Bejelentkezés Engedélyezése"
#: frappe/website/doctype/website_settings/website_settings.js:139
msgid "Enable Tracking Page Views"
msgstr "Oldalmegtekintések Követésének Engedélyezése"
msgstr "Oldalmegtekintés Számának Követése"
#. Label of the enable_two_factor_auth (Check) field in DocType 'System
#. Settings'
@ -11109,7 +11117,7 @@ msgstr "Frappe Framework"
#: frappe/public/js/frappe/ui/theme_switcher.js:59
msgid "Frappe Light"
msgstr "Frappe Light"
msgstr "Frappe Világos"
#. Option for the 'Service' (Select) field in DocType 'Email Account'
#: frappe/email/doctype/email_account/email_account.json
@ -12020,7 +12028,7 @@ msgstr "Súgó HTML"
#. Description of the 'Content' (Text Editor) field in DocType 'Note'
#: frappe/desk/doctype/note/note.json
msgid "Help: To link to another record in the system, use \"/desk/note/[Note Name]\" as the Link URL. (don't use \"http://\")"
msgstr ""
msgstr "Segítség: A rendszer egy másik rekordjára való hivatkozáshoz használja a \"/desk/note/[jegyzet neve]\" URL-címet a hivatkozás URL-jeként. (ne használja a \"http://\" előtagot)"
#. Label of the helpful (Int) field in DocType 'Help Article'
#: frappe/website/doctype/help_article/help_article.json
@ -12515,7 +12523,7 @@ msgstr "Ha engedélyezve van, a dokumentum módosításai nyomon követésre ker
#. Description of the 'Track Views' (Check) field in DocType 'DocType'
#: frappe/core/doctype/doctype/doctype.json
msgid "If enabled, document views are tracked, this can happen multiple times"
msgstr "Ha engedélyezve van, a dokumentum megtekintések nyomon követésre kerülnek, ez többször is megtörténhet"
msgstr "Ha engedélyezve van, a dokumentum megtekintések száma nyomon követésre kerül"
#. Description of the 'Only allow System Managers to upload public files'
#. (Check) field in DocType 'System Settings'
@ -14695,7 +14703,7 @@ msgstr "Utoljára Módosította"
#: frappe/model/meta.py:56 frappe/public/js/frappe/model/meta.js:212
#: frappe/public/js/frappe/model/model.js:126
msgid "Last Updated On"
msgstr "Utoljára Módosítva ekkor"
msgstr "Frissítve ekkor"
#. Label of the last_user (Link) field in DocType 'Assignment Rule'
#: frappe/automation/doctype/assignment_rule/assignment_rule.json
@ -15194,12 +15202,12 @@ msgstr "Listaszűrő"
#: frappe/custom/doctype/customize_form/customize_form.json
#: frappe/website/doctype/web_form/web_form.json
msgid "List Settings"
msgstr "Lista Beállításai"
msgstr "Lista Beállítások"
#: frappe/public/js/frappe/list/list_view.js:2067
msgctxt "Button in list view menu"
msgid "List Settings"
msgstr "Lista Beállításai"
msgstr "Lista Beállítások"
#: frappe/public/js/frappe/list/base_list.js:203
msgid "List View"
@ -15208,7 +15216,7 @@ msgstr "Lista Nézet"
#. Name of a DocType
#: frappe/desk/doctype/list_view_settings/list_view_settings.json
msgid "List View Settings"
msgstr "Lista Nézet Beállításai"
msgstr "Listanézet Beállítások"
#: frappe/public/js/frappe/ui/toolbar/awesome_bar.js:223
msgid "List a document type"
@ -16463,7 +16471,7 @@ msgstr "További információk"
#: frappe/core/doctype/user/user.json
#: frappe/core/web_form/edit_profile/edit_profile.json
msgid "More Information"
msgstr "Több Információ"
msgstr "További információk"
#: frappe/website/doctype/help_article/templates/help_article.html:19
#: frappe/website/doctype/help_article/templates/help_article.html:33
@ -16478,7 +16486,7 @@ msgstr "További tartalom az oldal aljára."
#: frappe/public/js/frappe/ui/sort_selector.js:193
msgid "Most Used"
msgstr "Legtöbbet használt"
msgstr "Leggyakrabban használt"
#: frappe/utils/password.py:75
msgid "Most probably your password is too long."
@ -17493,7 +17501,7 @@ msgstr "Nem találtunk sablont a következő elérési útvonalon: {0}"
#: frappe/core/page/permission_manager/permission_manager.js:362
msgid "No user has the role <strong>{0}</strong>"
msgstr ""
msgstr "Egyetlen felhasználónak sincs szerepköre <strong>{0}</strong>"
#: frappe/public/js/frappe/form/controls/multiselect_list.js:276
msgid "No values to show"
@ -19119,7 +19127,7 @@ msgstr "Jelszó nem található ehhez: {0} {1} {2}"
#: frappe/core/doctype/user/user.py:1307
msgid "Password requirements not met"
msgstr ""
msgstr "Jelszókövetelmények nem teljesültek"
#: frappe/core/doctype/user/user.py:1140
msgid "Password reset instructions have been sent to {}'s email"
@ -19318,7 +19326,7 @@ msgstr "Jogosultsági Szintek"
#. Name of a DocType
#: frappe/core/doctype/permission_log/permission_log.json
msgid "Permission Log"
msgstr "Engedélynapló"
msgstr "Jogosultság Napló"
#. Label of a shortcut in the Users Workspace
#: frappe/core/workspace/users/users.json
@ -21039,7 +21047,7 @@ msgstr "Olvass El"
#. 'System Health Report'
#: frappe/desk/doctype/system_health_report/system_health_report.json
msgid "Realtime (SocketIO)"
msgstr "Valós Idejű (SocketIO)"
msgstr "Valós idejű kommunikáció (SocketIO)"
#. Label of the reason (Long Text) field in DocType 'Unhandled Email'
#: frappe/email/doctype/unhandled_email/unhandled_email.json
@ -21466,13 +21474,13 @@ msgstr "Token Frissítése"
#: frappe/public/js/frappe/list/list_view.js:537
msgctxt "Document count in list view"
msgid "Refreshing"
msgstr "Újratöltés"
msgstr "Frissítés"
#: frappe/core/doctype/system_settings/system_settings.js:57
#: frappe/core/doctype/user/user.js:369
#: frappe/desk/page/setup_wizard/setup_wizard.js:211
msgid "Refreshing..."
msgstr "Újratöltés..."
msgstr "Frissítés..."
#: frappe/core/doctype/user/user.py:1083
msgid "Registered but disabled"
@ -22902,7 +22910,7 @@ msgstr "Mentve"
#: frappe/public/js/frappe/list/list_filter.js:20
msgid "Saved Filters"
msgstr "Elmentett szűrők"
msgstr "Mentett Szűrések"
#: frappe/public/js/frappe/list/list_settings.js:41
#: frappe/public/js/frappe/views/kanban/kanban_settings.js:47
@ -23012,7 +23020,7 @@ msgstr "Ütemező"
#: frappe/core/doctype/scheduler_event/scheduler_event.json
#: frappe/core/doctype/server_script/server_script.json
msgid "Scheduler Event"
msgstr "Ütemező Esemény"
msgstr "Ütemezett Esemény"
#: frappe/core/doctype/data_import/data_import.py:124
msgid "Scheduler Inactive"
@ -23801,7 +23809,7 @@ msgstr "Feladó Neve Mező"
#. Option for the 'Service' (Select) field in DocType 'Email Account'
#: frappe/email/doctype/email_account/email_account.json
msgid "Sendgrid"
msgstr "Küldési háló"
msgstr "Sendgrid"
#. Option for the 'Delivery Status' (Select) field in DocType 'Communication'
#. Option for the 'Status' (Select) field in DocType 'Email Queue'
@ -24471,7 +24479,7 @@ msgstr "Sortörések Megjelenítése Szakaszok után"
#: frappe/public/js/frappe/form/toolbar.js:443
msgid "Show Links"
msgstr "Hivatkozások Megjelenítése"
msgstr "Hivatkozások Megtekintése"
#. Label of the show_failed_logs (Check) field in DocType 'Data Import'
#: frappe/core/doctype/data_import/data_import.json
@ -24521,7 +24529,7 @@ msgstr "Kapcsolódó Hibák Megjelenítése"
#: frappe/core/doctype/prepared_report/prepared_report.js:43
#: frappe/core/doctype/report/report.js:16
msgid "Show Report"
msgstr "Jelentés Megjelenítése"
msgstr "Jelentés Megtekintése"
#. Label of the show_section_headings (Check) field in DocType 'Print Format'
#: frappe/printing/doctype/print_format/print_format.json
@ -25868,7 +25876,7 @@ msgstr "Kamera Váltása"
#: frappe/public/js/frappe/desk.js:96
#: frappe/public/js/frappe/ui/theme_switcher.js:11
msgid "Switch Theme"
msgstr "Téma Váltása"
msgstr "Témaváltás"
#: frappe/templates/includes/navbar/navbar_login.html:17
msgid "Switch To Desk"
@ -27487,7 +27495,7 @@ msgstr "A Google Névjegyek használatához engedélyezze a(z) {0} lehetőséget
#. 'Website Settings'
#: frappe/website/doctype/website_settings/website_settings.json
msgid "To use Google Indexing, enable <a href=\"/desk/google-settings\">Google Settings</a>."
msgstr ""
msgstr "A Google indexelés használatához engedélyezze a <a href=\"/desk/google-settings\">Google Beállításokat</a>."
#. Description of the 'Slack Channel' (Link) field in DocType 'Notification'
#: frappe/email/doctype/notification/notification.json
@ -27534,7 +27542,7 @@ msgstr "Oldalsáv be-/kikapcsolása"
#. Type: Action
#: frappe/hooks.py
msgid "Toggle Theme"
msgstr "Téma Váltása"
msgstr "Témaváltás"
#. Option for the 'Response Type' (Select) field in DocType 'OAuth Client'
#: frappe/integrations/doctype/oauth_client/oauth_client.json
@ -27654,7 +27662,7 @@ msgstr "Összesen"
#. Report'
#: frappe/desk/doctype/system_health_report/system_health_report.json
msgid "Total Background Workers"
msgstr "Összes Háttér Munkás"
msgstr "Összes Háttérmunkás"
#. Label of the total_errors (Int) field in DocType 'System Health Report'
#: frappe/desk/doctype/system_health_report/system_health_report.json
@ -27746,7 +27754,7 @@ msgstr "Lépések Követése"
#: frappe/core/doctype/doctype/doctype.json
#: frappe/custom/doctype/customize_form/customize_form.json
msgid "Track Views"
msgstr "Megtekintések Követése"
msgstr "Megtekintések Számának Követése"
#. Description of the 'Track Email Status' (Check) field in DocType 'Email
#. Account'
@ -28575,7 +28583,7 @@ msgstr "TLS használata"
#: frappe/utils/password_strength.py:191
msgid "Use a few uncommon words together."
msgstr ""
msgstr "Használjon együtt néhány szokatlan szót."
#: frappe/utils/password_strength.py:44
msgid "Use a few words, avoid common phrases."
@ -29329,7 +29337,7 @@ msgstr "Weboldal megtekintése"
#: frappe/core/page/permission_manager/permission_manager.js:395
msgid "View all {0} users"
msgstr ""
msgstr "Összes {0} felhasználó megtekintése"
#: frappe/www/confirm_workflow_action.html:12
msgid "View document"
@ -29451,7 +29459,7 @@ msgstr "Figyelmeztetés: A számláló frissítése dokumentumnév-ütközésekh
#: frappe/core/doctype/doctype/doctype.py:458
msgid "Warning: Usage of 'format:' is discouraged."
msgstr ""
msgstr "Figyelmeztetés: A 'format:' használata nem javasolt."
#: frappe/website/doctype/help_article/templates/help_article.html:24
msgid "Was this article helpful?"
@ -30467,7 +30475,7 @@ msgstr "A Dokumentumtípusok táblázatban csak a 3 egyéni doctype-pt állítha
#: frappe/handler.py:184
msgid "You can only upload JPG, PNG, GIF, PDF, TXT, CSV or Microsoft documents."
msgstr ""
msgstr "Csak JPG, PNG, GIF, PDF, TXT, CSV vagy Microsoft dokumentumokat tölthet fel."
#: frappe/core/doctype/data_export/exporter.py:199
msgid "You can only upload upto 5000 records in one go. (may be less in some cases)"
@ -31075,7 +31083,7 @@ msgstr "pl. pop.gmail.com / imap.gmail.com"
#. Account'
#: frappe/email/doctype/email_account/email_account.json
msgid "e.g. replies@yourcomany.com. All replies will come to this inbox."
msgstr "pl. valaszok@cegem.hu. Minden válasz ebbe a postafiókba fog jönni."
msgstr "pl. info@cegem.hu. Minden válasz ebbe a postafiókba fog jönni."
#. Description of the 'Outgoing Server' (Data) field in DocType 'Email Account'
#. Description of the 'Outgoing Server' (Data) field in DocType 'Email Domain'
@ -31684,7 +31692,7 @@ msgstr "{0} Lista"
#: frappe/public/js/frappe/list/list_settings.js:33
msgid "{0} List View Settings"
msgstr "{0} Listanézet Beállításai"
msgstr "{0} Listanézet Beállítások"
#: frappe/public/js/frappe/utils/pretty_date.js:37
msgid "{0} M"
@ -32280,7 +32288,7 @@ msgstr "{0} héttel ezelőtt"
#: frappe/core/page/permission_manager/permission_manager.js:378
msgid "{0} with the role <strong>{1}</strong>"
msgstr ""
msgstr "{0} a szerepkörrel: <strong>{1}</strong>"
#: frappe/public/js/frappe/utils/pretty_date.js:39
msgid "{0} y"

View file

@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: frappe\n"
"Report-Msgid-Bugs-To: developers@frappe.io\n"
"POT-Creation-Date: 2025-12-21 09:35+0000\n"
"PO-Revision-Date: 2025-12-24 20:24\n"
"PO-Revision-Date: 2026-01-11 00:40\n"
"Last-Translator: developers@frappe.io\n"
"Language-Team: Indonesian\n"
"MIME-Version: 1.0\n"
@ -22,13 +22,13 @@ msgstr ""
#. Condition'
#: frappe/core/doctype/document_naming_rule_condition/document_naming_rule_condition.json
msgid "!="
msgstr ""
msgstr "!="
#. Description of the 'Org History Heading' (Data) field in DocType 'About Us
#. Settings'
#: frappe/website/doctype/about_us_settings/about_us_settings.json
msgid "\"Company History\""
msgstr ""
msgstr "\"Riwayat Perusahaan\""
#: frappe/core/doctype/data_export/exporter.py:202
msgid "\"Parent\" signifies the parent table in which this row must be added"
@ -38,7 +38,7 @@ msgstr "\"Induk\" menandakan tabel induk di mana baris ini harus ditambahkan"
#. Settings'
#: frappe/website/doctype/about_us_settings/about_us_settings.json
msgid "\"Team Members\" or \"Management\""
msgstr ""
msgstr "\"Anggota Tim\" atau \"Manajemen\""
#: frappe/public/js/frappe/form/form.js:1093
msgid "\"amended_from\" field must be present to do an amendment."
@ -55,16 +55,16 @@ msgstr ""
#: frappe/core/report/database_storage_usage_by_tables/database_storage_usage_by_tables.js:36
msgid "${values.doctype_name} has been added to queue for optimization"
msgstr ""
msgstr "${values.doctype_name} telah ditambahkan ke antrian untuk optimasi"
#: frappe/public/js/frappe/ui/toolbar/about.js:11
msgid "&copy; Frappe Technologies Pvt. Ltd. and contributors"
msgstr ""
msgstr "&copy; Frappe Technologies Pvt. Ltd. dan kontributor"
#. Label of the head_html (Code) field in DocType 'Website Settings'
#: frappe/website/doctype/website_settings/website_settings.json
msgid "&lt;head&gt; HTML"
msgstr ""
msgstr "&lt;head&gt; HTML"
#: frappe/database/query.py:2100
msgid "'*' is only allowed in {0} SQL function(s)"
@ -72,7 +72,7 @@ msgstr ""
#: frappe/public/js/form_builder/store.js:206
msgid "'In Global Search' is not allowed for field {0} of type {1}"
msgstr ""
msgstr "'Dalam Pencarian Global' tidak diizinkan untuk bidang {0} dengan tipe {1}"
#: frappe/core/doctype/doctype/doctype.py:1369
msgid "'In Global Search' not allowed for type {0} in row {1}"
@ -80,7 +80,7 @@ msgstr "'Di Pencarian Global' tidak dibolehkan jenis {0} pada baris {1}"
#: frappe/public/js/form_builder/store.js:198
msgid "'In List View' is not allowed for field {0} of type {1}"
msgstr ""
msgstr "'Dalam Tampilan Daftar' tidak diizinkan untuk bidang {0} dengan tipe {1}"
#: frappe/custom/doctype/customize_form/customize_form.py:367
msgid "'In List View' not allowed for type {0} in row {1}"
@ -96,7 +96,7 @@ msgstr ""
#: frappe/utils/__init__.py:258
msgid "'{0}' is not a valid URL"
msgstr ""
msgstr "'{0}' bukan URL yang valid"
#: frappe/core/doctype/doctype/doctype.py:1363
msgid "'{0}' not allowed for type {1} in row {2}"
@ -104,7 +104,7 @@ msgstr "&#39;{0}&#39; tidak diperbolehkan untuk jenis {1} di baris {2}"
#: frappe/public/js/frappe/data_import/data_exporter.js:302
msgid "(Mandatory)"
msgstr ""
msgstr "(Wajib)"
#: frappe/model/rename_doc.py:703
msgid "** Failed: {0} to {1}: {2}"
@ -113,13 +113,13 @@ msgstr "** Gagal: {0} ke {1}: {2}"
#: frappe/public/js/frappe/list/list_settings.js:133
#: frappe/public/js/frappe/views/kanban/kanban_settings.js:111
msgid "+ Add / Remove Fields"
msgstr ""
msgstr "+ Tambah / Hapus Bidang"
#. Description of the 'Doc Status' (Select) field in DocType 'Workflow Document
#. State'
#: frappe/workflow/doctype/workflow_document_state/workflow_document_state.json
msgid "0 - Draft; 1 - Submitted; 2 - Cancelled"
msgstr ""
msgstr "0 - Draf; 1 - Diajukan; 2 - Dibatalkan"
#. Description of the 'Minimum Password Score' (Select) field in DocType
#. 'System Settings'
@ -138,21 +138,22 @@ msgstr ""
#. Description of the 'Priority' (Int) field in DocType 'Web Page'
#: frappe/website/doctype/web_page/web_page.json
msgid "0 is highest"
msgstr ""
msgstr "0 adalah tertinggi"
#: frappe/public/js/frappe/form/grid_row.js:892
msgid "1 = True & 0 = False"
msgstr ""
msgstr "1 = Benar & 0 = Salah"
#. Description of the 'Fraction Units' (Int) field in DocType 'Currency'
#: frappe/geo/doctype/currency/currency.json
msgid "1 Currency = [?] Fraction\n"
"For e.g. 1 USD = 100 Cent"
msgstr ""
msgstr "1 Mata Uang = [?] Pecahan\n"
"Contoh: 1 USD = 100 Sen"
#: frappe/public/js/frappe/form/reminders.js:19
msgid "1 Day"
msgstr ""
msgstr "1 Hari"
#: frappe/integrations/doctype/google_calendar/google_calendar.py:374
msgid "1 Google Calendar Event synced."
@ -160,15 +161,15 @@ msgstr "1 Acara Kalender Google disinkronkan."
#: frappe/public/js/frappe/views/reports/query_report.js:966
msgid "1 Report"
msgstr ""
msgstr "1 Laporan"
#: frappe/tests/test_utils.py:906
msgid "1 day ago"
msgstr ""
msgstr "1 hari yang lalu"
#: frappe/public/js/frappe/form/reminders.js:17
msgid "1 hour"
msgstr ""
msgstr "1 jam"
#: frappe/public/js/frappe/utils/pretty_date.js:52
#: frappe/tests/test_utils.py:904
@ -187,7 +188,7 @@ msgstr "1 bulan lalu"
#: frappe/public/js/print_format_builder/PrintFormat.vue:3
msgid "1 of 2"
msgstr ""
msgstr "1 dari 2"
#: frappe/public/js/frappe/data_import/data_exporter.js:227
msgid "1 record will be exported"
@ -205,7 +206,7 @@ msgstr ""
#: frappe/tests/test_utils.py:901
msgid "1 second ago"
msgstr ""
msgstr "1 detik yang lalu"
#: frappe/public/js/frappe/utils/pretty_date.js:62
#: frappe/tests/test_utils.py:908
@ -219,31 +220,31 @@ msgstr "1 tahun yang lalu"
#: frappe/tests/test_utils.py:905
msgid "2 hours ago"
msgstr ""
msgstr "2 jam yang lalu"
#: frappe/tests/test_utils.py:911
msgid "2 months ago"
msgstr ""
msgstr "2 bulan lalu"
#: frappe/tests/test_utils.py:909
msgid "2 weeks ago"
msgstr ""
msgstr "2 minggu yang lalu"
#: frappe/tests/test_utils.py:913
msgid "2 years ago"
msgstr ""
msgstr "2 tahun yang lalu"
#: frappe/tests/test_utils.py:903
msgid "3 minutes ago"
msgstr ""
msgstr "3 menit yang lalu"
#: frappe/public/js/frappe/form/reminders.js:16
msgid "30 minutes"
msgstr ""
msgstr "30 menit"
#: frappe/public/js/frappe/form/reminders.js:18
msgid "4 hours"
msgstr ""
msgstr "4 jam"
#: frappe/public/js/frappe/data_import/data_exporter.js:37
msgid "5 Records"
@ -251,7 +252,7 @@ msgstr "5 catatan"
#: frappe/tests/test_utils.py:907
msgid "5 days ago"
msgstr ""
msgstr "5 hari yang lalu"
#: frappe/desk/doctype/bulk_update/bulk_update.py:36
msgid "; not allowed in condition"
@ -261,13 +262,13 @@ msgstr "; tidak diijinkan dalam kondisi"
#. Condition'
#: frappe/core/doctype/document_naming_rule_condition/document_naming_rule_condition.json
msgid "<"
msgstr ""
msgstr "<"
#. Option for the 'Condition' (Select) field in DocType 'Document Naming Rule
#. Condition'
#: frappe/core/doctype/document_naming_rule_condition/document_naming_rule_condition.json
msgid "<="
msgstr ""
msgstr "<="
#. Description of the 'Generate Keys' (Button) field in DocType 'User'
#: frappe/core/doctype/user/user.json
@ -278,7 +279,7 @@ msgstr ""
#: frappe/public/js/frappe/widgets/widget_dialog.js:601
msgid "<b>{0}</b> is not a valid URL"
msgstr ""
msgstr "<b>{0}</b> bukan URL yang valid"
#. Content of the 'Help' (HTML) field in DocType 'Property Setter'
#: frappe/custom/doctype/property_setter/property_setter.json
@ -625,7 +626,7 @@ msgstr "{0} {1} berulang telah dibuat untuk Anda melalui Ulangi Otomatis {2}."
#. Description of the 'Symbol' (Data) field in DocType 'Currency'
#: frappe/geo/doctype/currency/currency.json
msgid "A symbol for this currency. For e.g. $"
msgstr ""
msgstr "Simbol untuk mata uang ini. Contoh. $"
#: frappe/printing/doctype/print_format_field_template/print_format_field_template.py:49
msgid "A template already exists for field {0} of {1}"
@ -645,67 +646,67 @@ msgstr "Sebuah kata dengan sendirinya mudah ditebak."
#. Option for the 'PDF Page Size' (Select) field in DocType 'Print Settings'
#: frappe/printing/doctype/print_settings/print_settings.json
msgid "A0"
msgstr ""
msgstr "A0"
#. Option for the 'PDF Page Size' (Select) field in DocType 'Print Settings'
#: frappe/printing/doctype/print_settings/print_settings.json
msgid "A1"
msgstr ""
msgstr "A1"
#. Option for the 'PDF Page Size' (Select) field in DocType 'Print Settings'
#: frappe/printing/doctype/print_settings/print_settings.json
msgid "A2"
msgstr ""
msgstr "A2"
#. Option for the 'PDF Page Size' (Select) field in DocType 'Print Settings'
#: frappe/printing/doctype/print_settings/print_settings.json
msgid "A3"
msgstr ""
msgstr "A3"
#. Option for the 'PDF Page Size' (Select) field in DocType 'Print Settings'
#: frappe/printing/doctype/print_settings/print_settings.json
msgid "A4"
msgstr ""
msgstr "A4"
#. Option for the 'PDF Page Size' (Select) field in DocType 'Print Settings'
#: frappe/printing/doctype/print_settings/print_settings.json
msgid "A5"
msgstr ""
msgstr "A5"
#. Option for the 'PDF Page Size' (Select) field in DocType 'Print Settings'
#: frappe/printing/doctype/print_settings/print_settings.json
msgid "A6"
msgstr ""
msgstr "A6"
#. Option for the 'PDF Page Size' (Select) field in DocType 'Print Settings'
#: frappe/printing/doctype/print_settings/print_settings.json
msgid "A7"
msgstr ""
msgstr "A7"
#. Option for the 'PDF Page Size' (Select) field in DocType 'Print Settings'
#: frappe/printing/doctype/print_settings/print_settings.json
msgid "A8"
msgstr ""
msgstr "A8"
#. Option for the 'PDF Page Size' (Select) field in DocType 'Print Settings'
#: frappe/printing/doctype/print_settings/print_settings.json
msgid "A9"
msgstr ""
msgstr "A9"
#. Option for the 'Email Sync Option' (Select) field in DocType 'Email Account'
#: frappe/email/doctype/email_account/email_account.json
msgid "ALL"
msgstr ""
msgstr "SEMUA"
#. Option for the 'Script Type' (Select) field in DocType 'Server Script'
#: frappe/core/doctype/server_script/server_script.json
msgid "API"
msgstr ""
msgstr "API"
#. Label of the api_access (Section Break) field in DocType 'User'
#: frappe/core/doctype/user/user.json
msgid "API Access"
msgstr ""
msgstr "Akses API"
#. Label of the api_endpoint (Data) field in DocType 'Social Login Key'
#: frappe/integrations/doctype/social_login_key/social_login_key.json
@ -918,7 +919,7 @@ msgstr ""
#: frappe/public/js/frappe/widgets/onboarding_widget.js:305
#: frappe/public/js/frappe/widgets/onboarding_widget.js:376
msgid "Action Complete"
msgstr ""
msgstr "Tindakan Selesai"
#: frappe/model/document.py:1941
msgid "Action Failed"
@ -927,7 +928,7 @@ msgstr "aksi Gagal"
#. Label of the action_label (Data) field in DocType 'Onboarding Step'
#: frappe/desk/doctype/onboarding_step/onboarding_step.json
msgid "Action Label"
msgstr ""
msgstr "Label Tindakan"
#. Label of the action_timeout (Int) field in DocType 'Success Action'
#: frappe/core/doctype/success_action/success_action.json
@ -945,7 +946,7 @@ msgstr ""
#: frappe/core/doctype/submission_queue/submission_queue.py:116
msgid "Action {0} failed on {1} {2}. View it {3}"
msgstr ""
msgstr "Tindakan {0} gagal pada {1} {2}. Lihat {3}"
#. Label of the actions_section (Tab Break) field in DocType 'DocType'
#. Label of the actions_section (Section Break) field in DocType 'User Session
@ -982,7 +983,7 @@ msgstr "Tindakan"
#. Label of the activate (Check) field in DocType 'Package Import'
#: frappe/core/doctype/package_import/package_import.json
msgid "Activate"
msgstr ""
msgstr "Aktifkan"
#. Option for the 'Status' (Select) field in DocType 'Auto Repeat'
#. Option for the 'Status' (Select) field in DocType 'Kanban Board Column'
@ -999,14 +1000,14 @@ msgstr "Aktif"
#. Option for the 'Directory Server' (Select) field in DocType 'LDAP Settings'
#: frappe/integrations/doctype/ldap_settings/ldap_settings.json
msgid "Active Directory"
msgstr ""
msgstr "Direktori Aktif"
#. Label of the active_domains_sb (Section Break) field in DocType 'Domain
#. Settings'
#. Label of the active_domains (Table) field in DocType 'Domain Settings'
#: frappe/core/doctype/domain_settings/domain_settings.json
msgid "Active Domains"
msgstr ""
msgstr "Domain Aktif"
#. Label of the active_sessions (Table) field in DocType 'User'
#. Label of the active_sessions (Int) field in DocType 'System Health Report'
@ -1047,7 +1048,7 @@ msgstr "Tambahkan"
#: frappe/public/js/frappe/form/grid_row.js:454
msgid "Add / Remove Columns"
msgstr ""
msgstr "Tambah / Hapus Kolom"
#: frappe/core/doctype/user_permission/user_permission_list.js:4
msgid "Add / Update"
@ -1065,21 +1066,21 @@ msgstr "Tambahkan lampiran"
#. Label of the add_background_image (Check) field in DocType 'Web Page Block'
#: frappe/website/doctype/web_page_block/web_page_block.json
msgid "Add Background Image"
msgstr ""
msgstr "Tambah Gambar Latar Belakang"
#. Label of the add_border_at_bottom (Check) field in DocType 'Web Page Block'
#: frappe/website/doctype/web_page_block/web_page_block.json
msgid "Add Border at Bottom"
msgstr ""
msgstr "Tambah Garis Tepi di Bawah"
#. Label of the add_border_at_top (Check) field in DocType 'Web Page Block'
#: frappe/website/doctype/web_page_block/web_page_block.json
msgid "Add Border at Top"
msgstr ""
msgstr "Tambah Garis Tepi di Atas"
#: frappe/desk/doctype/number_card/number_card.js:37
msgid "Add Card to Dashboard"
msgstr ""
msgstr "Tambah Kartu ke Dasbor"
#: frappe/public/js/frappe/views/reports/query_report.js:210
msgid "Add Chart to Dashboard"
@ -1114,7 +1115,7 @@ msgstr ""
#. Label of the set_meta_tags (Button) field in DocType 'Web Page'
#: frappe/website/doctype/web_page/web_page.json
msgid "Add Custom Tags"
msgstr ""
msgstr "Tambah Tag Kustom"
#: frappe/public/js/frappe/widgets/widget_dialog.js:188
#: frappe/public/js/frappe/widgets/widget_dialog.js:716
@ -1124,7 +1125,7 @@ msgstr "Tambahkan Filter"
#. Label of the add_shade (Check) field in DocType 'Web Page Block'
#: frappe/website/doctype/web_page_block/web_page_block.json
msgid "Add Gray Background"
msgstr ""
msgstr "Tambah Latar Belakang Abu-abu"
#: frappe/public/js/frappe/ui/group_by/group_by.js:230
#: frappe/public/js/frappe/ui/group_by/group_by.js:430
@ -1137,7 +1138,7 @@ msgstr ""
#: frappe/public/js/frappe/form/grid.js:66
msgid "Add Multiple"
msgstr ""
msgstr "Tambah Beberapa"
#: frappe/core/page/permission_manager/permission_manager.js:495
msgid "Add New Permission Rule"
@ -1150,15 +1151,15 @@ msgstr "Tambahkan Peserta"
#. Label of the add_query_parameters (Check) field in DocType 'Email Group'
#: frappe/email/doctype/email_group/email_group.json
msgid "Add Query Parameters"
msgstr ""
msgstr "Tambah Parameter Kueri"
#: frappe/core/doctype/user/user.py:857
msgid "Add Roles"
msgstr ""
msgstr "Tambah Peran"
#: frappe/public/js/frappe/form/grid.js:66
msgid "Add Row"
msgstr ""
msgstr "Tambah Baris"
#. Label of the add_signature (Check) field in DocType 'Email Account'
#: frappe/email/doctype/email_account/email_account.json
@ -1169,12 +1170,12 @@ msgstr "Tambahkan Signature"
#. Label of the add_bottom_padding (Check) field in DocType 'Web Page Block'
#: frappe/website/doctype/web_page_block/web_page_block.json
msgid "Add Space at Bottom"
msgstr ""
msgstr "Tambah Spasi di Bawah"
#. Label of the add_top_padding (Check) field in DocType 'Web Page Block'
#: frappe/website/doctype/web_page_block/web_page_block.json
msgid "Add Space at Top"
msgstr ""
msgstr "Tambah Spasi di Atas"
#: frappe/email/doctype/email_group/email_group.js:38
#: frappe/email/doctype/email_group/email_group.js:59
@ -1183,12 +1184,12 @@ msgstr "Tambahkan Pelanggan"
#: frappe/public/js/frappe/list/bulk_operations.js:425
msgid "Add Tags"
msgstr ""
msgstr "Tambah Tag"
#: frappe/public/js/frappe/list/list_view.js:2228
msgctxt "Button in list view actions menu"
msgid "Add Tags"
msgstr ""
msgstr "Tambah Tag"
#: frappe/public/js/frappe/views/communication.js:424
msgid "Add Template"
@ -1476,13 +1477,13 @@ msgstr ""
#: frappe/core/doctype/doctype/doctype.json
#: frappe/core/doctype/system_settings/system_settings.json
msgid "Advanced"
msgstr ""
msgstr "Lanjutan"
#. Label of the advanced_control_section (Section Break) field in DocType 'User
#. Permission'
#: frappe/core/doctype/user_permission/user_permission.json
msgid "Advanced Control"
msgstr ""
msgstr "Kontrol Lanjutan"
#: frappe/public/js/frappe/form/controls/link.js:485
#: frappe/public/js/frappe/form/controls/link.js:487
@ -1492,22 +1493,22 @@ msgstr "Pencarian Lanjutan"
#. Label of the sb_advanced (Section Break) field in DocType 'OAuth Client'
#: frappe/integrations/doctype/oauth_client/oauth_client.json
msgid "Advanced Settings"
msgstr ""
msgstr "Pengaturan Lanjutan"
#: frappe/public/js/frappe/ui/filters/filter.js:64
#: frappe/public/js/frappe/ui/filters/filter.js:70
msgid "After"
msgstr ""
msgstr "Setelah"
#. Option for the 'DocType Event' (Select) field in DocType 'Server Script'
#: frappe/core/doctype/server_script/server_script.json
msgid "After Cancel"
msgstr ""
msgstr "Setelah Batal"
#. Option for the 'DocType Event' (Select) field in DocType 'Server Script'
#: frappe/core/doctype/server_script/server_script.json
msgid "After Delete"
msgstr ""
msgstr "Setelah Hapus"
#. Option for the 'DocType Event' (Select) field in DocType 'Server Script'
#: frappe/core/doctype/server_script/server_script.json
@ -1517,17 +1518,17 @@ msgstr ""
#. Option for the 'DocType Event' (Select) field in DocType 'Server Script'
#: frappe/core/doctype/server_script/server_script.json
msgid "After Insert"
msgstr ""
msgstr "Setelah Sisip"
#. Option for the 'DocType Event' (Select) field in DocType 'Server Script'
#: frappe/core/doctype/server_script/server_script.json
msgid "After Rename"
msgstr ""
msgstr "Setelah Ubah Nama"
#. Option for the 'DocType Event' (Select) field in DocType 'Server Script'
#: frappe/core/doctype/server_script/server_script.json
msgid "After Save"
msgstr ""
msgstr "Setelah Simpan"
#. Option for the 'DocType Event' (Select) field in DocType 'Server Script'
#: frappe/core/doctype/server_script/server_script.json

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