Procházet zdrojové kódy

Merge pull request #17859 from frappe/version-14-hotfix

chore: release
version-14
Ankush Menat před 2 roky
committed by GitHub
rodič
revize
b2dadc90e6
V databázi nebyl nalezen žádný známý klíč pro tento podpis ID GPG klíče: 4AEE18F83AFDEB23
34 změnil soubory, kde provedl 367 přidání a 572 odebrání
  1. +0
    -3
      .github/helper/install.sh
  2. +1
    -11
      .github/workflows/server-mariadb-tests.yml
  3. +1
    -11
      .github/workflows/server-postgres-tests.yml
  4. +2
    -34
      .github/workflows/ui-tests.yml
  5. +55
    -143
      cypress/integration/control_link.js
  6. +2
    -2
      cypress/integration/web_form.js
  7. +10
    -2
      frappe/__init__.py
  8. +2
    -10
      frappe/boot.py
  9. +38
    -103
      frappe/contacts/doctype/gender/gender.json
  10. +50
    -121
      frappe/contacts/doctype/salutation/salutation.json
  11. +4
    -4
      frappe/core/doctype/doctype/doctype.json
  12. +3
    -1
      frappe/core/doctype/language/language.json
  13. +3
    -2
      frappe/core/doctype/role/role.json
  14. +8
    -1
      frappe/core/doctype/user/test_user.py
  15. +11
    -9
      frappe/core/doctype/user/user.py
  16. +3
    -0
      frappe/custom/doctype/custom_field/custom_field.py
  17. +3
    -3
      frappe/custom/doctype/customize_form/customize_form.json
  18. +1
    -1
      frappe/custom/doctype/customize_form/customize_form.py
  19. +1
    -1
      frappe/database/mariadb/framework_mariadb.sql
  20. +1
    -1
      frappe/database/postgres/framework_postgres.sql
  21. +25
    -12
      frappe/desk/search.py
  22. +4
    -2
      frappe/geo/doctype/country/country.json
  23. +0
    -2
      frappe/hooks.py
  24. +10
    -1
      frappe/model/base_document.py
  25. +1
    -1
      frappe/public/js/frappe/db.js
  26. +11
    -12
      frappe/public/js/frappe/file_uploader/FilePreview.vue
  27. +1
    -1
      frappe/public/js/frappe/file_uploader/FileUploader.vue
  28. +11
    -18
      frappe/public/js/frappe/form/controls/link.js
  29. +7
    -4
      frappe/public/js/frappe/form/sidebar/attachments.js
  30. +0
    -1
      frappe/sessions.py
  31. +8
    -0
      frappe/tests/test_document.py
  32. +34
    -0
      frappe/tests/ui_test_helpers.py
  33. +9
    -1
      frappe/translate.py
  34. +47
    -54
      frappe/utils/boilerplate.py

+ 0
- 3
.github/helper/install.sh Zobrazit soubor

@@ -52,9 +52,6 @@ if [ "$TYPE" == "server" ]; then
sed -i 's/^socketio:/# socketio:/g' Procfile;
sed -i 's/^redis_socketio:/# redis_socketio:/g' Procfile;
fi
if [ "$TYPE" == "ui" ]; then
sed -i 's/^web: bench serve/web: bench serve --with-coverage/g' Procfile;
fi

echo "Starting Bench..."



+ 1
- 11
.github/workflows/server-mariadb-tests.yml Zobrazit soubor

@@ -122,17 +122,7 @@ jobs:

- name: Run Tests
if: ${{ steps.check-build.outputs.build == 'strawberry' }}
run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --use-orchestrator --with-coverage
run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --use-orchestrator
env:
CI_BUILD_ID: ${{ github.run_id }}
ORCHESTRATOR_URL: http://test-orchestrator.frappe.io

- name: Upload coverage data
if: ${{ steps.check-build.outputs.build == 'strawberry' }}
uses: codecov/codecov-action@v3
with:
name: MariaDB
fail_ci_if_error: true
files: /home/runner/frappe-bench/sites/coverage.xml
verbose: true
flags: server

+ 1
- 11
.github/workflows/server-postgres-tests.yml Zobrazit soubor

@@ -125,17 +125,7 @@ jobs:

- name: Run Tests
if: ${{ steps.check-build.outputs.build == 'strawberry' }}
run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --use-orchestrator --with-coverage
run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --use-orchestrator
env:
CI_BUILD_ID: ${{ github.run_id }}
ORCHESTRATOR_URL: http://test-orchestrator.frappe.io

- name: Upload coverage data
if: ${{ steps.check-build.outputs.build == 'strawberry' }}
uses: codecov/codecov-action@v3
with:
name: Postgres
fail_ci_if_error: true
files: /home/runner/frappe-bench/sites/coverage.xml
verbose: true
flags: server

+ 2
- 34
.github/workflows/ui-tests.yml Zobrazit soubor

@@ -144,42 +144,10 @@ jobs:

- name: UI Tests
if: ${{ steps.check-build.outputs.build == 'strawberry' }}
run: cd ~/frappe-bench/ && bench --site test_site run-ui-tests frappe --with-coverage --headless --parallel --ci-build-id $GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT
run: cd ~/frappe-bench/ && bench --site test_site run-ui-tests frappe --headless --parallel --ci-build-id $GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT
env:
CYPRESS_RECORD_KEY: 4a48f41c-11b3-425b-aa88-c58048fa69eb

- name: Stop server
if: ${{ steps.check-build.outputs.build-server == 'strawberry' }}
run: |
ps -ef | grep "frappe serve" | awk '{print $2}' | xargs kill -s SIGINT 2> /dev/null || true
sleep 5

- name: Check If Coverage Report Exists
id: check_coverage
uses: andstor/file-existence-action@v1
with:
files: "/home/runner/frappe-bench/apps/frappe/.cypress-coverage/clover.xml"

- name: Upload Coverage Data
if: ${{ steps.check-build.outputs.build == 'strawberry' && steps.check_coverage.outputs.files_exists == 'true' }}
uses: codecov/codecov-action@v3
with:
name: Cypress
fail_ci_if_error: true
directory: /home/runner/frappe-bench/apps/frappe/.cypress-coverage/
verbose: true
flags: ui-tests

- name: Upload Server Coverage Data
if: ${{ steps.check-build.outputs.build-server == 'strawberry' }}
uses: codecov/codecov-action@v3
with:
name: MariaDB
fail_ci_if_error: true
files: /home/runner/frappe-bench/sites/coverage.xml
verbose: true
flags: server

- name: Show bench console if tests failed
if: ${{ failure() }}
run: cat ~/frappe-bench/bench_start.log
run: cat ~/frappe-bench/bench_start.log

+ 55
- 143
cypress/integration/control_link.js Zobrazit soubor

@@ -26,15 +26,15 @@ context("Control Link", () => {
});
}

function get_dialog_with_user_link() {
function get_dialog_with_gender_link() {
return cy.dialog({
title: "Link",
fields: [
{
label: "Select User",
label: "Select Gender",
fieldname: "link",
fieldtype: "Link",
options: "User",
options: "Gender",
},
],
});
@@ -43,19 +43,6 @@ context("Control Link", () => {
it("should set the valid value", () => {
get_dialog_with_link().as("dialog");

cy.insert_doc(
"Property Setter",
{
doctype: "Property Setter",
doc_type: "User",
property: "translate_link_fields",
property_type: "Check",
doctype_or_field: "DocType",
value: "0",
},
true
);

cy.insert_doc(
"Property Setter",
{
@@ -133,19 +120,6 @@ context("Control Link", () => {
});

it("show title field in link", () => {
cy.insert_doc(
"Property Setter",
{
doctype: "Property Setter",
doc_type: "User",
property: "translate_link_fields",
property_type: "Check",
doctype_or_field: "DocType",
value: "0",
},
true
);

cy.insert_doc(
"Property Setter",
{
@@ -275,142 +249,54 @@ context("Control Link", () => {
);
});

it("show translated text for link with show_title_field_in_link enabled", () => {
cy.insert_doc(
"Property Setter",
{
doctype: "Property Setter",
doc_type: "ToDo",
property: "translate_link_fields",
property_type: "Check",
doctype_or_field: "DocType",
value: "1",
},
true
);

cy.insert_doc(
"Property Setter",
{
doctype: "Property Setter",
doc_type: "ToDo",
property: "show_title_field_in_link",
property_type: "Check",
doctype_or_field: "DocType",
value: "1",
},
true
);

cy.window()
.its("frappe")
.then((frappe) => {
cy.insert_doc("Translation", {
doctype: "Translation",
language: frappe.boot.lang,
source_text: "this is a test todo for link",
translated_text: "this is a translated test todo for link",
it("show translated text for Gender link field with language de with input in de", () => {
cy.call("frappe.tests.ui_test_helpers.insert_translations").then(() => {
cy.window()
.its("frappe")
.then((frappe) => {
cy.set_value("User", frappe.user.name, { language: "de" });
});
});

cy.clear_cache();
cy.wait(500);

cy.window()
.its("frappe")
.then((frappe) => {
if (!frappe.boot) {
frappe.boot = {
link_title_doctypes: ["ToDo"],
translatable_doctypes: ["ToDo"],
};
} else {
frappe.boot.link_title_doctypes = ["ToDo"];
frappe.boot.translatable_doctypes = ["ToDo"];
}
});
cy.clear_cache();
cy.wait(500);

get_dialog_with_link().as("dialog");
cy.intercept("POST", "/api/method/frappe.desk.search.search_link").as("search_link");
get_dialog_with_gender_link().as("dialog");
cy.intercept("POST", "/api/method/frappe.desk.search.search_link").as("search_link");

cy.get(".frappe-control[data-fieldname=link] input").focus().as("input");
cy.wait("@search_link");
cy.get("@input").type("todo for link", { delay: 100 });
cy.wait("@search_link");
cy.get(".frappe-control[data-fieldname=link] ul").should("be.visible");
cy.get(".frappe-control[data-fieldname=link] input").type("{enter}", { delay: 100 });
cy.get(".frappe-control[data-fieldname=link] input").blur();
cy.get("@dialog").then((dialog) => {
cy.get("@todos").then((todos) => {
cy.get(".frappe-control[data-fieldname=link] input").focus().as("input");
cy.wait("@search_link");
cy.get("@input").type("Sonstiges", { delay: 100 });
cy.wait("@search_link");
cy.get(".frappe-control[data-fieldname=link] ul").should("be.visible");
cy.get(".frappe-control[data-fieldname=link] input").type("{enter}", { delay: 100 });
cy.get(".frappe-control[data-fieldname=link] input").blur();
cy.get("@dialog").then((dialog) => {
let field = dialog.get_field("link");
let value = field.get_value();
let label = field.get_label_value();

expect(value).to.eq(todos[0]);
expect(label).to.eq("this is a translated test todo for link");
expect(value).to.eq("Other");
expect(label).to.eq("Sonstiges");
});
});
});

it("show translated text for link with show_title_field_in_link disabled", () => {
cy.insert_doc(
"Property Setter",
{
doctype: "Property Setter",
doc_type: "User",
property: "translate_link_fields",
property_type: "Check",
doctype_or_field: "DocType",
value: "1",
},
true
);

cy.insert_doc(
"Property Setter",
{
doctype: "Property Setter",
doc_type: "ToDo",
property: "show_title_field_in_link",
property_type: "Check",
doctype_or_field: "DocType",
value: "0",
},
true
);

it("show text for Gender link field with language en", () => {
cy.window()
.its("frappe")
.then((frappe) => {
cy.insert_doc("Translation", {
doctype: "Translation",
language: frappe.boot.lang,
source_text: "test@erpnext.com",
translated_text: "translatedtest@erpnext.com",
});
cy.set_value("User", frappe.user.name, { language: "en" });
});

cy.clear_cache();
cy.wait(500);

cy.window()
.its("frappe")
.then((frappe) => {
if (!frappe.boot) {
frappe.boot = {
translatable_doctypes: ["User"],
};
} else {
frappe.boot.translatable_doctypes = ["User"];
}
});

get_dialog_with_user_link().as("dialog");
get_dialog_with_gender_link().as("dialog");
cy.intercept("POST", "/api/method/frappe.desk.search.search_link").as("search_link");

cy.get(".frappe-control[data-fieldname=link] input").focus().as("input");
cy.wait("@search_link");
cy.get("@input").type("test@erpnext.com", { delay: 100 });
cy.get("@input").type("Non-Conforming", { delay: 100 });
cy.wait("@search_link");
cy.get(".frappe-control[data-fieldname=link] ul").should("be.visible");
cy.get(".frappe-control[data-fieldname=link] input").type("{enter}", { delay: 100 });
@@ -420,8 +306,34 @@ context("Control Link", () => {
let value = field.get_value();
let label = field.get_label_value();

expect(value).to.eq("test@erpnext.com");
expect(label).to.eq("translatedtest@erpnext.com");
expect(value).to.eq("Non-Conforming");
expect(label).to.eq("Non-Conforming");
});
});

it("show custom link option", () => {
cy.window()
.its("frappe")
.then((frappe) => {
frappe.ui.form.ControlLink.link_options = (link) => {
return [
{
html:
"<span class='text-primary custom-link-option'>" +
"<i class='fa fa-search' style='margin-right: 5px;'></i> " +
"Custom Link Option" +
"</span>",
label: "Custom Link Option",
value: "custom__link_option",
action: () => {},
},
];
};

get_dialog_with_link().as("dialog");
cy.get(".frappe-control[data-fieldname=link] input").focus().as("input");
cy.get("@input").type("custom", { delay: 100 });
cy.get(".custom-link-option").should("be.visible");
});
});
});

+ 2
- 2
cypress/integration/web_form.js Zobrazit soubor

@@ -237,7 +237,7 @@ context("Web Form", () => {

cy.get(".web-form-actions a").contains("Edit").click();

cy.fill_field("last_name", "_Test User");
cy.fill_field("middle_name", "_Test User");

cy.get(".web-form-actions .btn-primary").click();
cy.url().should("include", "/me");
@@ -249,7 +249,7 @@ context("Web Form", () => {

cy.get(".web-form-actions a").contains("Edit").click();

cy.fill_field("last_name", "_Test User");
cy.fill_field("middle_name", "_Test User");

cy.get(".btn-next").should("be.visible");
cy.get(".btn-next").click();


+ 10
- 2
frappe/__init__.py Zobrazit soubor

@@ -2284,14 +2284,22 @@ def safe_eval(code, eval_globals=None, eval_locals=None):

def get_website_settings(key):
if not hasattr(local, "website_settings"):
local.website_settings = db.get_singles_dict("Website Settings", cast=True)
try:
local.website_settings = get_cached_doc("Website Settings")
except DoesNotExistError:
clear_last_message()
return

return local.website_settings.get(key)


def get_system_settings(key):
if not hasattr(local, "system_settings"):
local.system_settings = db.get_singles_dict("System Settings", cast=True)
try:
local.system_settings = get_cached_doc("System Settings")
except DoesNotExistError: # possible during new install
clear_last_message()
return

return local.system_settings.get(key)



+ 2
- 10
frappe/boot.py Zobrazit soubor

@@ -19,7 +19,7 @@ from frappe.social.doctype.energy_point_log.energy_point_log import get_energy_p
from frappe.social.doctype.energy_point_settings.energy_point_settings import (
is_energy_point_enabled,
)
from frappe.translate import get_lang_dict, get_messages_for_boot
from frappe.translate import get_lang_dict, get_messages_for_boot, get_translated_doctypes
from frappe.utils import add_user_info, cstr, get_time_zone
from frappe.utils.change_log import get_versions
from frappe.website.doctype.web_page_view.web_page_view import is_tracking_enabled
@@ -100,7 +100,7 @@ def get_bootinfo():
bootinfo.desk_settings = get_desk_settings()
bootinfo.app_logo_url = get_app_logo()
bootinfo.link_title_doctypes = get_link_title_doctypes()
bootinfo.translatable_doctypes = get_translatable_doctypes()
bootinfo.translated_doctypes = get_translated_doctypes()

return bootinfo

@@ -399,14 +399,6 @@ def set_time_zone(bootinfo):
}


def get_translatable_doctypes():
dts = frappe.get_all("DocType", {"translate_link_fields": 1}, pluck="name")
custom_dts = frappe.get_all(
"Property Setter", {"property": "translate_link_fields", "value": "1"}, pluck="doc_type"
)
return dts + custom_dts


def load_country_doc(bootinfo):
country = frappe.db.get_default("country")
if not country:


+ 38
- 103
frappe/contacts/doctype/gender/gender.json Zobrazit soubor

@@ -1,113 +1,48 @@
{
"allow_copy": 0,
"allow_guest_to_view": 0,
"allow_import": 0,
"allow_rename": 0,
"autoname": "field:gender",
"beta": 0,
"creation": "2017-04-10 12:11:36.526508",
"custom": 0,
"docstatus": 0,
"doctype": "DocType",
"document_type": "",
"editable_grid": 1,
"engine": "InnoDB",
"actions": [],
"autoname": "field:gender",
"creation": "2017-04-10 12:11:36.526508",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"gender"
],
"fields": [
{
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "gender",
"fieldtype": "Data",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Gender",
"length": 0,
"no_copy": 0,
"options": "",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
"fieldname": "gender",
"fieldtype": "Data",
"label": "Gender",
"unique": 1
}
],
"has_web_view": 0,
"hide_heading": 0,
"hide_toolbar": 0,
"idx": 0,
"image_view": 0,
"in_create": 0,
"is_submittable": 0,
"issingle": 0,
"istable": 0,
"max_attachments": 0,
"modified": "2017-04-10 12:17:04.848338",
"modified_by": "Administrator",
"module": "Contacts",
"name": "Gender",
"name_case": "",
"owner": "Administrator",
],
"links": [],
"modified": "2022-08-05 18:33:28.043370",
"modified_by": "Administrator",
"module": "Contacts",
"name": "Gender",
"owner": "Administrator",
"permissions": [
{
"amend": 0,
"apply_user_permissions": 0,
"cancel": 0,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"if_owner": 0,
"import": 0,
"permlevel": 0,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"set_user_permissions": 0,
"share": 1,
"submit": 0,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
},
{
"amend": 0,
"apply_user_permissions": 0,
"cancel": 0,
"create": 0,
"delete": 0,
"email": 0,
"export": 0,
"if_owner": 0,
"import": 0,
"permlevel": 0,
"print": 0,
"read": 1,
"report": 0,
"role": "All",
"set_user_permissions": 0,
"share": 0,
"submit": 0,
"write": 0
"read": 1,
"role": "All"
}
],
"quick_entry": 0,
"read_only": 0,
"read_only_onload": 0,
"show_name_in_global_search": 0,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1,
"track_seen": 0
],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1,
"translated_doctype": 1
}

+ 50
- 121
frappe/contacts/doctype/salutation/salutation.json Zobrazit soubor

@@ -1,132 +1,61 @@
{
"allow_copy": 0,
"allow_guest_to_view": 0,
"allow_import": 0,
"allow_rename": 1,
"autoname": "field:salutation",
"beta": 0,
"creation": "2017-04-10 12:17:58.071915",
"custom": 0,
"docstatus": 0,
"doctype": "DocType",
"document_type": "",
"editable_grid": 1,
"engine": "InnoDB",
"actions": [],
"allow_rename": 1,
"autoname": "field:salutation",
"creation": "2017-04-10 12:17:58.071915",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"salutation"
],
"fields": [
{
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "salutation",
"fieldtype": "Data",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Salutation",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
"fieldname": "salutation",
"fieldtype": "Data",
"label": "Salutation",
"unique": 1
}
],
"has_web_view": 0,
"hide_heading": 0,
"hide_toolbar": 0,
"idx": 0,
"image_view": 0,
"in_create": 0,
"is_submittable": 0,
"issingle": 0,
"istable": 0,
"max_attachments": 0,
"modified": "2020-09-14 12:55:18.855578",
"modified_by": "Administrator",
"module": "Contacts",
"name": "Salutation",
"name_case": "",
"owner": "Administrator",
],
"links": [],
"modified": "2022-08-05 18:33:28.196387",
"modified_by": "Administrator",
"module": "Contacts",
"name": "Salutation",
"owner": "Administrator",
"permissions": [
{
"amend": 0,
"apply_user_permissions": 0,
"cancel": 0,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"if_owner": 0,
"import": 0,
"permlevel": 0,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"set_user_permissions": 0,
"share": 1,
"submit": 0,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
},
{
"amend": 0,
"apply_user_permissions": 0,
"cancel": 0,
"create": 0,
"delete": 0,
"email": 0,
"export": 0,
"if_owner": 0,
"import": 0,
"permlevel": 0,
"print": 0,
"read": 1,
"report": 0,
"role": "All",
"set_user_permissions": 0,
"share": 0,
"submit": 0,
"write": 0
},
"read": 1,
"role": "All"
},
{
"amend": 0,
"apply_user_permissions": 0,
"cancel": 0,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"if_owner": 0,
"import": 0,
"permlevel": 0,
"print": 1,
"read": 1,
"report": 1,
"role": "Administrator",
"set_user_permissions": 0,
"share": 1,
"submit": 0,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Administrator",
"share": 1,
"write": 1
}
],
"quick_entry": 0,
"read_only": 0,
"read_only_onload": 0,
"show_name_in_global_search": 0,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1,
"track_seen": 0
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1,
"translated_doctype": 1
}

+ 4
- 4
frappe/core/doctype/doctype/doctype.json Zobrazit soubor

@@ -47,7 +47,7 @@
"view_settings",
"title_field",
"show_title_field_in_link",
"translate_link_fields",
"translated_doctype",
"search_fields",
"default_print_format",
"sort_field",
@@ -595,7 +595,7 @@
},
{
"default": "0",
"fieldname": "translate_link_fields",
"fieldname": "translated_doctype",
"fieldtype": "Check",
"label": "Translate Link Fields"
}
@@ -680,7 +680,7 @@
"link_fieldname": "reference_doctype"
}
],
"modified": "2022-02-28 21:56:52.116915",
"modified": "2022-08-05 18:33:27.315351",
"modified_by": "Administrator",
"module": "Core",
"name": "DocType",
@@ -716,5 +716,5 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1,
"translate_link_fields": 1
"translated_doctype": 1
}

+ 3
- 1
frappe/core/doctype/language/language.json Zobrazit soubor

@@ -51,7 +51,7 @@
"icon": "fa fa-globe",
"in_create": 1,
"links": [],
"modified": "2021-10-18 14:02:06.818219",
"modified": "2022-08-14 18:54:03.490836",
"modified_by": "Administrator",
"module": "Core",
"name": "Language",
@@ -76,8 +76,10 @@
}
],
"search_fields": "language_name",
"show_title_field_in_link": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "language_name",
"track_changes": 1
}

+ 3
- 2
frappe/core/doctype/role/role.json Zobrazit soubor

@@ -148,7 +148,7 @@
"idx": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2022-01-12 20:18:18.496230",
"modified": "2022-08-05 18:33:27.694065",
"modified_by": "Administrator",
"module": "Core",
"name": "Role",
@@ -171,5 +171,6 @@
"sort_field": "modified",
"sort_order": "ASC",
"states": [],
"track_changes": 1
"track_changes": 1,
"translated_doctype": 1
}

+ 8
- 1
frappe/core/doctype/user/test_user.py Zobrazit soubor

@@ -8,6 +8,7 @@ from unittest.mock import patch
import frappe
import frappe.exceptions
from frappe.core.doctype.user.user import (
handle_password_test_fail,
reset_password,
sign_up,
test_password_strength,
@@ -191,6 +192,12 @@ class TestUser(unittest.TestCase):
# Score 1; should now fail
result = test_password_strength("bee2ve")
self.assertEqual(result["feedback"]["password_policy_validation_passed"], False)
self.assertRaises(
frappe.exceptions.ValidationError, handle_password_test_fail, result["feedback"]
)
self.assertRaises(
frappe.exceptions.ValidationError, handle_password_test_fail, result
) # test backwards compatibility

# Score 4; should pass
result = test_password_strength("Eastern_43A1W")
@@ -200,7 +207,7 @@ class TestUser(unittest.TestCase):
user = frappe.get_doc("User", "test@example.com")
frappe.flags.in_test = False
user.new_password = "password"
self.assertRaisesRegex(frappe.exceptions.ValidationError, "Invalid Password", user.save)
self.assertRaises(frappe.exceptions.ValidationError, user.save)
user.reload()
user.new_password = "Eastern_43A1W"
user.save()


+ 11
- 9
frappe/core/doctype/user/user.py Zobrazit soubor

@@ -540,7 +540,7 @@ class User(Document):
feedback = result.get("feedback", None)

if feedback and not feedback.get("password_policy_validation_passed", False):
handle_password_test_fail(result)
handle_password_test_fail(feedback)

def suggest_username(self):
def _check_suggestion(suggestion):
@@ -686,7 +686,7 @@ def update_password(new_password, logout_all_sessions=0, key=None, old_password=
feedback = result.get("feedback", None)

if feedback and not feedback.get("password_policy_validation_passed", False):
handle_password_test_fail(result)
handle_password_test_fail(feedback)

res = _get_user_for_update_password(key, old_password)
if res.get("message"):
@@ -1042,13 +1042,15 @@ def notify_admin_access_to_system_manager(login_manager=None):
)


def handle_password_test_fail(result):
suggestions = result["feedback"]["suggestions"][0] if result["feedback"]["suggestions"] else ""
warning = result["feedback"]["warning"] if "warning" in result["feedback"] else ""
suggestions += (
"<br>" + _("Hint: Include symbols, numbers and capital letters in the password") + "<br>"
)
frappe.throw(" ".join([_("Invalid Password:"), warning, suggestions]))
def handle_password_test_fail(feedback: dict):
# Backward compatibility
if "feedback" in feedback:
feedback = feedback["feedback"]

suggestions = feedback.get("suggestions", [])
warning = feedback.get("warning", "")

frappe.throw(msg=" ".join([warning] + suggestions), title=_("Invalid Password"))


def update_gravatar(name):


+ 3
- 0
frappe/custom/doctype/custom_field/custom_field.py Zobrazit soubor

@@ -186,10 +186,13 @@ def create_custom_fields(custom_fields, ignore_validate=False, update=True):
field = frappe.db.get_value("Custom Field", {"dt": doctype, "fieldname": df["fieldname"]})
if not field:
try:
df = df.copy()
df["owner"] = "Administrator"
create_custom_field(doctype, df, ignore_validate=ignore_validate)

except frappe.exceptions.DuplicateEntryError:
pass

elif update:
custom_field = frappe.get_doc("Custom Field", field)
custom_field.flags.ignore_validate = ignore_validate


+ 3
- 3
frappe/custom/doctype/customize_form/customize_form.json Zobrazit soubor

@@ -29,7 +29,7 @@
"view_settings_section",
"title_field",
"show_title_field_in_link",
"translate_link_fields",
"translated_doctype",
"image_field",
"default_print_format",
"column_break_29",
@@ -315,7 +315,7 @@
},
{
"default": "0",
"fieldname": "translate_link_fields",
"fieldname": "translated_doctype",
"fieldtype": "Check",
"label": "Translate Link Fields"
}
@@ -326,7 +326,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2022-05-13 15:36:16.772277",
"modified": "2022-08-04 15:36:16.772277",
"modified_by": "Administrator",
"module": "Custom",
"name": "Customize Form",


+ 1
- 1
frappe/custom/doctype/customize_form/customize_form.py Zobrazit soubor

@@ -585,7 +585,7 @@ doctype_properties = {
"naming_rule": "Data",
"autoname": "Data",
"show_title_field_in_link": "Check",
"translate_link_fields": "Check",
"translated_doctype": "Check",
}

docfield_properties = {


+ 1
- 1
frappe/database/mariadb/framework_mariadb.sql Zobrazit soubor

@@ -226,7 +226,7 @@ CREATE TABLE `tabDocType` (
`sender_field` varchar(255) DEFAULT NULL,
`show_title_field_in_link` int(1) NOT NULL DEFAULT 0,
`migration_hash` varchar(255) DEFAULT NULL,
`translate_link_fields` int(1) NOT NULL DEFAULT 0,
`translated_doctype` int(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`name`)
) ENGINE=InnoDB ROW_FORMAT=DYNAMIC CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;



+ 1
- 1
frappe/database/postgres/framework_postgres.sql Zobrazit soubor

@@ -231,7 +231,7 @@ CREATE TABLE "tabDocType" (
"sender_field" varchar(255) DEFAULT NULL,
"show_title_field_in_link" smallint NOT NULL DEFAULT 0,
"migration_hash" varchar(255) DEFAULT NULL,
"translate_link_fields" smallint NOT NULL DEFAULT 0,
"translated_doctype" smallint NOT NULL DEFAULT 0,
PRIMARY KEY ("name")
) ;



+ 25
- 12
frappe/desk/search.py Zobrazit soubor

@@ -8,6 +8,7 @@ import re
import frappe
from frappe import _, is_whitelisted
from frappe.permissions import has_permission
from frappe.translate import get_translated_doctypes
from frappe.utils import cint, cstr, unique


@@ -115,7 +116,10 @@ def search_widget(
raise e
else:
frappe.respond_as_web_page(
title="Invalid Method", html="Method not found", indicator_color="red", http_status_code=404
title="Invalid Method",
html="Method not found",
indicator_color="red",
http_status_code=404,
)
return
except Exception as e:
@@ -146,9 +150,22 @@ def search_widget(
filters = []
or_filters = []

translated_search_doctypes = frappe.get_hooks("translated_search_doctypes")
translated_doctypes = frappe.cache().hget(
"translated_doctypes", "doctypes", get_translated_doctypes
)

# build from doctype
if txt:
field_types = [
"Data",
"Text",
"Small Text",
"Long Text",
"Link",
"Select",
"Read Only",
"Text Editor",
]
search_fields = ["name"]
if meta.title_field:
search_fields.append(meta.title_field)
@@ -158,13 +175,8 @@ def search_widget(

for f in search_fields:
fmeta = meta.get_field(f.strip())
if (doctype not in translated_search_doctypes) and (
f == "name"
or (
fmeta
and fmeta.fieldtype
in ["Data", "Text", "Small Text", "Long Text", "Link", "Select", "Read Only", "Text Editor"]
)
if (doctype not in translated_doctypes) and (
f == "name" or (fmeta and fmeta.fieldtype in field_types)
):
or_filters.append([doctype, f.strip(), "like", f"%{txt}%"])

@@ -188,7 +200,8 @@ def search_widget(
# find relevance as location of search term from the beginning of string `name`. used for sorting results.
formatted_fields.append(
"""locate({_txt}, `tab{doctype}`.`name`) as `_relevance`""".format(
_txt=frappe.db.escape((txt or "").replace("%", "").replace("@", "")), doctype=doctype
_txt=frappe.db.escape((txt or "").replace("%", "").replace("@", "")),
doctype=doctype,
)
)

@@ -206,7 +219,7 @@ def search_widget(
else (cint(ignore_user_permissions) and has_permission(doctype, ptype=ptype))
)

if doctype in translated_search_doctypes:
if doctype in translated_doctypes:
page_length = None

values = frappe.get_list(
@@ -223,7 +236,7 @@ def search_widget(
strict=False,
)

if doctype in translated_search_doctypes:
if doctype in translated_doctypes:
# Filtering the values array so that query is included in very element
values = (
v


+ 4
- 2
frappe/geo/doctype/country/country.json Zobrazit soubor

@@ -54,7 +54,7 @@
"icon": "fa fa-globe",
"idx": 1,
"links": [],
"modified": "2020-02-24 15:44:31.837133",
"modified": "2022-08-05 18:33:27.880783",
"modified_by": "Administrator",
"module": "Geo",
"name": "Country",
@@ -84,5 +84,7 @@
"quick_entry": 1,
"sort_field": "country_name",
"sort_order": "ASC",
"track_changes": 1
"states": [],
"track_changes": 1,
"translated_doctype": 1
}

+ 0
- 2
frappe/hooks.py Zobrazit soubor

@@ -373,5 +373,3 @@ override_whitelisted_methods = {
"frappe.core.doctype.file.file.move_file": "frappe.core.api.file.move_file",
"frappe.core.doctype.file.file.zip_files": "frappe.core.api.file.zip_files",
}

translated_search_doctypes = ["DocType", "Role", "Country", "Gender", "Salutation"]

+ 10
- 1
frappe/model/base_document.py Zobrazit soubor

@@ -671,10 +671,19 @@ class BaseDocument:

return _("Error: Value missing for {0}: {1}").format(_(df.parent), _(df.label))

def has_content(df):
value = cstr(self.get(df.fieldname))
has_text_content = strip_html(value).strip()
has_img_tag = "<img" in value
if df.fieldtype == "Text Editor" and (has_text_content or has_img_tag):
return True
else:
return has_text_content

missing = []

for df in self.meta.get("fields", {"reqd": ("=", 1)}):
if self.get(df.fieldname) in (None, []) or not strip_html(cstr(self.get(df.fieldname))).strip():
if self.get(df.fieldname) in (None, []) or not has_content(df):
missing.append((df.fieldname, get_msg(df)))

# check for missing parent and parenttype


+ 1
- 1
frappe/public/js/frappe/db.js Zobrazit soubor

@@ -71,7 +71,7 @@ frappe.db = {
},
});
},
get_doc: function (doctype, name, filters = null) {
get_doc: function (doctype, name, filters) {
return new Promise((resolve, reject) => {
frappe
.call({


+ 11
- 12
frappe/public/js/frappe/file_uploader/FilePreview.vue Zobrazit soubor

@@ -13,14 +13,8 @@
<div>
<a class="flex" :href="file.doc.file_url" v-if="file.doc" target="_blank">
<span class="file-name">{{ file.name | file_name }}</span>
<div class="ml-2" v-html="private_icon"></div>
</a>
<span class="flex" v-else>
<span class="file-name">{{ file.name | file_name }}</span>
<button class="ml-2 btn-reset" @click="$emit('toggle_private')" :title="__('Toggle Public/Private')">
<div v-html="private_icon"></div>
</button>
</span>
<span class="file-name" v-else>{{ file.name | file_name }}</span>
</div>

<div>
@@ -28,7 +22,11 @@
{{ file.file_obj.size | file_size }}
</span>
</div>
<label v-if="is_optimizable" class="optimize-checkbox"><input type="checkbox" :checked="optimize" @change="$emit('toggle_optimize')">Optimize</label>

<div class="flex config-area">
<label v-if="is_optimizable" class="frappe-checkbox"><input type="checkbox" :checked="optimize" @change="$emit('toggle_optimize')">Optimize</label>
<label class="frappe-checkbox"><input type="checkbox" :checked="file.private" @change="$emit('toggle_private')">Private</label>
</div>
<div>
<span v-if="file.error_message" class="file-error text-danger">
{{ file.error_message }}
@@ -87,9 +85,6 @@ export default {
}
},
computed: {
private_icon() {
return frappe.utils.icon(this.is_private ? 'lock' : 'unlock');
},
is_private() {
return this.file.doc ? this.file.doc.is_private : this.file.private;
},
@@ -206,7 +201,7 @@ export default {
opacity: 1;
}

.optimize-checkbox {
.frappe-checkbox {
font-size: var(--text-sm);
color: var(--text-light);
display: flex;
@@ -214,6 +209,10 @@ export default {
padding-top: 0.25rem;
}

.config-area {
gap: 0.5rem;
}

.file-error {
font-size: var(--text-sm);
font-weight: var(--text-bold);


+ 1
- 1
frappe/public/js/frappe/file_uploader/FileUploader.vue Zobrazit soubor

@@ -228,7 +228,7 @@ export default {
});
}
if (this.restrictions.max_number_of_files == null && this.doctype) {
this.restrictions.max_number_of_files = frappe.get_meta(self.doctype).max_attachments;
this.restrictions.max_number_of_files = frappe.get_meta(this.doctype)?.max_attachments;
}
},
watch: {


+ 11
- 18
frappe/public/js/frappe/form/controls/link.js Zobrazit soubor

@@ -87,7 +87,7 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
return this.is_translatable() ? __(value) : value;
}
is_translatable() {
return in_list(frappe.boot?.translatable_doctypes || [], this.get_options());
return in_list(frappe.boot?.translated_doctypes || [], this.get_options());
}
set_link_title(value) {
let doctype = this.get_options();
@@ -288,8 +288,17 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
action: me.new_doc,
});
}
// advanced search

//custom link actions
let custom__link_options =
frappe.ui.form.ControlLink.link_options &&
frappe.ui.form.ControlLink.link_options(me);

if (custom__link_options) {
r.results = r.results.concat(custom__link_options);
}

// advanced search
if (locals && locals["DocType"]) {
// not applicable in web forms
r.results.push({
@@ -382,22 +391,6 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
me.$input.val("");
}
});

this.$input.on("focus", function () {
if (!frappe.boot.translated_search_doctypes.includes(me.df.options)) {
me.show_untranslated();
}
});

this.$input.keydown((e) => {
let BACKSPACE = 8;
if (
e.keyCode === BACKSPACE &&
!frappe.boot.translated_search_doctypes.includes(me.df.options)
) {
me.show_untranslated();
}
});
}

show_untranslated() {


+ 7
- 4
frappe/public/js/frappe/form/sidebar/attachments.js Zobrazit soubor

@@ -160,6 +160,12 @@ frappe.ui.form.Attachments = class Attachments {
this.dialog.$wrapper.remove();
}

const restrictions = {};
if (this.frm.meta.max_attachments) {
restrictions.max_number_of_files =
this.frm.meta.max_attachments - this.frm.attachments.get_attachments().length;
}

new frappe.ui.FileUploader({
doctype: this.frm.doctype,
docname: this.frm.docname,
@@ -168,10 +174,7 @@ frappe.ui.form.Attachments = class Attachments {
on_success: (file_doc) => {
this.attachment_uploaded(file_doc);
},
restrictions: {
max_number_of_files:
this.frm.meta.max_attachments - this.frm.attachments.get_attachments().length,
},
restrictions,
});
}
get_args() {


+ 0
- 1
frappe/sessions.py Zobrazit soubor

@@ -184,7 +184,6 @@ def get():
frappe.get_attr(hook)(bootinfo=bootinfo)

bootinfo["lang"] = frappe.translate.get_user_lang()
bootinfo["translated_search_doctypes"] = frappe.get_hooks("translated_search_doctypes")
bootinfo["disable_async"] = frappe.conf.disable_async

bootinfo["setup_complete"] = cint(frappe.get_system_settings("setup_complete"))


+ 8
- 0
frappe/tests/test_document.py Zobrazit soubor

@@ -101,6 +101,14 @@ class TestDocument(unittest.TestCase):
d.insert()
self.assertEqual(frappe.db.get_value("User", d.name), d.name)

def test_text_editor_field(self):
try:
frappe.get_doc(
doctype="Activity Log", subject="test", message='<img src="test.png" />'
).insert()
except frappe.MandatoryError:
self.fail("Text Editor false positive mandatory error")

def test_conflict_validation(self):
d1 = self.test_insert()
d2 = frappe.get_doc(d1.doctype, d1.name)


+ 34
- 0
frappe/tests/ui_test_helpers.py Zobrazit soubor

@@ -333,3 +333,37 @@ def insert_doctype_with_child_table_record(name):
insert_child(doc, "Drag", "08189DIHAA2981", 0, 0.7, 342628, "2022-05-04")

doc.insert()


@frappe.whitelist()
def insert_translations():
translation = [
{
"doctype": "Translation",
"language": "de",
"source_text": "Other",
"translated_text": "Sonstiges",
},
{
"doctype": "Translation",
"language": "de",
"source_text": "Genderqueer",
"translated_text": "Nichtbinär",
},
{
"doctype": "Translation",
"language": "de",
"source_text": "Non-Conforming",
"translated_text": "Nicht konform",
},
{
"doctype": "Translation",
"language": "de",
"source_text": "Prefer not to say",
"translated_text": "Keine Angabe",
},
]

for doc in translation:
if not frappe.db.exists("doc"):
frappe.get_doc(doc).insert()

+ 9
- 1
frappe/translate.py Zobrazit soubor

@@ -23,7 +23,7 @@ from pypika.terms import PseudoColumn
import frappe
from frappe.model.utils import InvalidIncludePath, render_include
from frappe.query_builder import DocType, Field
from frappe.utils import cstr, get_bench_path, is_html, strip, strip_html_tags
from frappe.utils import cstr, get_bench_path, is_html, strip, strip_html_tags, unique

TRANSLATE_PATTERN = re.compile(
r"_\(\s*" # starts with literal `_(`, ignore following whitespace/newlines
@@ -1294,3 +1294,11 @@ def set_preferred_language_cookie(preferred_language):

def get_preferred_language_cookie():
return frappe.request.cookies.get("preferred_language")


def get_translated_doctypes():
dts = frappe.get_all("DocType", {"translated_doctype": 1}, pluck="name")
custom_dts = frappe.get_all(
"Property Setter", {"property": "translated_doctype", "value": "1"}, pluck="doc_type"
)
return unique(dts + custom_dts)

+ 47
- 54
frappe/utils/boilerplate.py Zobrazit soubor

@@ -79,7 +79,8 @@ def is_valid_title(title) -> bool:

def _create_app_boilerplate(dest, hooks, no_git=False):
frappe.create_folder(
os.path.join(dest, hooks.app_name, hooks.app_name, frappe.scrub(hooks.app_title)), with_init=True
os.path.join(dest, hooks.app_name, hooks.app_name, frappe.scrub(hooks.app_title)),
with_init=True,
)
frappe.create_folder(
os.path.join(dest, hooks.app_name, hooks.app_name, "templates"), with_init=True
@@ -249,8 +250,8 @@ app_license = "{app_license}"

# add methods and filters to jinja environment
# jinja = {{
# "methods": "{app_name}.utils.jinja_methods",
# "filters": "{app_name}.utils.jinja_filters"
# "methods": "{app_name}.utils.jinja_methods",
# "filters": "{app_name}.utils.jinja_filters"
# }}

# Installation
@@ -276,11 +277,11 @@ app_license = "{app_license}"
# Permissions evaluated in scripted ways

# permission_query_conditions = {{
# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions",
# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions",
# }}
#
# has_permission = {{
# "Event": "frappe.desk.doctype.event.event.has_permission",
# "Event": "frappe.desk.doctype.event.event.has_permission",
# }}

# DocType Class
@@ -288,7 +289,7 @@ app_license = "{app_license}"
# Override standard doctype classes

# override_doctype_class = {{
# "ToDo": "custom_app.overrides.CustomToDo"
# "ToDo": "custom_app.overrides.CustomToDo"
# }}

# Document Events
@@ -296,10 +297,10 @@ app_license = "{app_license}"
# Hook on document methods and events

# doc_events = {{
# "*": {{
# "on_update": "method",
# "on_cancel": "method",
# "on_trash": "method"
# "*": {{
# "on_update": "method",
# "on_cancel": "method",
# "on_trash": "method"
# }}
# }}

@@ -307,21 +308,21 @@ app_license = "{app_license}"
# ---------------

# scheduler_events = {{
# "all": [
# "{app_name}.tasks.all"
# ],
# "daily": [
# "{app_name}.tasks.daily"
# ],
# "hourly": [
# "{app_name}.tasks.hourly"
# ],
# "weekly": [
# "{app_name}.tasks.weekly"
# ],
# "monthly": [
# "{app_name}.tasks.monthly"
# ],
# "all": [
# "{app_name}.tasks.all"
# ],
# "daily": [
# "{app_name}.tasks.daily"
# ],
# "hourly": [
# "{app_name}.tasks.hourly"
# ],
# "weekly": [
# "{app_name}.tasks.weekly"
# ],
# "monthly": [
# "{app_name}.tasks.monthly"
# ],
# }}

# Testing
@@ -333,14 +334,14 @@ app_license = "{app_license}"
# ------------------------------
#
# override_whitelisted_methods = {{
# "frappe.desk.doctype.event.event.get_events": "{app_name}.event.get_events"
# "frappe.desk.doctype.event.event.get_events": "{app_name}.event.get_events"
# }}
#
# each overriding function accepts a `data` argument;
# generated from the base implementation of the doctype dashboard,
# along with any modifications made in other Frappe apps
# override_doctype_dashboards = {{
# "Task": "{app_name}.task.get_dashboard_data"
# "Task": "{app_name}.task.get_dashboard_data"
# }}

# exempt linked doctypes from being automatically cancelled
@@ -352,40 +353,32 @@ app_license = "{app_license}"
# --------------------

# user_data_fields = [
# {{
# "doctype": "{{doctype_1}}",
# "filter_by": "{{filter_by}}",
# "redact_fields": ["{{field_1}}", "{{field_2}}"],
# "partial": 1,
# }},
# {{
# "doctype": "{{doctype_2}}",
# "filter_by": "{{filter_by}}",
# "partial": 1,
# }},
# {{
# "doctype": "{{doctype_3}}",
# "strict": False,
# }},
# {{
# "doctype": "{{doctype_4}}"
# }}
# {{
# "doctype": "{{doctype_1}}",
# "filter_by": "{{filter_by}}",
# "redact_fields": ["{{field_1}}", "{{field_2}}"],
# "partial": 1,
# }},
# {{
# "doctype": "{{doctype_2}}",
# "filter_by": "{{filter_by}}",
# "partial": 1,
# }},
# {{
# "doctype": "{{doctype_3}}",
# "strict": False,
# }},
# {{
# "doctype": "{{doctype_4}}"
# }}
# ]

# Authentication and authorization
# --------------------------------

# auth_hooks = [
# "{app_name}.auth.validate"
# "{app_name}.auth.validate"
# ]

# Translation
# --------------------------------

# Make link fields search translated document names for these DocTypes
# Recommended only for DocTypes which have limited documents with untranslated names
# For example: Role, Gender, etc.
# translated_search_doctypes = []
"""

desktop_template = """from frappe import _


Načítá se…
Zrušit
Uložit