Merge branch 'develop' into bleach-to-nh3
This commit is contained in:
commit
e4376fc067
249 changed files with 12280 additions and 8232 deletions
14
.github/workflows/_base-migration.yml
vendored
14
.github/workflows/_base-migration.yml
vendored
|
|
@ -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: |
|
||||
|
|
|
|||
7
.github/workflows/generate-pot-file.yml
vendored
7
.github/workflows/generate-pot-file.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/initiate_release.yml
vendored
2
.github/workflows/initiate_release.yml
vendored
|
|
@ -15,7 +15,7 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version: ["14", "15"]
|
||||
version: ["14", "15", "16"]
|
||||
|
||||
steps:
|
||||
- uses: octokit/request-action@v2.x
|
||||
|
|
|
|||
2
.github/workflows/server-tests.yml
vendored
2
.github/workflows/server-tests.yml
vendored
|
|
@ -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' }}
|
||||
|
|
|
|||
49
.mergify.yml
49
.mergify.yml
|
|
@ -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 }}"
|
||||
|
|
|
|||
11
.releaserc
11
.releaserc
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ context("Customize Form", () => {
|
|||
"Set by user": "prompt",
|
||||
"By fieldname": "field:",
|
||||
Expression: "",
|
||||
"Expression (old style)": "format:",
|
||||
Random: "hash",
|
||||
"By script": "",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
323
frappe/api/v2.py
323
frappe/api/v2.py
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
123
frappe/client.py
123
frappe/client.py
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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("%", ""),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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})",
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright (c) {year}, {app_publisher} and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.treeview_settings["{doctype}"] = {{
|
||||
// }};
|
||||
|
|
@ -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"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@
|
|||
},
|
||||
{
|
||||
"fieldname": "error_message",
|
||||
"fieldtype": "Text",
|
||||
"fieldtype": "Code",
|
||||
"label": "Error Message",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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("<script>", result)
|
||||
self.assertIn("<div>", 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)
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
27
frappe/core/number_card/error_logs/error_logs.json
Normal file
27
frappe/core/number_card/error_logs/error_logs.json
Normal 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"
|
||||
}
|
||||
26
frappe/core/number_card/scheduled_jobs/scheduled_jobs.json
Normal file
26
frappe/core/number_card/scheduled_jobs/scheduled_jobs.json
Normal 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"
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -768,6 +768,7 @@ docfield_properties = {
|
|||
"permlevel": "Int",
|
||||
"width": "Data",
|
||||
"print_width": "Data",
|
||||
"alignment": "Select",
|
||||
"non_negative": "Check",
|
||||
"reqd": "Check",
|
||||
"unique": "Check",
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
8
frappe/desk/doctype/desktop_layout/desktop_layout.js
Normal file
8
frappe/desk/doctype/desktop_layout/desktop_layout.js
Normal 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) {
|
||||
|
||||
// },
|
||||
// });
|
||||
55
frappe/desk/doctype/desktop_layout/desktop_layout.json
Normal file
55
frappe/desk/doctype/desktop_layout/desktop_layout.json
Normal 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": []
|
||||
}
|
||||
50
frappe/desk/doctype/desktop_layout/desktop_layout.py
Normal file
50
frappe/desk/doctype/desktop_layout/desktop_layout.py
Normal 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}
|
||||
20
frappe/desk/doctype/desktop_layout/test_desktop_layout.py
Normal file
20
frappe/desk/doctype/desktop_layout/test_desktop_layout.py
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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();
|
||||
},
|
||||
});
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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"))
|
||||
|
|
@ -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));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -334,8 +334,7 @@ def get_communication_data(
|
|||
return frappe.db.multisql(
|
||||
{
|
||||
"sqlite": sqlite_query,
|
||||
"postgres": query,
|
||||
"mariadb": query,
|
||||
"*": query,
|
||||
},
|
||||
dict(
|
||||
doctype=doctype,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
27
frappe/desk/number_card/users/users.json
Normal file
27
frappe/desk/number_card/users/users.json
Normal 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"
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 * * * *": [
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
8785
frappe/locale/ar.po
8785
frappe/locale/ar.po
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 > 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 > Ű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"
|
||||
|
|
|
|||
|
|
@ -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 "© Frappe Technologies Pvt. Ltd. and contributors"
|
||||
msgstr ""
|
||||
msgstr "© 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 "<head> HTML"
|
||||
msgstr ""
|
||||
msgstr "<head> 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 "'{0}' 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
Loading…
Add table
Reference in a new issue