Parcourir la source

Merge branch 'frappe:develop' into develop

version-14
Vladislav il y a 3 ans
committed by GitHub
Parent
révision
2b8b97fcc9
Aucune clé connue n'a été trouvée dans la base pour cette signature ID de la clé GPG: 4AEE18F83AFDEB23
100 fichiers modifiés avec 2631 ajouts et 1018 suppressions
  1. +1
    -1
      .eslintrc
  2. +7
    -0
      .github/helper/install_dependencies.sh
  3. +2
    -2
      .github/try-on-f-cloud-button.svg
  4. +0
    -1
      .github/workflows/release.yml
  5. +21
    -17
      README.md
  6. +47
    -0
      cypress/fixtures/doctype_with_phone.js
  7. +129
    -0
      cypress/integration/control_data.js
  8. +8
    -3
      cypress/integration/control_dynamic_link.js
  9. +1
    -1
      cypress/integration/control_markdown_editor.js
  10. +90
    -0
      cypress/integration/control_phone.js
  11. +22
    -0
      cypress/integration/customize_form.js
  12. +1
    -1
      cypress/integration/form.js
  13. +12
    -10
      cypress/integration/kanban.js
  14. +2
    -2
      cypress/integration/timeline_email.js
  15. +105
    -8
      cypress/integration/workspace.js
  16. +15
    -0
      cypress/support/commands.js
  17. +38
    -29
      frappe/__init__.py
  18. +7
    -0
      frappe/boot.py
  19. +4
    -1
      frappe/client.py
  20. +2
    -2
      frappe/core/doctype/docfield/docfield.json
  21. +2
    -76
      frappe/core/doctype/doctype/doctype.js
  22. +1
    -1
      frappe/core/doctype/doctype/doctype.json
  23. +64
    -17
      frappe/core/doctype/doctype/doctype.py
  24. +18
    -3
      frappe/core/doctype/doctype/test_doctype.py
  25. +2
    -2
      frappe/core/doctype/server_script/server_script.json
  26. +1
    -0
      frappe/core/doctype/server_script/server_script_utils.py
  27. +14
    -2
      frappe/core/doctype/system_settings/system_settings.json
  28. +1
    -1
      frappe/custom/doctype/custom_field/custom_field.json
  29. +4
    -0
      frappe/custom/doctype/customize_form/customize_form.js
  30. +10
    -2
      frappe/custom/doctype/customize_form/customize_form.json
  31. +11
    -0
      frappe/custom/doctype/customize_form/customize_form.py
  32. +2
    -2
      frappe/custom/doctype/customize_form_field/customize_form_field.json
  33. +23
    -13
      frappe/database/database.py
  34. +2
    -1
      frappe/database/mariadb/database.py
  35. +1
    -2
      frappe/database/mariadb/schema.py
  36. +8
    -5
      frappe/database/postgres/database.py
  37. +1
    -1
      frappe/database/postgres/framework_postgres.sql
  38. +1
    -2
      frappe/database/postgres/schema.py
  39. +15
    -9
      frappe/database/query.py
  40. +28
    -25
      frappe/database/sequence.py
  41. +4
    -1
      frappe/desk/doctype/number_card/number_card.js
  42. +18
    -9
      frappe/desk/doctype/number_card/number_card.py
  43. +23
    -6
      frappe/desk/doctype/workspace/workspace.py
  44. +3
    -1
      frappe/desk/query_report.py
  45. +13
    -12
      frappe/desk/search.py
  46. +11
    -6
      frappe/email/doctype/auto_email_report/auto_email_report.py
  47. +4
    -3
      frappe/email/doctype/email_account/email_account.js
  48. +3
    -1
      frappe/email/doctype/email_queue/email_queue.py
  49. +2
    -2
      frappe/email/doctype/newsletter/newsletter.py
  50. +21
    -0
      frappe/email/doctype/newsletter/test_newsletter.py
  51. +3
    -3
      frappe/email/email_body.py
  52. +492
    -246
      frappe/geo/country_info.json
  53. +1
    -1
      frappe/hooks.py
  54. +3
    -3
      frappe/integrations/doctype/razorpay_settings/razorpay_settings.py
  55. +4
    -0
      frappe/model/__init__.py
  56. +46
    -45
      frappe/model/base_document.py
  57. +51
    -50
      frappe/model/db_query.py
  58. +33
    -12
      frappe/model/document.py
  59. +3
    -0
      frappe/model/meta.py
  60. +17
    -20
      frappe/model/naming.py
  61. +250
    -221
      frappe/model/rename_doc.py
  62. +3
    -3
      frappe/parallel_test_runner.py
  63. +71
    -2
      frappe/public/js/frappe/doctype/index.js
  64. +1
    -5
      frappe/public/js/frappe/form/controls/base_input.js
  65. +1
    -0
      frappe/public/js/frappe/form/controls/control.js
  66. +1
    -1
      frappe/public/js/frappe/form/controls/icon.js
  67. +1
    -1
      frappe/public/js/frappe/form/controls/link.js
  68. +197
    -0
      frappe/public/js/frappe/form/controls/phone.js
  69. +1
    -1
      frappe/public/js/frappe/form/dashboard.js
  70. +12
    -9
      frappe/public/js/frappe/form/form.js
  71. +23
    -13
      frappe/public/js/frappe/form/grid_row.js
  72. +2
    -2
      frappe/public/js/frappe/form/layout.js
  73. +1
    -1
      frappe/public/js/frappe/form/multi_select_dialog.js
  74. +36
    -13
      frappe/public/js/frappe/form/toolbar.js
  75. +1
    -1
      frappe/public/js/frappe/model/model.js
  76. +103
    -0
      frappe/public/js/frappe/phone_picker/phone_picker.js
  77. +7
    -6
      frappe/public/js/frappe/ui/field_group.js
  78. +8
    -10
      frappe/public/js/frappe/ui/keyboard.js
  79. +1
    -1
      frappe/public/js/frappe/ui/sort_selector.js
  80. +1
    -1
      frappe/public/js/frappe/utils/dashboard_utils.js
  81. +6
    -0
      frappe/public/js/frappe/utils/utils.js
  82. +1
    -1
      frappe/public/js/frappe/views/file/file_view.js
  83. +1
    -1
      frappe/public/js/frappe/views/workspace/blocks/block.js
  84. +1
    -1
      frappe/public/js/frappe/views/workspace/blocks/card.js
  85. +1
    -1
      frappe/public/js/frappe/views/workspace/blocks/chart.js
  86. +1
    -1
      frappe/public/js/frappe/views/workspace/blocks/onboarding.js
  87. +1
    -1
      frappe/public/js/frappe/views/workspace/blocks/shortcut.js
  88. +25
    -20
      frappe/public/js/frappe/widgets/widget_dialog.js
  89. +3
    -3
      frappe/public/scss/common/controls.scss
  90. +144
    -0
      frappe/public/scss/common/phone_picker.scss
  91. +10
    -0
      frappe/public/scss/common/quill.scss
  92. +4
    -0
      frappe/query_builder/utils.py
  93. +16
    -13
      frappe/test_runner.py
  94. +37
    -0
      frappe/tests/test_client.py
  95. +9
    -0
      frappe/tests/test_db.py
  96. +44
    -9
      frappe/tests/test_db_query.py
  97. +14
    -13
      frappe/tests/test_permissions.py
  98. +26
    -0
      frappe/tests/test_rename_doc.py
  99. +54
    -0
      frappe/tests/test_sequence.py
  100. +34
    -0
      frappe/tests/test_test_utils.py

+ 1
- 1
.eslintrc Voir le fichier

@@ -5,7 +5,7 @@
"es6": true
},
"parserOptions": {
"ecmaVersion": 9,
"ecmaVersion": 11,
"sourceType": "module"
},
"extends": "eslint:recommended",


+ 7
- 0
.github/helper/install_dependencies.sh Voir le fichier

@@ -2,6 +2,13 @@

set -e

# Check for merge conflicts before proceeding
python -m compileall -f "${GITHUB_WORKSPACE}"
if grep -lr --exclude-dir=node_modules "^<<<<<<< " "${GITHUB_WORKSPACE}"
then echo "Found merge conflicts"
exit 1
fi

# install wkhtmltopdf
wget -O /tmp/wkhtmltox.tar.xz https://github.com/frappe/wkhtmltopdf/raw/master/wkhtmltox-0.12.3_linux-generic-amd64.tar.xz
tar -xf /tmp/wkhtmltox.tar.xz -C /tmp


+ 2
- 2
.github/try-on-f-cloud-button.svg Voir le fichier

@@ -1,4 +1,4 @@
<svg width="201" height="60" viewBox="0 0 201 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="4 2 193 52">
<g filter="url(#filter0_dd)">
<rect x="4" y="2" width="193" height="52" rx="6" fill="#2490EF"/>
<path d="M28 22.2891H32.8786V35.5H36.2088V22.2891H41.0874V19.5H28V22.2891Z" fill="white"/>
@@ -29,4 +29,4 @@
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow" result="shape"/>
</filter>
</defs>
</svg>
</svg>

+ 0
- 1
.github/workflows/release.yml Voir le fichier

@@ -22,7 +22,6 @@ jobs:
npm install @semantic-release/git @semantic-release/exec --no-save
- name: Create Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GIT_AUTHOR_NAME: "Frappe PR Bot"


+ 21
- 17
README.md Voir le fichier

@@ -1,16 +1,16 @@
<div align="center">
<h1>
<br>
<a href="https://frappeframework.com">
<img src=".github/frappe-framework-logo.svg" height="50">
</a>
</h1>
<h3>
a web framework with <a href="https://www.youtube.com/watch?v=LOjk3m0wTwg">"batteries included"</a>
</h3>
<h5>
it's pronounced - <em>fra-pay</em>
</h5>
<h1>
<br>
<a href="https://frappeframework.com">
<img src=".github/frappe-framework-logo.svg" height="50">
</a>
</h1>
<h3>
a web framework with <a href="https://www.youtube.com/watch?v=LOjk3m0wTwg">"batteries included"</a>
</h3>
<h5>
it's pronounced - <em>fra-pay</em>
</h5>
</div>

<div align="center">
@@ -27,20 +27,24 @@
<img src='https://www.codetriage.com/frappe/frappe/badges/users.svg'>
</a>
<a href="https://codecov.io/gh/frappe/frappe">
<img src="https://codecov.io/gh/frappe/frappe/branch/develop/graph/badge.svg?token=XoTa679hIj"/>
<img src="https://codecov.io/gh/frappe/frappe/branch/develop/graph/badge.svg?token=XoTa679hIj"/>
</a>
</div>



Full-stack web application framework that uses Python and MariaDB on the server side and a tightly integrated client side library. Built for [ERPNext](https://erpnext.com)

<div align="center">
<a href="https://frappecloud.com/deploy?apps=frappe&source=frappe_readme">
<div align="center" style="max-height: 40px;">
<a href="https://frappecloud.com/frappe/signup">
<img src=".github/try-on-f-cloud-button.svg" height="40">
</a>
<a href="https://labs.play-with-docker.com/?stack=https://raw.githubusercontent.com/gavindsouza/install-scripts/main/frappe/pwd.yml">
<img src="https://raw.githubusercontent.com/play-with-docker/stacks/master/assets/images/button.png" alt="Try in PWD" height="37"/>
</a>
</div>

> Login for the PWD site: (username: Administrator, password: admin)

## Table of Contents
* [Installation](#installation)
* [Contributing](#contributing)
@@ -52,7 +56,7 @@ Full-stack web application framework that uses Python and MariaDB on the server
* [Install via Docker](https://github.com/frappe/frappe_docker)
* [Install via Frappe Bench](https://github.com/frappe/bench)
* [Offical Documentation](https://frappeframework.com/docs/user/en/installation)
* [Managed Hosting on Frappe Cloud](https://frappecloud.com/deploy?apps=frappe&source=frappe_readme)
* [Managed Hosting on Frappe Cloud](https://frappecloud.com/frappe/signup)

## Contributing



+ 47
- 0
cypress/fixtures/doctype_with_phone.js Voir le fichier

@@ -0,0 +1,47 @@
export default {
name: "Doctype With Phone",
actions: [],
custom: 1,
is_submittable: 1,
autoname: "field:title",
creation: '2022-03-30 06:29:07.215072',
doctype: 'DocType',
engine: 'InnoDB',
fields: [

{
fieldname: 'title',
fieldtype: 'Data',
label: 'title',
unique: 1,
},
{
fieldname: 'phone',
fieldtype: 'Phone',
label: 'Phone'
}
],
links: [],
modified: '2019-03-30 14:40:53.127615',
modified_by: 'Administrator',
naming_rule: "By fieldname",
module: 'Custom',
owner: 'Administrator',
permissions: [
{
create: 1,
delete: 1,
email: 1,
print: 1,
read: 1,
role: 'System Manager',
share: 1,
write: 1,
submit: 1,
cancel: 1
}
],
sort_field: 'modified',
sort_order: 'ASC',
track_changes: 1
};

+ 129
- 0
cypress/integration/control_data.js Voir le fichier

@@ -0,0 +1,129 @@
context('Data Control', () => {
before(() => {
cy.login();
cy.visit('/app/doctype');
return cy.window().its('frappe').then(frappe => {
return frappe.xcall('frappe.tests.ui_test_helpers.create_doctype', {
name: 'Test Data Control',
fields: [
{
"label": "Name",
"fieldname": "name1",
"fieldtype": "Data",
"options": "Name",
"in_list_view": 1,
"reqd": 1,
},
{
"label": "Email-ID",
"fieldname": "email",
"fieldtype": "Data",
"options": "Email",
"in_list_view": 1,
"reqd": 1,
},
{
"label": "Phone No.",
"fieldname": "phone",
"fieldtype": "Data",
"options": "Phone",
"in_list_view": 1,
"reqd": 1,
},
]
});
});
});
it('Verifying data control by inputting different patterns for "Name" field', () => {
cy.new_form('Test Data Control');

//Checking the URL for the new form of the doctype
cy.location("pathname").should('eq', '/app/test-data-control/new-test-data-control-1');
cy.get('.title-text').should('have.text', 'New Test Data Control');
cy.get('.frappe-control[data-fieldname="name1"]').find('label').should('have.class', 'reqd');
cy.get('.frappe-control[data-fieldname="email"]').find('label').should('have.class', 'reqd');
cy.get('.frappe-control[data-fieldname="phone"]').find('label').should('have.class', 'reqd');

//Checking if the status is "Not Saved" initially
cy.get('.indicator-pill').should('have.text', 'Not Saved');

//Inputting data in the field
cy.fill_field('name1', '@@###', 'Data');
cy.fill_field('email', 'test@example.com', 'Data');
cy.fill_field('phone', '9834280031', 'Data');

//Checking if the border color of the field changes to red
cy.get('.frappe-control[data-fieldname="name1"]').should('have.class', 'has-error');
cy.findByRole('button', {name: 'Save'}).click();

//Checking for the error message
cy.get('.modal-title').should('have.text', 'Message');
cy.get('.msgprint').should('have.text', '@@### is not a valid Name');
cy.get('.modal').type('{esc}');

cy.get_field('name1', 'Data').clear({force: true});
cy.fill_field('name1', 'Komal{}/!', 'Data');
cy.get('.frappe-control[data-fieldname="name1"]').should('have.class', 'has-error');
cy.findByRole('button', {name: 'Save'}).click();
cy.get('.modal-title').should('have.text', 'Message');
cy.get('.msgprint').should('have.text', 'Komal{}/! is not a valid Name');
});

it('Verifying data control by inputting different patterns for "Email" field', () => {
cy.get('.modal-actions > .btn-modal-close').trigger("click");
cy.get_field('name1', 'Data').clear({force: true});
cy.fill_field('name1', 'Komal', 'Data');
cy.get_field('email', 'Data').clear({force: true});
cy.fill_field('email', 'komal', 'Data');
cy.get('.frappe-control[data-fieldname="email"]').should('have.class', 'has-error');
cy.findByRole('button', {name: 'Save'}).click();
cy.get('.modal-title').should('have.text', 'Message');
cy.get('.msgprint').should('have.text', 'komal is not a valid Email Address');
cy.get('.modal-actions > .btn-modal-close').trigger("click");
cy.get_field('email', 'Data').clear({force: true});
cy.fill_field('email', 'komal@test', 'Data');
cy.get('.frappe-control[data-fieldname="email"]').should('have.class', 'has-error');
cy.findByRole('button', {name: 'Save'}).click();
cy.get('.modal-title').should('have.text', 'Message');
cy.get('.msgprint').should('have.text', 'komal@test is not a valid Email Address');
});

it('Verifying data control by inputting different patterns for "Phone" field', () => {
cy.get('.modal-actions > .btn-modal-close').trigger("click");
cy.get_field('email', 'Data').clear({force: true});
cy.fill_field('email', 'komal@test.com', 'Data');
cy.get_field('phone', 'Data').clear({force: true});
cy.fill_field('phone', 'komal', 'Data');
cy.get('.frappe-control[data-fieldname="phone"]').should('have.class', 'has-error');
cy.findByRole('button', {name: 'Save'}).click({force: true});
cy.get('.modal-title').should('have.text', 'Message');
cy.get('.msgprint').should('have.text', 'komal is not a valid Phone Number');
cy.get('.modal-actions > .btn-modal-close').trigger("click");
});

it('Inputting correct data and saving the doc', () => {
//Inputting the data as expected and saving the document
cy.get_field('name1', 'Data').clear({force: true});
cy.get_field('email', 'Data').clear({force: true});
cy.get_field('phone', 'Data').clear({force: true});
cy.fill_field('name1', 'Komal', 'Data');
cy.fill_field('email', 'komal@test.com', 'Data');
cy.fill_field('phone', '9432380001', 'Data');
cy.findByRole('button', {name: 'Save'}).click({force: true});
//Checking if the fields contains the data which has been filled in
cy.location("pathname").should('not.be', '/app/test-data-control/new-test-data-control-1');
cy.get_field('name1').should('have.value', 'Komal');
cy.get_field('email').should('have.value', 'komal@test.com');
cy.get_field('phone').should('have.value', '9432380001');
});

it('Deleting the doc', () => {
//Deleting the inserted document
cy.go_to_list('Test Data Control');
cy.get('.list-row-checkbox').eq(0).click({force: true});
cy.get('.actions-btn-group > .btn').contains('Actions').click();
cy.get('.actions-btn-group > .dropdown-menu [data-label="Delete"]').click();
cy.click_modal_primary_button('Yes');
cy.get('.btn-modal-close').click();
});
});

+ 8
- 3
cypress/integration/control_dynamic_link.js Voir le fichier

@@ -62,8 +62,8 @@ context('Dynamic Link', () => {
"label": "Document ID",
"fieldname": "doc_id",
"fieldtype": "Dynamic Link",
"get_options": () => {
return "User";
"get_options": () => {
return "User";
},
"in_list_view": 1,
}]
@@ -118,11 +118,16 @@ context('Dynamic Link', () => {
cy.get_field('doc_type').clear();

//Entering System Settings in the Doctype field
cy.intercept('/api/method/frappe.desk.search.search_link').as('search_query');
cy.fill_field('doc_type', 'System Settings', 'Link', {delay: 500});
cy.wait('@search_query');
cy.get(`[data-fieldname="doc_type"] ul:visible li:first-child`)
.click({scrollBehavior: false});

cy.get_field('doc_id').click();

//Checking if the system throws error
cy.get('.modal-title').should('have.text', 'Error');
cy.get('.msgprint').should('have.text', 'System Settings is not a valid DocType for Dynamic Link');
});
});
});

+ 1
- 1
cypress/integration/control_markdown_editor.js Voir le fichier

@@ -16,7 +16,7 @@ context("Control Markdown Editor", () => {
cy.click_modal_primary_button("Upload");
cy.get_field("main_section_md", "Markdown Editor").should(
"contain",
"![](/files/sample_image.jpg)"
"![](/files/sample_image"
);
});
});

+ 90
- 0
cypress/integration/control_phone.js Voir le fichier

@@ -0,0 +1,90 @@
import doctype_with_phone from '../fixtures/doctype_with_phone';

context("Control Phone", () => {
before(() => {
cy.login();
cy.visit("/app/website");
});

function get_dialog_with_phone() {
return cy.dialog({
title: "Phone",
fields: [{
"fieldname": "phone",
"fieldtype": "Phone",
}]
});
}

it("should set flag and data", () => {
get_dialog_with_phone().as("dialog");
cy.get(".selected-phone").click();
cy.get(".phone-picker .phone-wrapper[id='afghanistan']").click();
cy.get(".selected-phone").click();
cy.get(".phone-picker .phone-wrapper[id='india']").click();
cy.get(".selected-phone .country").should("have.text", "+91");
cy.get(".selected-phone > img").should("have.attr", "src").and("include", "/in.svg");

let phone_number = "9312672712";
cy.get(".selected-phone > img").click().first();
cy.get_field("phone")
.first()
.click({multiple: true});
cy.get(".frappe-control[data-fieldname=phone]")
.findByRole("textbox")
.first()
.type(phone_number, {force: true});

cy.get_field("phone").first().should("have.value", phone_number);
cy.get_field("phone").first().blur({force: true});
cy.wait(100);
cy.get("@dialog").then(dialog => {
let value = dialog.get_value("phone");
expect(value).to.equal("+91-" + phone_number);
});
});

it("case insensitive search for country and clear search", () => {
let search_text = "india";
cy.get(".selected-phone").click().first();
cy.get(".phone-picker").findByRole("searchbox").click().type(search_text);
cy.get(".phone-section .phone-wrapper:not(.hidden)").then(i => {
cy.get(`.phone-section .phone-wrapper[id*="${search_text.toLowerCase()}"]`).then(countries => {
expect(i.length).to.equal(countries.length);
});
});

cy.get(".phone-picker").findByRole("searchbox").clear().blur();
cy.get(".phone-section .phone-wrapper").should("not.have.class", "hidden");
});

it("existing document should render phone field with data", () => {
cy.visit("/app/doctype");
cy.insert_doc("DocType", doctype_with_phone, true);
cy.clear_cache();

// Creating custom doctype
cy.insert_doc("DocType", doctype_with_phone, true);
cy.visit("/app/doctype-with-phone");
cy.click_listview_primary_button("Add Doctype With Phone");

// create a record
cy.fill_field("title", "Test Phone 1");
cy.fill_field("phone", "+91-9823341234");
cy.get_field("phone").should("have.value", "9823341234");
cy.click_doc_primary_button("Save");
cy.get_doc("Doctype With Phone", "Test Phone 1").then((doc) => {
let value = doc.data.phone;
expect(value).to.equal("+91-9823341234");
});

// open the doc from list view
cy.go_to_list("Doctype With Phone");
cy.clear_cache();
cy.click_listview_row_item(0);
cy.title().should("eq", "Test Phone 1");
cy.get(".selected-phone .country").should("have.text", "+91");
cy.get(".selected-phone > img").should("have.attr", "src").and("include", "/in.svg");
cy.get_field("phone").should("have.value", "9823341234");
});
});

+ 22
- 0
cypress/integration/customize_form.js Voir le fichier

@@ -0,0 +1,22 @@
context('Customize Form', () => {
before(() => {
cy.visit('/app/customize-form');
});
it('Changing to naming rule should update autoname', () => {
cy.fill_field("doc_type", "ToDo", "Link").blur();
cy.click_form_section("Naming");
const naming_rule_default_autoname_map = {
"Set by user": "prompt",
"By fieldname": "field:",
'By "Naming Series" field': "naming_series:",
"Expression": "format:",
"Expression (old style)": "",
"Random": "hash",
"By script": ""
};
Cypress._.forOwn(naming_rule_default_autoname_map, (value, naming_rule) => {
cy.fill_field("naming_rule", naming_rule, "Select");
cy.get_field("autoname", "Data").should("have.value", value);
});
});
});

+ 1
- 1
cypress/integration/form.js Voir le fichier

@@ -27,7 +27,7 @@ context('Form', () => {

cy.clear_filters();
cy.get('.standard-filter-section [data-fieldname="name"] input').type('Test Form Contact 3').blur();
cy.click_listview_row_item(0);
cy.click_listview_row_item_with_text('Test Form Contact 3');

cy.get('#page-Contact .page-head').findByTitle('Test Form Contact 3').should('exist');
cy.get('.prev-doc').should('be.visible').click();


+ 12
- 10
cypress/integration/kanban.js Voir le fichier

@@ -72,14 +72,16 @@ context('Kanban Board', () => {

});

it('Drag todo', () => {
cy.intercept({
method: 'POST',
url: 'api/method/frappe.desk.doctype.kanban_board.kanban_board.update_order_for_single_card'
}).as('drag-completed');

cy.get('.kanban-card-body:first').drag('[data-column-value="Closed"] .kanban-cards', {force: true});

cy.wait('@drag-completed');
});
// it('Drag todo', () => {
// cy.intercept({
// method: 'POST',
// url: 'api/method/frappe.desk.doctype.kanban_board.kanban_board.update_order_for_single_card'
// }).as('drag-completed');

// cy.get('.kanban-card-body')
// .contains('Test Kanban ToDo').first()
// .drag('[data-column-value="Closed"] .kanban-cards', { force: true });

// cy.wait('@drag-completed');
// });
});

+ 2
- 2
cypress/integration/timeline_email.js Voir le fichier

@@ -16,7 +16,7 @@ context('Timeline Email', () => {

it('Adding email and verifying timeline content for email attachment', () => {
cy.visit('/app/todo');
cy.get('.list-row > .level-left > .list-subject').eq(0).click();
cy.click_listview_row_item_with_text('Test ToDo');

//Creating a new email
cy.get('.timeline-actions > .timeline-item > .action-buttons > .action-btn').click();
@@ -47,7 +47,7 @@ context('Timeline Email', () => {

it('Deleting attachment and ToDo', () => {
cy.visit('/app/todo');
cy.get('.list-row > .level-left > .list-subject > .level-item.ellipsis > .ellipsis').eq(0).click();
cy.click_listview_row_item_with_text('Test ToDo');

//Removing the added attachment
cy.get('.attachment-row > .data-pill > .remove-btn > .icon').click();


+ 105
- 8
cypress/integration/workspace.js Voir le fichier

@@ -2,7 +2,6 @@ context('Workspace 2.0', () => {
before(() => {
cy.visit('/login');
cy.login();
cy.visit('/app/website');
});

it('Navigate to page from sidebar', () => {
@@ -13,6 +12,11 @@ context('Workspace 2.0', () => {
});

it('Create Private Page', () => {
cy.intercept({
method: 'POST',
url: 'api/method/frappe.desk.doctype.workspace.workspace.new_page'
}).as('new_page');

cy.get('.codex-editor__redactor .ce-block');
cy.get('.custom-actions button[data-label="Create%20Workspace"]').click();
cy.fill_field('title', 'Test Private Page', 'Data');
@@ -27,12 +31,100 @@ context('Workspace 2.0', () => {
cy.wait(300);
cy.get('.sidebar-item-container[item-name="Test Private Page"]').should('have.attr', 'item-public', '0');

cy.wait(500);
cy.wait('@new_page');
});

it('Create Child Page', () => {
cy.intercept({
method: 'POST',
url: 'api/method/frappe.desk.doctype.workspace.workspace.new_page'
}).as('new_page');

cy.get('.codex-editor__redactor .ce-block');
cy.get('.custom-actions button[data-label="Create%20Workspace"]').click();
cy.fill_field('title', 'Test Child Page', 'Data');
cy.fill_field('parent', 'Test Private Page', 'Select');
cy.fill_field('icon', 'edit', 'Icon');
cy.get_open_dialog().find('.modal-header').click();
cy.get_open_dialog().find('.btn-primary').click();

// check if sidebar item is added in pubic section
cy.get('.sidebar-item-container[item-name="Test Child Page"]').should('have.attr', 'item-public', '0');

cy.get('.standard-actions .btn-primary[data-label="Save"]').click();
cy.wait(300);
cy.get('.sidebar-item-container[item-name="Test Child Page"]').should('have.attr', 'item-public', '0');

cy.wait('@new_page');
});

it('Duplicate Page', () => {
cy.intercept({
method: 'POST',
url: 'api/method/frappe.desk.doctype.workspace.workspace.duplicate_page'
}).as('page_duplicated');

cy.get('.codex-editor__redactor .ce-block');
cy.get('.standard-actions .btn-secondary[data-label=Edit]').click();

cy.get('.sidebar-item-container[item-name="Test Private Page"]').as('sidebar-item');

cy.get('@sidebar-item').find('.standard-sidebar-item').first().click();
cy.get('@sidebar-item').find('.dropdown-btn').first().click();
cy.get('@sidebar-item').find('.dropdown-list .dropdown-item').contains('Duplicate').first().click({force: true});

cy.get_open_dialog().fill_field('title', 'Duplicate Page', 'Data');
cy.click_modal_primary_button('Duplicate');

cy.wait('@page_duplicated');
});

it('Drag Sidebar Item', () => {
cy.intercept({
method: 'POST',
url: 'api/method/frappe.desk.doctype.workspace.workspace.sort_pages'
}).as('page_sorted');

cy.get('.sidebar-item-container[item-name="Duplicate Page"]').as('sidebar-item');

cy.get('@sidebar-item').find('.standard-sidebar-item').first().click();
cy.get('@sidebar-item').find('.drag-handle').first().move({ deltaX: 0, deltaY: 100 });

cy.get('.sidebar-item-container[item-name="Build"]').as('sidebar-item');

cy.get('@sidebar-item').find('.standard-sidebar-item').first().click();
cy.get('@sidebar-item').find('.drag-handle').first().move({ deltaX: 0, deltaY: 100 });

cy.wait('@page_sorted');
});

it('Edit Page Detail', () => {
cy.intercept({
method: 'POST',
url: 'api/method/frappe.desk.doctype.workspace.workspace.update_page'
}).as('page_updated');

cy.get('.sidebar-item-container[item-name="Test Private Page"]').as('sidebar-item');

cy.get('@sidebar-item').find('.standard-sidebar-item').first().click();
cy.get('@sidebar-item').find('.dropdown-btn').first().click();
cy.get('@sidebar-item').find('.dropdown-list .dropdown-item').contains('Edit').first().click({force: true});

cy.get_open_dialog().fill_field('title', ' 1', 'Data');
cy.get_open_dialog().find('input[data-fieldname="is_public"]').check();
cy.click_modal_primary_button('Update');

cy.get('.standard-sidebar-section:first .sidebar-item-container[item-name="Test Private Page"]').should('not.exist');
cy.get('.standard-sidebar-section:last .sidebar-item-container[item-name="Test Private Page 1"]').should('exist');

cy.wait('@page_updated');
});

it('Add New Block', () => {
cy.get('.sidebar-item-container[item-name="Duplicate Page"]').as('sidebar-item');

cy.get('@sidebar-item').find('.standard-sidebar-item').first().click();

cy.get('.ce-block').click().type('{enter}');
cy.get('.block-list-container .block-list-item').contains('Heading').click();
cy.get(":focus").type('Header');
@@ -70,19 +162,24 @@ context('Workspace 2.0', () => {
cy.get('.standard-actions .btn-primary[data-label="Save"]').click();
});

it('Delete Private Page', () => {
it('Delete Duplicate Page', () => {
cy.intercept({
method: 'POST',
url: 'api/method/frappe.desk.doctype.workspace.workspace.delete_page'
}).as('page_deleted');

cy.get('.codex-editor__redactor .ce-block');
cy.get('.standard-actions .btn-secondary[data-label=Edit]').click();

cy.get('.sidebar-item-container[item-name="Test Private Page"]')
cy.get('.sidebar-item-container[item-name="Duplicate Page"]')
.find('.sidebar-item-control .setting-btn').click();
cy.get('.sidebar-item-container[item-name="Test Private Page"]')
cy.get('.sidebar-item-container[item-name="Duplicate Page"]')
.find('.dropdown-item[title="Delete Workspace"]').click({force: true});
cy.wait(300);
cy.get('.modal-footer > .standard-actions > .btn-modal-primary:visible').first().click();
cy.get('.standard-actions .btn-primary[data-label="Save"]').click();
cy.get('.codex-editor__redactor .ce-block');
cy.get('.sidebar-item-container[item-name="Test Private Page"]').should('not.exist');
cy.get('.sidebar-item-container[item-name="Duplicate Page"]').should('not.exist');
cy.wait('@page_deleted');
});

});

+ 15
- 0
cypress/support/commands.js Voir le fichier

@@ -352,6 +352,13 @@ Cypress.Commands.add('click_listview_row_item', (row_no) => {
cy.get('.list-row > .level-left > .list-subject > .level-item > .ellipsis').eq(row_no).click({force: true});
});

Cypress.Commands.add('click_listview_row_item_with_text', (text) => {
cy.get('.list-row > .level-left > .list-subject > .level-item > .ellipsis')
.contains(text)
.first()
.click({force: true});
});

Cypress.Commands.add('click_filter_button', () => {
cy.get('.filter-selector > .btn').click();
});
@@ -360,6 +367,10 @@ Cypress.Commands.add('click_listview_primary_button', (btn_name) => {
cy.get('.primary-action').contains(btn_name).click({force: true});
});

Cypress.Commands.add('click_doc_primary_button', (btn_name) => {
cy.get('.primary-action').contains(btn_name).click({force: true});
});

Cypress.Commands.add('click_timeline_action_btn', (btn_name) => {
cy.get('.timeline-message-box .actions .action-btn').contains(btn_name).click();
});
@@ -367,3 +378,7 @@ Cypress.Commands.add('click_timeline_action_btn', (btn_name) => {
Cypress.Commands.add('select_listview_row_checkbox', (row_no) => {
cy.get('.frappe-list .select-like > .list-row-checkbox').eq(row_no).click();
});

Cypress.Commands.add('click_form_section', (section_name) => {
cy.get('.section-head').contains(section_name).click();
});

+ 38
- 29
frappe/__init__.py Voir le fichier

@@ -199,6 +199,7 @@ def init(site, sites_path=None, new_site=False):
}
)
local.rollback_observers = []
local.locked_documents = []
local.before_commit = []
local.test_objects = {}

@@ -231,7 +232,6 @@ def init(site, sites_path=None, new_site=False):
local.cache = {}
local.document_cache = {}
local.meta_cache = {}
local.autoincremented_status_map = {site: -1}
local.form_dict = _dict()
local.session = _dict()
local.dev_server = _dev_server
@@ -354,11 +354,11 @@ def cache() -> "RedisWrapper":
return redis_server


def get_traceback():
def get_traceback(with_context=False):
"""Returns error traceback."""
from frappe.utils import get_traceback

return get_traceback()
return get_traceback(with_context=with_context)


def errprint(msg):
@@ -1210,18 +1210,35 @@ def reload_doc(module, dt=None, dn=None, force=False, reset_permissions=False):


@whitelist()
def rename_doc(*args, **kwargs):
def rename_doc(
doctype: str,
old: str,
new: str,
force: bool = False,
merge: bool = False,
*,
ignore_if_exists: bool = False,
show_alert: bool = True,
rebuild_search: bool = True,
) -> str:
"""
Renames a doc(dt, old) to doc(dt, new) and updates all linked fields of type "Link"

Calls `frappe.model.rename_doc.rename_doc`
"""
kwargs.pop("ignore_permissions", None)
kwargs.pop("cmd", None)

from frappe.model.rename_doc import rename_doc

return rename_doc(*args, **kwargs)
return rename_doc(
doctype=doctype,
old=old,
new=new,
force=force,
merge=merge,
ignore_if_exists=ignore_if_exists,
show_alert=show_alert,
rebuild_search=rebuild_search,
)


def get_module(modulename):
@@ -1490,10 +1507,11 @@ def get_newargs(fn, kwargs):
if hasattr(fn, "fnargs"):
fnargs = fn.fnargs
else:
fullargspec = inspect.getfullargspec(fn)
fnargs = fullargspec.args
fnargs.extend(fullargspec.kwonlyargs)
varkw = fullargspec.varkw
signature = inspect.signature(fn)
fnargs = list(signature.parameters)
varkw = "kwargs" in fnargs
if varkw:
fnargs.pop(-1)

newargs = {}
for a in kwargs:
@@ -1907,7 +1925,7 @@ def attach_print(

if not file_name:
file_name = name
file_name = file_name.replace(" ", "").replace("/", "-")
file_name = cstr(file_name).replace(" ", "").replace("/", "-")

print_settings = db.get_singles_dict("Print Settings")

@@ -2069,7 +2087,6 @@ def logger(

def log_error(title=None, message=None, reference_doctype=None, reference_name=None):
"""Log error to Error Log"""

# Parameter ALERT:
# the title and message may be swapped
# the better API for this is log_error(title, message), and used in many cases this way
@@ -2082,20 +2099,15 @@ def log_error(title=None, message=None, reference_doctype=None, reference_name=N
else:
traceback = message

if not traceback:
traceback = get_traceback()

if not title:
title = "Error"
title = title or "Error"
traceback = as_unicode(traceback or get_traceback(with_context=True))

return get_doc(
dict(
doctype="Error Log",
error=as_unicode(traceback),
method=title,
reference_doctype=reference_doctype,
reference_name=reference_name,
)
doctype="Error Log",
error=traceback,
method=title,
reference_doctype=reference_doctype,
reference_name=reference_name,
).insert(ignore_permissions=True)


@@ -2252,7 +2264,4 @@ def mock(type, size=1, locale="en"):
return squashify(results)


def validate_and_sanitize_search_inputs(fn):
from frappe.desk.search import validate_and_sanitize_search_inputs as func

return func(fn)
from frappe.desk.search import validate_and_sanitize_search_inputs # noqa

+ 7
- 0
frappe/boot.py Voir le fichier

@@ -11,6 +11,7 @@ from frappe.core.doctype.navbar_settings.navbar_settings import get_app_logo, ge
from frappe.desk.doctype.route_history.route_history import frequently_visited_links
from frappe.desk.form.load import get_meta_bundle
from frappe.email.inbox import get_email_accounts
from frappe.geo.country_info import get_all
from frappe.model.base_document import get_controller
from frappe.query_builder import DocType
from frappe.query_builder.functions import Count
@@ -67,6 +68,7 @@ def get_bootinfo():
bootinfo.home_folder = frappe.db.get_value("File", {"is_home_folder": 1})
bootinfo.navbar_settings = get_navbar_settings()
bootinfo.notification_settings = get_notification_settings()
get_country_codes(bootinfo)
set_time_zone(bootinfo)

# ipinfo
@@ -384,6 +386,11 @@ def get_notification_settings():
return frappe.get_cached_doc("Notification Settings", frappe.session.user)


def get_country_codes(bootinfo):
country_codes = get_all()
bootinfo.country_codes = frappe._dict(country_codes)


@frappe.whitelist()
def get_link_title_doctypes():
dts = frappe.get_all("DocType", {"show_title_field_in_link": 1})


+ 4
- 1
frappe/client.py Voir le fichier

@@ -189,7 +189,10 @@ def insert(doc=None):
if isinstance(doc, str):
doc = json.loads(doc)

if doc.get("parenttype"):
doc = frappe._dict(doc)
if frappe.is_table(doc.doctype):
if not (doc.parenttype and doc.parent and doc.parentfield):
frappe.throw(_("parenttype, parent and parentfield are required to insert a child record"))
# inserting a child record
parent = frappe.get_doc(doc.parenttype, doc.parent)
parent.append(doc.parentfield, doc)


+ 2
- 2
frappe/core/doctype/docfield/docfield.json Voir le fichier

@@ -99,7 +99,7 @@
"label": "Type",
"oldfieldname": "fieldtype",
"oldfieldtype": "Select",
"options": "Autocomplete\nAttach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nJSON\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRead Only\nRating\nSection Break\nSelect\nSignature\nSmall Text\nTab Break\nTable\nTable MultiSelect\nText\nText Editor\nTime",
"options": "Autocomplete\nAttach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nJSON\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nPhone\nRead Only\nRating\nSection Break\nSelect\nSignature\nSmall Text\nTab Break\nTable\nTable MultiSelect\nText\nText Editor\nTime",
"reqd": 1,
"search_index": 1
},
@@ -557,4 +557,4 @@
"sort_field": "modified",
"sort_order": "ASC",
"states": []
}
}

+ 2
- 76
frappe/core/doctype/doctype/doctype.js Voir le fichier

@@ -57,8 +57,8 @@ frappe.ui.form.on('DocType', {
frm.get_docfield('fields', 'in_list_view').label = frm.doc.istable ?
__('In Grid View') : __('In List View');

frm.events.autoname(frm);
frm.events.set_naming_rule_description(frm);
frm.cscript.autoname(frm);
frm.cscript.set_naming_rule_description(frm);
},

istable: (frm) => {
@@ -67,80 +67,6 @@ frappe.ui.form.on('DocType', {
frm.set_value('allow_rename', 0);
}
},

naming_rule: function(frm) {
// set the "autoname" property based on naming_rule
if (frm.doc.naming_rule && !frm.__from_autoname) {

// flag to avoid recursion
frm.__from_naming_rule = true;

if (frm.doc.naming_rule=='Set by user') {
frm.set_value('autoname', 'Prompt');
} else if (frm.doc.naming_rule === 'Autoincrement') {
frm.set_value('autoname', 'autoincrement');
// set allow rename to be false when using autoincrement
frm.set_value('allow_rename', 0);
} else if (frm.doc.naming_rule=='By fieldname') {
frm.set_value('autoname', 'field:');
} else if (frm.doc.naming_rule=='By "Naming Series" field') {
frm.set_value('autoname', 'naming_series:');
} else if (frm.doc.naming_rule=='Expression') {
frm.set_value('autoname', 'format:');
} else if (frm.doc.naming_rule=='Expression (old style)') {
// pass
} else if (frm.doc.naming_rule=='Random') {
frm.set_value('autoname', 'hash');
}
setTimeout(() =>frm.__from_naming_rule = false, 500);

frm.events.set_naming_rule_description(frm);
}

},

set_naming_rule_description(frm) {
let naming_rule_description = {
'Set by user': '',
'Autoincrement': 'Uses Auto Increment feature of database.<br><b>WARNING: After using this option, any other naming option will not be accessible.</b>',
'By fieldname': 'Format: <code>field:[fieldname]</code>. Valid fieldname must exist',
'By "Naming Series" field': 'Format: <code>naming_series:[fieldname]</code>. Fieldname called <code>naming_series</code> must exist',
'Expression': 'Format: <code>format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####}</code> - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.',
'Expression (old style)': 'Format: <code>EXAMPLE-.#####</code> Series by prefix (separated by a dot)',
'Random': '',
'By script': ''
};

if (frm.doc.naming_rule) {
frm.get_field('autoname').set_description(naming_rule_description[frm.doc.naming_rule]);
}
},

autoname: function(frm) {
// set naming_rule based on autoname (for old doctypes where its not been set)
if (frm.doc.autoname && !frm.doc.naming_rule && !frm.__from_naming_rule) {
// flag to avoid recursion
frm.__from_autoname = true;
if (frm.doc.autoname.toLowerCase() === 'prompt') {
frm.set_value('naming_rule', 'Set by user');
} else if (frm.doc.autoname.toLowerCase() === 'autoincrement') {
frm.set_value('naming_rule', 'Autoincrement');
} else if (frm.doc.autoname.startsWith('field:')) {
frm.set_value('naming_rule', 'By fieldname');
} else if (frm.doc.autoname.startsWith('naming_series:')) {
frm.set_value('naming_rule', 'By "Naming Series" field');
} else if (frm.doc.autoname.startsWith('format:')) {
frm.set_value('naming_rule', 'Expression');
} else if (frm.doc.autoname.toLowerCase() === 'hash') {
frm.set_value('naming_rule', 'Random');
} else {
frm.set_value('naming_rule', 'Expression (old style)');
}
setTimeout(() => frm.__from_autoname = false, 500);
}

frm.set_df_property('fields', 'reqd', frm.doc.autoname !== 'Prompt');
},
});

frappe.ui.form.on("DocField", {


+ 1
- 1
frappe/core/doctype/doctype/doctype.json Voir le fichier

@@ -208,7 +208,7 @@
"label": "Naming"
},
{
"description": "Naming Options:\n<ol><li><b>field:[fieldname]</b> - By Field</li><li><b>autoincrement</b> - Uses Databases' Auto Increment feature</li><li><b>naming_series:</b> - By Naming Series (field called naming_series must be present</li><li><b>Prompt</b> - Prompt user for a name</li><li><b>[series]</b> - Series by prefix (separated by a dot); for example PRE.#####</li>\n<li><b>format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####}</b> - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.</li></ol>",
"description": "Naming Options:\n<ol><li><b>field:[fieldname]</b> - By Field</li><li><b>autoincrement</b> - Uses Databases' Auto Increment feature</li><li><b>naming_series:</b> - By Naming Series (field called naming_series must be present)</li><li><b>Prompt</b> - Prompt user for a name</li><li><b>[series]</b> - Series by prefix (separated by a dot); for example PRE.#####</li>\n<li><b>format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####}</b> - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.</li></ol>",
"fieldname": "autoname",
"fieldtype": "Data",
"label": "Auto Name",


+ 64
- 17
frappe/core/doctype/doctype/doctype.py Voir le fichier

@@ -92,10 +92,10 @@ class DocType(Document):

self.check_developer_mode()

self.validate_autoname()
self.validate_name()

self.set_defaults_for_single_and_table()
self.set_defaults_for_autoincremented()
self.scrub_field_names()
self.set_default_in_list_view()
self.set_default_translatable()
@@ -124,6 +124,12 @@ class DocType(Document):
if self.default_print_format and not self.custom:
frappe.throw(_("Standard DocType cannot have default print format, use Customize Form"))

if check_if_can_change_name_type(self):
change_name_column_type(
self.name,
"bigint" if self.autoname == "autoincrement" else f"varchar({frappe.db.VARCHAR_LEN})",
)

def validate_field_name_conflicts(self):
"""Check if field names dont conflict with controller properties and methods"""
core_doctypes = [
@@ -184,6 +190,10 @@ class DocType(Document):
self.allow_import = 0
self.permissions = []

def set_defaults_for_autoincremented(self):
if self.autoname and self.autoname == "autoincrement":
self.allow_rename = 0

def set_default_in_list_view(self):
"""Set default in-list-view for first 4 mandatory fields"""
if not [d.fieldname for d in self.fields if d.in_list_view]:
@@ -809,19 +819,6 @@ class DocType(Document):
max_idx = frappe.db.sql("""select max(idx) from `tabDocField` where parent = %s""", self.name)
return max_idx and max_idx[0][0] or 0

def validate_autoname(self):
if not self.is_new():
doc_before_save = self.get_doc_before_save()
if doc_before_save:
if (self.autoname == "autoincrement" and doc_before_save.autoname != "autoincrement") or (
self.autoname != "autoincrement" and doc_before_save.autoname == "autoincrement"
):
frappe.throw(_("Cannot change to/from Autoincrement naming rule"))

else:
if self.autoname == "autoincrement":
self.allow_rename = 0

def validate_name(self, name=None):
if not name:
name = self.name
@@ -865,8 +862,13 @@ def validate_series(dt, autoname=None, name=None):

if not autoname and dt.get("fields", {"fieldname": "naming_series"}):
dt.autoname = "naming_series:"
elif dt.autoname == "naming_series:" and not dt.get("fields", {"fieldname": "naming_series"}):
frappe.throw(_("Invalid fieldname '{0}' in autoname").format(dt.autoname))
elif dt.autoname and dt.autoname.startswith("naming_series:"):
fieldname = dt.autoname.split("naming_series:")[0] or "naming_series"
if not dt.get("fields", {"fieldname": fieldname}):
frappe.throw(
_("Fieldname called {0} must exist to enable autonaming").format(frappe.bold(fieldname)),
title=_("Field Missing"),
)

# validate field name if autoname field:fieldname is used
# Create unique index on autoname field automatically.
@@ -884,7 +886,7 @@ def validate_series(dt, autoname=None, name=None):
autoname
and (not autoname.startswith("field:"))
and (not autoname.startswith("eval:"))
and (not autoname.lower() in ("prompt", "hash"))
and (autoname.lower() not in ("prompt", "hash"))
and (not autoname.startswith("naming_series:"))
and (not autoname.startswith("format:"))
):
@@ -901,6 +903,51 @@ def validate_series(dt, autoname=None, name=None):
frappe.throw(_("Series {0} already used in {1}").format(prefix, used_in[0][0]))


def check_if_can_change_name_type(dt: DocType, raise_err: bool = True) -> bool:
def get_autoname_before_save(doctype: str, to_be_customized_dt: str) -> str:
if doctype == "Customize Form":
property_value = frappe.db.get_value(
"Property Setter", {"doc_type": to_be_customized_dt, "property": "autoname"}, "value"
)

# initially no property setter is set,
# hence getting autoname value from the doctype itself
if not property_value:
return frappe.db.get_value("DocType", to_be_customized_dt, "autoname") or ""

return property_value

return getattr(dt.get_doc_before_save(), "autoname", "")

doctype_name = dt.doc_type if dt.doctype == "Customize Form" else dt.name

if not dt.is_new():
autoname_before_save = get_autoname_before_save(dt.doctype, doctype_name)
is_autoname_autoincrement = dt.autoname == "autoincrement"

if (
is_autoname_autoincrement
and autoname_before_save != "autoincrement"
or (not is_autoname_autoincrement and autoname_before_save == "autoincrement")
):
if not frappe.get_all(doctype_name, limit=1):
# allow changing the column type if there is no data
return True

if raise_err:
frappe.throw(
_("Can only change to/from Autoincrement naming rule when there is no data in the doctype")
)

return False


def change_name_column_type(doctype_name: str, type: str) -> None:
return frappe.db.change_column_type(
doctype_name, "name", type, True if frappe.db.db_type == "mariadb" else False
)


def validate_links_table_fieldnames(meta):
"""Validate fieldnames in Links table"""
if not meta.links or frappe.flags.in_patch or frappe.flags.in_fixtures:


+ 18
- 3
frappe/core/doctype/doctype/test_doctype.py Voir le fichier

@@ -524,18 +524,33 @@ class TestDocType(unittest.TestCase):
dt.delete()

def test_autoincremented_doctype_transition(self):
frappe.delete_doc("testy_autoinc_dt")
frappe.delete_doc_if_exists("DocType", "testy_autoinc_dt")
dt = new_doctype("testy_autoinc_dt", autoname="autoincrement").insert(ignore_permissions=True)
dt.autoname = "hash"

dt.save(ignore_permissions=True)

dt_data = frappe.get_doc({"doctype": dt.name, "some_fieldname": "test data"}).insert(
ignore_permissions=True
)

dt.autoname = "autoincrement"

try:
dt.save(ignore_permissions=True)
except frappe.ValidationError as e:
self.assertEqual(e.args[0], "Cannot change to/from Autoincrement naming rule")
self.assertEqual(
e.args[0],
"Can only change to/from Autoincrement naming rule when there is no data in the doctype",
)
else:
self.fail("Shouldnt be possible to transition autoincremented doctype to any other naming rule")
self.fail(
"""Shouldn't be possible to transition to/from autoincremented doctype
when data is present in doctype"""
)
finally:
# cleanup
dt_data.delete(ignore_permissions=True)
dt.delete(ignore_permissions=True)

def test_json_field(self):


+ 2
- 2
frappe/core/doctype/server_script/server_script.json Voir le fichier

@@ -49,7 +49,7 @@
"fieldname": "doctype_event",
"fieldtype": "Select",
"label": "DocType Event",
"options": "Before Insert\nBefore Validate\nBefore Save\nAfter Insert\nAfter Save\nBefore Submit\nAfter Submit\nBefore Cancel\nAfter Cancel\nBefore Delete\nAfter Delete\nBefore Save (Submitted Document)\nAfter Save (Submitted Document)"
"options": "Before Insert\nBefore Validate\nBefore Save\nAfter Insert\nAfter Save\nBefore Submit\nAfter Submit\nBefore Cancel\nAfter Cancel\nBefore Delete\nAfter Delete\nBefore Save (Submitted Document)\nAfter Save (Submitted Document)\nOn Payment Authorization"
},
{
"depends_on": "eval:doc.script_type==='API'",
@@ -109,7 +109,7 @@
"link_fieldname": "server_script"
}
],
"modified": "2022-04-07 19:41:23.178772",
"modified": "2022-04-27 11:42:52.032963",
"modified_by": "Administrator",
"module": "Core",
"name": "Server Script",


+ 1
- 0
frappe/core/doctype/server_script/server_script_utils.py Voir le fichier

@@ -17,6 +17,7 @@ EVENT_MAP = {
"after_delete": "After Delete",
"before_update_after_submit": "Before Save (Submitted Document)",
"on_update_after_submit": "After Save (Submitted Document)",
"on_payment_authorized": "On Payment Authorization",
}




+ 14
- 2
frappe/core/doctype/system_settings/system_settings.json Voir le fichier

@@ -68,6 +68,8 @@
"prepared_report_section",
"enable_prepared_report_auto_deletion",
"prepared_report_expiry_period",
"column_break_64",
"max_auto_email_report_per_user",
"system_updates_section",
"disable_system_update_notification"
],
@@ -445,7 +447,7 @@
"collapsible": 1,
"fieldname": "prepared_report_section",
"fieldtype": "Section Break",
"label": "Prepared Report"
"label": "Reports"
},
{
"default": "Frappe",
@@ -485,12 +487,22 @@
"fieldtype": "Select",
"label": "First Day of the Week",
"options": "Sunday\nMonday\nTuesday\nWednesday\nThursday\nFriday\nSaturday"
},
{
"fieldname": "column_break_64",
"fieldtype": "Column Break"
},
{
"default": "20",
"fieldname": "max_auto_email_report_per_user",
"fieldtype": "Int",
"label": "Max auto email report per user"
}
],
"icon": "fa fa-cog",
"issingle": 1,
"links": [],
"modified": "2022-01-04 11:28:34.881192",
"modified": "2022-04-21 09:11:35.218721",
"modified_by": "Administrator",
"module": "Core",
"name": "System Settings",


+ 1
- 1
frappe/custom/doctype/custom_field/custom_field.json Voir le fichier

@@ -123,7 +123,7 @@
"label": "Field Type",
"oldfieldname": "fieldtype",
"oldfieldtype": "Select",
"options": "Autocomplete\nAttach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nJSON\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRead Only\nRating\nSection Break\nSelect\nSignature\nSmall Text\nTab Break\nTable\nTable MultiSelect\nText\nText Editor\nTime",
"options": "Autocomplete\nAttach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nJSON\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nPhone\nRead Only\nRating\nSection Break\nSelect\nSignature\nSmall Text\nTab Break\nTable\nTable MultiSelect\nText\nText Editor\nTime",
"reqd": 1
},
{


+ 4
- 0
frappe/custom/doctype/customize_form/customize_form.js Voir le fichier

@@ -152,6 +152,10 @@ frappe.ui.form.on("Customize Form", {
},
__("Actions")
);

const is_autoname_autoincrement = frm.doc.autoname === 'autoincrement';
frm.set_df_property("naming_rule", "hidden", is_autoname_autoincrement);
frm.set_df_property("autoname", "read_only", is_autoname_autoincrement);
}

frm.events.setup_export(frm);


+ 10
- 2
frappe/custom/doctype/customize_form/customize_form.json Voir le fichier

@@ -24,6 +24,7 @@
"fields_section_break",
"fields",
"naming_section",
"naming_rule",
"autoname",
"view_settings_section",
"title_field",
@@ -50,6 +51,13 @@
"sort_order"
],
"fields": [
{
"fieldname": "naming_rule",
"fieldtype": "Select",
"label": "Naming Rule",
"length": 40,
"options": "\nSet by user\nBy fieldname\nBy \"Naming Series\" field\nExpression\nExpression (old style)\nRandom\nBy script"
},
{
"fieldname": "doc_type",
"fieldtype": "Link",
@@ -279,7 +287,7 @@
"label": "Naming"
},
{
"description": "Naming Options:\n<ol><li><b>field:[fieldname]</b> - By Field</li><li><b>naming_series:</b> - By Naming Series (field called naming_series must be present</li><li><b>Prompt</b> - Prompt user for a name</li><li><b>[series]</b> - Series by prefix (separated by a dot); for example PRE.#####</li>\n<li><b>format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####}</b> - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.</li></ol>",
"description": "Naming Options:\n<ol><li><b>field:[fieldname]</b> - By Field</li>\n<li><b>autoincrement</b> - Uses Databases' Auto Increment feature</li><li><b>naming_series:</b> - By Naming Series (field called naming_series must be present</li><li><b>Prompt</b> - Prompt user for a name</li><li><b>[series]</b> - Series by prefix (separated by a dot); for example PRE.#####</li>\n<li><b>format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####}</b> - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.</li></ol>",
"fieldname": "autoname",
"fieldtype": "Data",
"label": "Auto Name"
@@ -311,7 +319,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2022-01-07 16:07:06.196534",
"modified": "2022-04-21 15:36:16.772277",
"modified_by": "Administrator",
"module": "Custom",
"name": "Customize Form",


+ 11
- 0
frappe/custom/doctype/customize_form/customize_form.py Voir le fichier

@@ -11,7 +11,9 @@ import frappe
import frappe.translate
from frappe import _
from frappe.core.doctype.doctype.doctype import (
change_name_column_type,
check_email_append_to,
check_if_can_change_name_type,
validate_fields_for_doctype,
validate_series,
)
@@ -159,7 +161,9 @@ class CustomizeForm(Document):
def save_customization(self):
if not self.doc_type:
return

validate_series(self, self.autoname, self.doc_type)
can_change_name_type = check_if_can_change_name_type(self)
self.flags.update_db = False
self.flags.rebuild_doctype_for_global_search = False
self.set_property_setters()
@@ -168,6 +172,12 @@ class CustomizeForm(Document):
validate_fields_for_doctype(self.doc_type)
check_email_append_to(self)

if can_change_name_type:
change_name_column_type(
self.doc_type,
"bigint" if self.autoname == "autoincrement" else f"varchar({frappe.db.VARCHAR_LEN})",
)

if self.flags.update_db:
frappe.db.updatedb(self.doc_type)

@@ -571,6 +581,7 @@ doctype_properties = {
"email_append_to": "Check",
"subject_field": "Data",
"sender_field": "Data",
"naming_rule": "Data",
"autoname": "Data",
"show_title_field_in_link": "Check",
}


+ 2
- 2
frappe/custom/doctype/customize_form_field/customize_form_field.json Voir le fichier

@@ -87,7 +87,7 @@
"label": "Type",
"oldfieldname": "fieldtype",
"oldfieldtype": "Select",
"options": "Autocomplete\nAttach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRating\nRead Only\nSection Break\nSelect\nSignature\nSmall Text\nTab Break\nTable\nTable MultiSelect\nText\nText Editor\nTime",
"options": "Autocomplete\nAttach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nPhone\nRating\nRead Only\nSection Break\nSelect\nSignature\nSmall Text\nTab Break\nTable\nTable MultiSelect\nText\nText Editor\nTime",
"reqd": 1,
"search_index": 1
},
@@ -477,4 +477,4 @@
"sort_field": "modified",
"sort_order": "ASC",
"states": []
}
}

+ 23
- 13
frappe/database/database.py Voir le fichier

@@ -1066,7 +1066,7 @@ class Database(object):
now_datetime() - relativedelta(minutes=minutes),
)[0][0]

def get_db_table_columns(self, table):
def get_db_table_columns(self, table) -> List[str]:
"""Returns list of column names from given table."""
columns = frappe.cache().hget("table_columns", table)
if columns is None:
@@ -1146,18 +1146,13 @@ class Database(object):
return frappe.db.is_missing_column(e)

def get_descendants(self, doctype, name):
"""Return descendants of the current record"""
node_location_indexes = self.get_value(doctype, name, ("lft", "rgt"))
if node_location_indexes:
lft, rgt = node_location_indexes
return self.sql_list(
"""select name from `tab{doctype}`
where lft > {lft} and rgt < {rgt}""".format(
doctype=doctype, lft=lft, rgt=rgt
)
)
else:
# when document does not exist
"""Return descendants of the group node in tree"""
from frappe.utils.nestedset import get_descendants_of

try:
return get_descendants_of(doctype, name, ignore_permissions=True)
except Exception:
# Can only happen if document doesn't exists - kept for backward compatibility
return []

def is_missing_table_or_column(self, e):
@@ -1251,6 +1246,21 @@ class Database(object):
values_to_insert = values[start_index : start_index + chunk_size]
query.columns(fields).insert(*values_to_insert).run()

def create_sequence(self, *args, **kwargs):
from frappe.database.sequence import create_sequence

return create_sequence(*args, **kwargs)

def set_next_sequence_val(self, *args, **kwargs):
from frappe.database.sequence import set_next_val

set_next_val(*args, **kwargs)

def get_next_sequence_val(self, *args, **kwargs):
from frappe.database.sequence import get_next_val

return get_next_val(*args, **kwargs)


def enqueue_jobs_after_commit():
from frappe.utils.background_jobs import execute_job, get_queue


+ 2
- 1
frappe/database/mariadb/database.py Voir le fichier

@@ -53,6 +53,7 @@ class MariaDBDatabase(Database):
"Geolocation": ("longtext", ""),
"Duration": ("decimal", "21,9"),
"Icon": ("varchar", self.VARCHAR_LEN),
"Phone": ("varchar", self.VARCHAR_LEN),
"Autocomplete": ("varchar", self.VARCHAR_LEN),
"JSON": ("json", ""),
}
@@ -148,7 +149,7 @@ class MariaDBDatabase(Database):
) -> Union[List, Tuple]:
table_name = get_table_name(doctype)
null_constraint = "NOT NULL" if not nullable else ""
return self.sql(f"ALTER TABLE `{table_name}` MODIFY `{column}` {type} {null_constraint}")
return self.sql_ddl(f"ALTER TABLE `{table_name}` MODIFY `{column}` {type} {null_constraint}")

# exception types
@staticmethod


+ 1
- 2
frappe/database/mariadb/schema.py Voir le fichier

@@ -1,7 +1,6 @@
import frappe
from frappe import _
from frappe.database.schema import DBTable
from frappe.database.sequence import create_sequence
from frappe.model import log_types


@@ -48,7 +47,7 @@ class MariaDBTable(DBTable):
# By default the cache is 1000 which will mess up the sequence when
# using the system after a restore.
# issue link: https://jira.mariadb.org/browse/MDEV-21786
create_sequence(self.doctype, check_not_exists=True, cache=50)
frappe.db.create_sequence(self.doctype, check_not_exists=True, cache=50)

# NOTE: not used nextval func as default as the ability to restore
# database with sequences has bugs in mariadb and gives a scary error.


+ 8
- 5
frappe/database/postgres/database.py Voir le fichier

@@ -65,6 +65,7 @@ class PostgresDatabase(Database):
"Geolocation": ("text", ""),
"Duration": ("decimal", "21,9"),
"Icon": ("varchar", self.VARCHAR_LEN),
"Phone": ("varchar", self.VARCHAR_LEN),
"Autocomplete": ("varchar", self.VARCHAR_LEN),
"JSON": ("json", ""),
}
@@ -212,7 +213,11 @@ class PostgresDatabase(Database):
) -> Union[List, Tuple]:
table_name = get_table_name(doctype)
null_constraint = "SET NOT NULL" if not nullable else "DROP NOT NULL"
return self.sql(

# postgres allows ddl in transactions but since we've currently made
# things same as mariadb (raising exception on ddl commands if the transaction has any writes),
# hence using sql_ddl here for committing and then moving forward.
return self.sql_ddl(
f"""ALTER TABLE "{table_name}"
ALTER COLUMN "{column}" TYPE {type},
ALTER COLUMN "{column}" {null_constraint}"""
@@ -381,12 +386,10 @@ def modify_query(query):
# drop .0 from decimals and add quotes around them
#
# >>> query = "c='abcd' , a >= 45, b = -45.0, c = 40, d=4500.0, e=3500.53, f=40psdfsd, g=9092094312, h=12.00023"
# >>> re.sub(r"([=><]+)\s*(?!\d+[a-zA-Z])(?![+-]?\d+\.\d\d+)([+-]?\d+)(\.0)?", r"\1 '\2'", query)
# >>> re.sub(r"([=><]+)\s*([+-]?\d+)(\.0)?(?![a-zA-Z\.\d])", r"\1 '\2'", query)
# "c='abcd' , a >= '45', b = '-45', c = '40', d= '4500', e=3500.53, f=40psdfsd, g= '9092094312', h=12.00023

query = re.sub(
r"([=><]+)\s*(?!\d+[a-zA-Z])(?![+-]?\d+\.\d\d+)([+-]?\d+)(\.0)?", r"\1 '\2'", query
)
query = re.sub(r"([=><]+)\s*([+-]?\d+)(\.0)?(?![a-zA-Z\.\d])", r"\1 '\2'", query)
return query




+ 1
- 1
frappe/database/postgres/framework_postgres.sql Voir le fichier

@@ -240,7 +240,7 @@ CREATE TABLE "tabDocType" (

DROP TABLE IF EXISTS "tabSeries";
CREATE TABLE "tabSeries" (
"name" varchar(100) DEFAULT NULL,
"name" varchar(100),
"current" bigint NOT NULL DEFAULT 0,
PRIMARY KEY ("name")
) ;


+ 1
- 2
frappe/database/postgres/schema.py Voir le fichier

@@ -1,7 +1,6 @@
import frappe
from frappe import _
from frappe.database.schema import DBTable, get_definition
from frappe.database.sequence import create_sequence
from frappe.model import log_types
from frappe.utils import cint, flt

@@ -39,7 +38,7 @@ class PostgresTable(DBTable):
# Since we're opening and closing connections for every transaction this results in skipping the cache
# to the next non-cached value hence not using cache in postgres.
# ref: https://stackoverflow.com/questions/21356375/postgres-9-0-4-sequence-skipping-numbers
create_sequence(self.doctype, check_not_exists=True)
frappe.db.create_sequence(self.doctype, check_not_exists=True)
name_column = "name bigint primary key"

# TODO: set docstatus length


+ 15
- 9
frappe/database/query.py Voir le fichier

@@ -108,11 +108,14 @@ def change_orderby(order: str):
tuple: field, order
"""
order = order.split()
if order[1].lower() == "asc":
orderby, order = order[0], Order.asc
return orderby, order
orderby, order = order[0], Order.desc
return orderby, order

try:
if order[1].lower() == "asc":
return order[0], Order.asc
except IndexError:
pass

return order[0], Order.desc


OPERATOR_MAP = {
@@ -175,10 +178,13 @@ class Query:
"""
if kwargs.get("orderby"):
orderby = kwargs.get("orderby")
order = kwargs.get("order") if kwargs.get("order") else Order.desc
if isinstance(orderby, str) and len(orderby.split()) > 1:
orderby, order = change_orderby(orderby)
conditions = conditions.orderby(orderby, order=order)
for ordby in orderby.split(","):
if ordby := ordby.strip():
orderby, order = change_orderby(ordby)
conditions = conditions.orderby(orderby, order=order)
else:
conditions = conditions.orderby(orderby, order=kwargs.get("order") or Order.desc)

if kwargs.get("limit"):
conditions = conditions.limit(kwargs.get("limit"))
@@ -288,7 +294,7 @@ class Query:
table: str,
fields: Union[List, Tuple],
filters: Union[Dict[str, Union[str, int]], str, int] = None,
**kwargs
**kwargs,
):
criterion = self.build_conditions(table, filters, **kwargs)
if isinstance(fields, (list, tuple)):


+ 28
- 25
frappe/database/sequence.py Voir le fichier

@@ -5,6 +5,7 @@ def create_sequence(
doctype_name: str,
*,
slug: str = "_id_seq",
temporary=False,
check_not_exists: bool = False,
cycle: bool = False,
cache: int = 0,
@@ -14,7 +15,7 @@ def create_sequence(
max_value: int = 0,
) -> str:

query = "create sequence"
query = "create sequence" if not temporary else "create temporary sequence"
sequence_name = scrub(doctype_name + slug)

if check_not_exists:
@@ -22,29 +23,29 @@ def create_sequence(

query += f" {sequence_name}"

if cache:
query += f" cache {cache}"
else:
# in postgres, the default is cache 1
if db.db_type == "mariadb":
query += " nocache"

if start_value:
# default is 1
query += f" start with {start_value}"

if increment_by:
# default is 1
query += f" increment by {increment_by}"

if min_value:
# default is 1
query += f" min value {min_value}"
query += f" minvalue {min_value}"

if max_value:
query += f" max value {max_value}"
query += f" maxvalue {max_value}"

if start_value:
# default is 1
query += f" start {start_value}"

# in postgres, the default is cache 1 / no cache
if cache:
query += f" cache {cache}"
elif db.db_type == "mariadb":
query += " nocache"

if not cycle:
# in postgres, default is no cycle
if db.db_type == "mariadb":
query += " nocycle"
else:
@@ -56,21 +57,23 @@ def create_sequence(


def get_next_val(doctype_name: str, slug: str = "_id_seq") -> int:
if db.db_type == "postgres":
return db.sql(f"select nextval('\"{scrub(doctype_name + slug)}\"')")[0][0]
return db.sql(f"select nextval(`{scrub(doctype_name + slug)}`)")[0][0]
return db.multisql(
{
"postgres": f"select nextval('\"{scrub(doctype_name + slug)}\"')",
"mariadb": f"select nextval(`{scrub(doctype_name + slug)}`)",
}
)[0][0]


def set_next_val(
doctype_name: str, next_val: int, *, slug: str = "_id_seq", is_val_used: bool = False
) -> None:

if not is_val_used:
is_val_used = 0 if db.db_type == "mariadb" else "f"
else:
is_val_used = 1 if db.db_type == "mariadb" else "t"
is_val_used = "false" if not is_val_used else "true"

if db.db_type == "postgres":
db.sql(f"SELECT SETVAL('\"{scrub(doctype_name + slug)}\"', {next_val}, '{is_val_used}')")
else:
db.sql(f"SELECT SETVAL(`{scrub(doctype_name + slug)}`, {next_val}, {is_val_used})")
db.multisql(
{
"postgres": f"SELECT SETVAL('\"{scrub(doctype_name + slug)}\"', {next_val}, {is_val_used})",
"mariadb": f"SELECT SETVAL(`{scrub(doctype_name + slug)}`, {next_val}, {is_val_used})",
}
)

+ 4
- 1
frappe/desk/doctype/number_card/number_card.js Voir le fichier

@@ -27,8 +27,11 @@ frappe.ui.form.on('Number Card', {
frm.trigger('set_method_description');
frm.trigger('render_filters_table');
}
frm.trigger('create_add_to_dashboard_button');
frm.trigger('set_parent_document_type');

if (!frm.is_new()) {
frm.trigger('create_add_to_dashboard_button');
}
},

create_add_to_dashboard_button: function(frm) {


+ 18
- 9
frappe/desk/doctype/number_card/number_card.py Voir le fichier

@@ -20,15 +20,24 @@ class NumberCard(Document):
self.name = append_number_if_name_exists("Number Card", self.name)

def validate(self):
if not self.document_type:
frappe.throw(_("Document type is required to create a number card"))

if (
self.document_type
and frappe.get_meta(self.document_type).istable
and not self.parent_document_type
):
frappe.throw(_("Parent document type is required to create a number card"))
if self.type == "Document Type":
if not (self.document_type and self.function):
frappe.throw(_("Document Type and Function are required to create a number card"))

if (
self.document_type
and frappe.get_meta(self.document_type).istable
and not self.parent_document_type
):
frappe.throw(_("Parent Document Type is required to create a number card"))

elif self.type == "Report":
if not (self.report_name and self.report_field and self.function):
frappe.throw(_("Report Name, Report Field and Fucntion are required to create a number card"))

elif self.type == "Custom":
if not self.method:
frappe.throw(_("Method is required to create a number card"))

def on_update(self):
if frappe.conf.developer_mode and self.is_standard:


+ 23
- 6
frappe/desk/doctype/workspace/workspace.py Voir le fichier

@@ -80,7 +80,14 @@ class Workspace(Document):

# remove duplicate before adding
for idx, link in enumerate(self.links):
if link.label == card.get("label") and link.type == "Card Break":
if link.get("label") == card.get("label") and link.get("type") == "Card Break":
# count and set number of links for the card if link_count is 0
if link.link_count == 0:
for count, card_link in enumerate(self.links[idx + 1 :]):
if card_link.get("type") == "Card Break":
break
link.link_count = count + 1

del self.links[idx : idx + link.link_count + 1]

self.append(
@@ -199,21 +206,31 @@ def update_page(name, title, icon, parent, public):
doc.sequence_id = frappe.db.count("Workspace", {"public": public}, cache=True)
doc.public = public
doc.for_user = "" if public else doc.for_user or frappe.session.user
doc.label = "{0}-{1}".format(title, doc.for_user) if doc.for_user else title
doc.label = new_name = "{0}-{1}".format(title, doc.for_user) if doc.for_user else title
doc.save(ignore_permissions=True)

if name != doc.label:
rename_doc("Workspace", name, doc.label, force=True, ignore_permissions=True)
if name != new_name:
rename_doc("Workspace", name, new_name, force=True, ignore_permissions=True)

# update new name and public in child pages
if child_docs:
for child in child_docs:
child_doc = frappe.get_doc("Workspace", child.name)
child_doc.parent_page = doc.title
child_doc.public = doc.public
if child_doc.public != public:
child_doc.public = public
child_doc.for_user = "" if public else child_doc.for_user or frappe.session.user
child_doc.label = new_child_name = (
"{0}-{1}".format(child_doc.title, child_doc.for_user)
if child_doc.for_user
else child_doc.title
)
child_doc.save(ignore_permissions=True)

return {"name": doc.title, "public": doc.public, "label": doc.label}
if child.name != new_child_name:
rename_doc("Workspace", child.name, new_child_name, force=True, ignore_permissions=True)

return {"name": title, "public": public, "label": new_name}


@frappe.whitelist()


+ 3
- 1
frappe/desk/query_report.py Voir le fichier

@@ -58,6 +58,8 @@ def get_report_doc(report_name):


def get_report_result(report, filters):
res = None

if report.report_type == "Query Report":
res = report.execute_query_report(filters)

@@ -84,7 +86,7 @@ def generate_report_result(
res = get_report_result(report, filters) or []

columns, result, message, chart, report_summary, skip_total_row = ljust_list(res, 6)
columns = [get_column_as_dict(col) for col in columns]
columns = [get_column_as_dict(col) for col in (columns or [])]
report_column_names = [col["fieldname"] for col in columns]

# convert to list of dicts


+ 13
- 12
frappe/desk/search.py Voir le fichier

@@ -1,12 +1,10 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE

import functools
import json
import re

import wrapt

# Search
import frappe
from frappe import _, is_whitelisted
from frappe.permissions import has_permission
@@ -314,17 +312,20 @@ def relevance_sorter(key, query, as_dict):
return (cstr(value).lower().startswith(query.lower()) is not True, value)


@wrapt.decorator
def validate_and_sanitize_search_inputs(fn, instance, args, kwargs):
kwargs.update(dict(zip(fn.__code__.co_varnames, args)))
sanitize_searchfield(kwargs["searchfield"])
kwargs["start"] = cint(kwargs["start"])
kwargs["page_len"] = cint(kwargs["page_len"])
def validate_and_sanitize_search_inputs(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
kwargs.update(dict(zip(fn.__code__.co_varnames, args)))
sanitize_searchfield(kwargs["searchfield"])
kwargs["start"] = cint(kwargs["start"])
kwargs["page_len"] = cint(kwargs["page_len"])

if kwargs["doctype"] and not frappe.db.exists("DocType", kwargs["doctype"]):
return []

if kwargs["doctype"] and not frappe.db.exists("DocType", kwargs["doctype"]):
return []
return fn(**kwargs)

return fn(**kwargs)
return wrapper


@frappe.whitelist()


+ 11
- 6
frappe/email/doctype/auto_email_report/auto_email_report.py Voir le fichier

@@ -12,6 +12,7 @@ from frappe.model.document import Document
from frappe.model.naming import append_number_if_name_exists
from frappe.utils import (
add_to_date,
cint,
format_time,
get_link_to_form,
get_url_to_report,
@@ -51,14 +52,18 @@ class AutoEmailReport(Document):
self.email_to = "\n".join(valid)

def validate_report_count(self):
"""check that there are only 3 enabled reports per user"""
count = frappe.db.sql(
"select count(*) from `tabAuto Email Report` where user=%s and enabled=1", self.user
)[0][0]
max_reports_per_user = frappe.local.conf.max_reports_per_user or 3
count = frappe.db.count("Auto Email Report", {"user": self.user, "enabled": 1})

max_reports_per_user = (
cint(frappe.local.conf.max_reports_per_user) # kept for backward compatibilty
or cint(frappe.db.get_single_value("System Settings", "max_auto_email_report_per_user"))
or 20
)

if count > max_reports_per_user + (-1 if self.flags.in_insert else 0):
frappe.throw(_("Only {0} emailed reports are allowed per user").format(max_reports_per_user))
msg = _("Only {0} emailed reports are allowed per user.").format(max_reports_per_user)
msg += " " + _("To allow more reports update limit in System Settings.")
frappe.throw(msg, title=_("Report limit reached"))

def validate_report_format(self):
"""check if user has select correct report format"""


+ 4
- 3
frappe/email/doctype/email_account/email_account.js Voir le fichier

@@ -134,10 +134,11 @@ frappe.ui.form.on("Email Account", {

show_gmail_message_for_less_secure_apps: function(frm) {
frm.dashboard.clear_headline();
let msg = __("GMail will only work if you enable 2-step authentication and use app-specific password.");
let cta = __("Read the step by step guide here.");
msg += ` <a target="_blank" href="https://docs.erpnext.com/docs/v13/user/manual/en/setting-up/email/email_account_setup_with_gmail">${cta}</a>`;
if (frm.doc.service==="GMail") {
frm.dashboard.set_headline_alert('Gmail will only work if you allow access for less secure \
apps in Gmail settings. <a target="_blank" \
href="https://support.google.com/accounts/answer/6010255?hl=en">Read this for details</a>');
frm.dashboard.set_headline_alert(msg);
}
},



+ 3
- 1
frappe/email/doctype/email_queue/email_queue.py Voir le fichier

@@ -622,11 +622,13 @@ class QueueBuilder:
mail_to_string = cstr(mail.as_string())
except frappe.InvalidEmailAddressError:
# bad Email Address - don't add to queue
self.log_error(
frappe.log_error(
title="Invalid email address",
message="Invalid email address Sender: {0}, Recipients: {1}, \nTraceback: {2} ".format(
self.sender, ", ".join(self.final_recipients()), traceback.format_exc()
),
reference_doctype=self.reference_doctype,
reference_name=self.reference_name,
)
return



+ 2
- 2
frappe/email/doctype/newsletter/newsletter.py Voir le fichier

@@ -124,7 +124,7 @@ class Newsletter(WebsiteGenerator):
)

def get_success_recipients(self) -> List[str]:
"""Recipients who have already recieved the newsletter.
"""Recipients who have already received the newsletter.

Couldn't think of a better name ;)
"""
@@ -132,7 +132,7 @@ class Newsletter(WebsiteGenerator):
"Email Queue Recipient",
filters={
"status": ("in", ["Not Sent", "Sending", "Sent"]),
"parentfield": ("in", self.get_linked_email_queue()),
"parent": ("in", self.get_linked_email_queue()),
},
pluck="recipient",
)


+ 21
- 0
frappe/email/doctype/newsletter/test_newsletter.py Voir le fichier

@@ -221,3 +221,24 @@ class TestNewsletter(TestNewsletterMixin, unittest.TestCase):

newsletter.reload()
self.assertEqual(newsletter.email_sent, 0)

def test_retry_partially_sent_newsletter(self):
frappe.db.delete("Email Queue")
frappe.db.delete("Email Queue Recipient")
frappe.db.delete("Newsletter")

newsletter = self.get_newsletter()
newsletter.send_emails()
email_queue_list = [frappe.get_doc("Email Queue", e.name) for e in frappe.get_all("Email Queue")]
self.assertEqual(len(email_queue_list), 4)

# emulate partial send
email_queue_list[0].status = "Error"
email_queue_list[0].recipients[0].status = "Error"
email_queue_list[0].save()
newsletter.email_sent = False

# retry
newsletter.send_emails()
email_queue_list = [frappe.get_doc("Email Queue", e.name) for e in frappe.get_all("Email Queue")]
self.assertEqual(len(email_queue_list), 5)

+ 3
- 3
frappe/email/email_body.py Voir le fichier

@@ -268,9 +268,9 @@ class EMail:
self.replace_sender()
self.replace_sender_name()

self.recipients = [strip(r) for r in self.recipients]
self.cc = [strip(r) for r in self.cc]
self.bcc = [strip(r) for r in self.bcc]
self.recipients = [strip(r) for r in self.recipients if r not in frappe.STANDARD_USERS]
self.cc = [strip(r) for r in self.cc if r not in frappe.STANDARD_USERS]
self.bcc = [strip(r) for r in self.bcc if r not in frappe.STANDARD_USERS]

for e in self.recipients + (self.cc or []) + (self.bcc or []):
validate_email_address(e, True)


+ 492
- 246
frappe/geo/country_info.json
Fichier diff supprimé car celui-ci est trop grand
Voir le fichier


+ 1
- 1
frappe/hooks.py Voir le fichier

@@ -226,7 +226,6 @@ scheduler_events = {
"frappe.sessions.clear_expired_sessions",
"frappe.email.doctype.notification.notification.trigger_daily_alerts",
"frappe.utils.scheduler.restrict_scheduler_events_if_dormant",
"frappe.email.doctype.auto_email_report.auto_email_report.send_daily",
"frappe.website.doctype.personal_data_deletion_request.personal_data_deletion_request.remove_unverified_record",
"frappe.desk.form.document_follow.send_daily_updates",
"frappe.social.doctype.energy_point_settings.energy_point_settings.allocate_review_points",
@@ -241,6 +240,7 @@ scheduler_events = {
"frappe.integrations.doctype.dropbox_settings.dropbox_settings.take_backups_daily",
"frappe.utils.change_log.check_for_update",
"frappe.integrations.doctype.s3_backup_settings.s3_backup_settings.take_backups_daily",
"frappe.email.doctype.auto_email_report.auto_email_report.send_daily",
"frappe.integrations.doctype.google_drive.google_drive.daily_backup",
],
"weekly_long": [


+ 3
- 3
frappe/integrations/doctype/razorpay_settings/razorpay_settings.py Voir le fichier

@@ -140,7 +140,7 @@ class RazorpaySettings(Document):
headers={"content-type": "application/json"},
)
if not resp.get("id"):
frappe.log_error(str(resp), "Razorpay Failed while creating subscription")
frappe.log_error(message=str(resp), title="Razorpay Failed while creating subscription")
except:
frappe.log_error(frappe.get_traceback())
# failed
@@ -179,7 +179,7 @@ class RazorpaySettings(Document):
frappe.flags.status = "created"
return kwargs
else:
frappe.log_error(str(resp), "Razorpay Failed while creating subscription")
frappe.log_error(message=str(resp), title="Razorpay Failed while creating subscription")

except:
frappe.log_error(frappe.get_traceback())
@@ -281,7 +281,7 @@ class RazorpaySettings(Document):
self.flags.status_changed_to = "Verified"

else:
frappe.log_error(str(resp), "Razorpay Payment not authorized")
frappe.log_error(message=str(resp), title="Razorpay Payment not authorized")

except:
frappe.log_error(frappe.get_traceback())


+ 4
- 0
frappe/model/__init__.py Voir le fichier

@@ -36,10 +36,14 @@ data_fieldtypes = (
"Geolocation",
"Duration",
"Icon",
"Phone",
"Autocomplete",
"JSON",
)

float_like_fields = {"Float", "Currency", "Percent"}
datetime_fields = {"Datetime", "Date", "Time"}

attachment_fieldtypes = (
"Attach",
"Attach Image",


+ 46
- 45
frappe/model/base_document.py Voir le fichier

@@ -2,10 +2,18 @@
# License: MIT. See LICENSE
import datetime
import json
from typing import Dict, List

import frappe
from frappe import _
from frappe.model import child_table_fields, default_fields, display_fieldtypes, table_fields
from frappe.model import (
child_table_fields,
datetime_fields,
default_fields,
display_fieldtypes,
float_like_fields,
table_fields,
)
from frappe.model.docstatus import DocStatus
from frappe.model.naming import set_new_name
from frappe.model.utils.link_count import notify_link_count
@@ -233,7 +241,6 @@ class BaseDocument(object):
raise AttributeError(key)

value = get_controller(value["doctype"])(value)
value.init_valid_columns()

value.parent = self.name
value.parenttype = self.doctype
@@ -252,10 +259,11 @@ class BaseDocument(object):

def get_valid_dict(
self, sanitize=True, convert_dates_to_str=False, ignore_nulls=False, ignore_virtual=False
):
) -> Dict:
d = frappe._dict()
for fieldname in self.meta.get_valid_columns():
d[fieldname] = self.get(fieldname)
# column is valid, we can use getattr
d[fieldname] = getattr(self, fieldname, None)

# if no need for sanitization and value is None, continue
if not sanitize and d[fieldname] is None:
@@ -263,25 +271,24 @@ class BaseDocument(object):

df = self.meta.get_field(fieldname)

if df and df.get("is_virtual"):
if ignore_virtual:
del d[fieldname]
continue
if df:
if getattr(df, "is_virtual", False):
if ignore_virtual:
del d[fieldname]
continue

from frappe.utils.safe_exec import get_safe_globals
if d[fieldname] is None and (options := getattr(df, "options", None)):
from frappe.utils.safe_exec import get_safe_globals

if d[fieldname] is None:
if df.get("options"):
d[fieldname] = frappe.safe_eval(
code=df.get("options"),
code=options,
eval_globals=get_safe_globals(),
eval_locals={"doc": self},
)
else:
_val = getattr(self, fieldname, None)
if _val and not callable(_val):
d[fieldname] = _val
elif df:

if isinstance(d[fieldname], list) and df.fieldtype not in table_fields:
frappe.throw(_("Value for {0} cannot be a list").format(_(df.label)))

if df.fieldtype == "Check":
d[fieldname] = 1 if cint(d[fieldname]) else 0

@@ -291,25 +298,20 @@ class BaseDocument(object):
elif df.fieldtype == "JSON" and isinstance(d[fieldname], dict):
d[fieldname] = json.dumps(d[fieldname], sort_keys=True, indent=4, separators=(",", ": "))

elif df.fieldtype in ("Currency", "Float", "Percent") and not isinstance(d[fieldname], float):
elif df.fieldtype in float_like_fields and not isinstance(d[fieldname], float):
d[fieldname] = flt(d[fieldname])

elif df.fieldtype in ("Datetime", "Date", "Time") and d[fieldname] == "":
elif (df.fieldtype in datetime_fields and d[fieldname] == "") or (
getattr(df, "unique", False) and cstr(d[fieldname]).strip() == ""
):
d[fieldname] = None

elif df.get("unique") and cstr(d[fieldname]).strip() == "":
# unique empty field should be set to None
d[fieldname] = None

if isinstance(d[fieldname], list) and df.fieldtype not in table_fields:
frappe.throw(_("Value for {0} cannot be a list").format(_(df.label)))

if convert_dates_to_str and isinstance(
d[fieldname], (datetime.datetime, datetime.date, datetime.time, datetime.timedelta)
):
d[fieldname] = str(d[fieldname])

if d[fieldname] is None and ignore_nulls:
if ignore_nulls and d[fieldname] is None:
del d[fieldname]

return d
@@ -329,7 +331,7 @@ class BaseDocument(object):
if key not in self.__dict__:
self.__dict__[key] = None

def get_valid_columns(self):
def get_valid_columns(self) -> List[str]:
if self.doctype not in frappe.local.valid_columns:
if self.doctype in DOCTYPES_FOR_DOCTYPE:
from frappe.model.meta import get_table_columns
@@ -342,12 +344,12 @@ class BaseDocument(object):

return frappe.local.valid_columns[self.doctype]

def is_new(self):
def is_new(self) -> bool:
return self.get("__islocal")

@property
def docstatus(self):
return DocStatus(self.get("docstatus"))
return DocStatus(cint(self.get("docstatus")))

@docstatus.setter
def docstatus(self, value):
@@ -359,8 +361,8 @@ class BaseDocument(object):
no_default_fields=False,
convert_dates_to_str=False,
no_child_table_fields=False,
):
doc = self.get_valid_dict(convert_dates_to_str=convert_dates_to_str)
) -> Dict:
doc = self.get_valid_dict(convert_dates_to_str=convert_dates_to_str, ignore_nulls=no_nulls)
doc["doctype"] = self.doctype

for df in self.meta.get_table_fields():
@@ -375,20 +377,15 @@ class BaseDocument(object):
for d in children
]

if no_nulls:
for k in list(doc):
if doc[k] is None:
del doc[k]

if no_default_fields:
for k in list(doc):
if k in default_fields:
del doc[k]
for key in default_fields:
if key in doc:
del doc[key]

if no_child_table_fields:
for k in list(doc):
if k in child_table_fields:
del doc[k]
for key in child_table_fields:
if key in doc:
del doc[key]

for key in (
"_user_tags",
@@ -398,8 +395,8 @@ class BaseDocument(object):
"__run_link_triggers",
"__unsaved",
):
if self.get(key):
doc[key] = self.get(key)
if value := getattr(self, key, None):
doc[key] = value

return doc

@@ -771,6 +768,10 @@ class BaseDocument(object):

def _validate_data_fields(self):
# data_field options defined in frappe.model.data_field_options
for phone_field in self.meta.get_phone_fields():
phone = self.get(phone_field.fieldname)
frappe.utils.validate_phone_number_with_country_code(phone, phone_field.fieldname)

for data_field in self.meta.get_data_fields():
data = self.get(data_field.fieldname)
data_field_options = data_field.get("options")


+ 51
- 50
frappe/model/db_query.py Voir le fichier

@@ -213,7 +213,7 @@ class DatabaseQuery(object):

# left join parent, child tables
for child in self.tables[1:]:
parent_name = self.cast_name(f"{self.tables[0]}.name")
parent_name = cast_name(f"{self.tables[0]}.name")
args.tables += f" {self.join} {child} on ({child}.parent = {parent_name})"

if self.grouped_or_conditions:
@@ -225,6 +225,7 @@ class DatabaseQuery(object):
args.conditions += (" or " if args.conditions else "") + " or ".join(self.or_conditions)

self.set_field_tables()
self.cast_name_fields()

fields = []

@@ -385,16 +386,8 @@ class DatabaseQuery(object):
]
# add tables from fields
if self.fields:
for i, field in enumerate(self.fields):
# add cast in locate/strpos
func_found = False
for func in sql_functions:
if func in field.lower():
self.fields[i] = self.cast_name(field, func)
func_found = True
break

if func_found or not ("tab" in field and "." in field):
for field in self.fields:
if not ("tab" in field and "." in field) or any(x for x in sql_functions if x in field):
continue

table_name = field.split(".")[0]
@@ -406,38 +399,6 @@ class DatabaseQuery(object):
if table_name not in self.tables:
self.append_table(table_name)

def cast_name(
self,
column: str,
sql_function: str = "",
) -> str:
if frappe.db.db_type == "postgres":
if "name" in column.lower():
if "cast(" not in column.lower() or "::" not in column:
if not sql_function:
return f"cast({column} as varchar)"

elif sql_function == "locate(":
return re.sub(
r"locate\(([^,]+),([^)]+)\)",
r"locate(\1, cast(\2 as varchar))",
column,
flags=re.IGNORECASE,
)

elif sql_function == "strpos(":
return re.sub(
r"strpos\(([^,]+),([^)]+)\)",
r"strpos(cast(\1 as varchar), \2)",
column,
flags=re.IGNORECASE,
)

elif sql_function == "ifnull(":
return re.sub(r"ifnull\(([^,]+)", r"ifnull(cast(\1 as varchar)", column, flags=re.IGNORECASE)

return column

def append_table(self, table_name):
self.tables.append(table_name)
doctype = table_name[4:-1]
@@ -462,6 +423,10 @@ class DatabaseQuery(object):
if "." not in field and not _in_standard_sql_methods(field):
self.fields[idx] = f"{self.tables[0]}.{field}"

def cast_name_fields(self):
for i, field in enumerate(self.fields):
self.fields[i] = cast_name(field)

def get_table_columns(self):
try:
return get_table_columns(self.doctype)
@@ -541,10 +506,7 @@ class DatabaseQuery(object):
if tname not in self.tables:
self.append_table(tname)

if "ifnull(" in f.fieldname:
column_name = self.cast_name(f.fieldname, "ifnull(")
else:
column_name = self.cast_name(f"{tname}.`{f.fieldname}`")
column_name = cast_name(f.fieldname if "ifnull(" in f.fieldname else f"{tname}.`{f.fieldname}`")

if f.operator.lower() in additional_filters_config:
f.update(get_additional_filter_field(additional_filters_config, f, f.value))
@@ -766,7 +728,10 @@ class DatabaseQuery(object):
return self.match_filters

def get_share_condition(self):
return f"`tab{self.doctype}`.name in ({', '.join(frappe.db.escape(s, percent=False) for s in self.shared)})"
return (
cast_name(f"`tab{self.doctype}`.name")
+ f" in ({', '.join(frappe.db.escape(s, percent=False) for s in self.shared)})"
)

def add_user_permissions(self, user_permissions):
meta = frappe.get_meta(self.doctype)
@@ -794,7 +759,9 @@ class DatabaseQuery(object):
if frappe.get_system_settings("apply_strict_user_permissions"):
condition = ""
else:
empty_value_condition = f"ifnull(`tab{self.doctype}`.`{df.get('fieldname')}`, '')=''"
empty_value_condition = cast_name(
f"ifnull(`tab{self.doctype}`.`{df.get('fieldname')}`, '')=''"
)
condition = empty_value_condition + " or "

for permission in user_permission_values:
@@ -815,7 +782,7 @@ class DatabaseQuery(object):

if docs:
values = ", ".join(frappe.db.escape(doc, percent=False) for doc in docs)
condition += f"`tab{self.doctype}`.`{df.get('fieldname')}` in ({values})"
condition += cast_name(f"`tab{self.doctype}`.`{df.get('fieldname')}`") + f" in ({values})"
match_conditions.append(f"({condition})")
match_filters[df.get("options")] = docs

@@ -933,6 +900,40 @@ class DatabaseQuery(object):
update_user_settings(self.doctype, user_settings)


def cast_name(column: str) -> str:
"""Casts name field to varchar for postgres

Handles majorly 4 cases:
1. locate
2. strpos
3. ifnull
4. coalesce

Uses regex substitution.

Example:
input - "ifnull(`tabBlog Post`.`name`, '')=''"
output - "ifnull(cast(`tabBlog Post`.`name` as varchar), '')=''" """

if frappe.db.db_type == "mariadb":
return column

kwargs = {"string": column, "flags": re.IGNORECASE}
if "cast(" not in column.lower() and "::" not in column:
if re.search(r"locate\(([^,]+),\s*([`\"]?name[`\"]?)\s*\)", **kwargs):
return re.sub(
r"locate\(([^,]+),\s*([`\"]?name[`\"]?)\)", r"locate(\1, cast(\2 as varchar))", **kwargs
)

elif match := re.search(r"(strpos|ifnull|coalesce)\(\s*([`\"]?name[`\"]?)\s*,", **kwargs):
func = match.groups()[0]
return re.sub(rf"{func}\(\s*([`\"]?name[`\"]?)\s*,", rf"{func}(cast(\1 as varchar),", **kwargs)

return re.sub(r"([`\"]?tab[\w`\" -]+\.[`\"]?name[`\"]?)(?!\w)", r"cast(\1 as varchar)", **kwargs)

return column


def check_parent_permission(parent, child_doctype):
if parent:
# User may pass fake parent and get the information from the child table


+ 33
- 12
frappe/model/document.py Voir le fichier

@@ -989,6 +989,16 @@ class Document(BaseDocument):
self.docstatus = DocStatus.cancelled()
return self.save()

@whitelist.__func__
def _rename(
self, name: str, merge: bool = False, force: bool = False, validate_rename: bool = True
):
"""Rename the document. Triggers frappe.rename_doc, then reloads."""
from frappe.model.rename_doc import rename_doc

self.name = rename_doc(doc=self, new=name, merge=merge, force=force, validate=validate_rename)
self.reload()

@whitelist.__func__
def submit(self):
"""Submit the document. Sets `docstatus` = 1, then saves."""
@@ -999,6 +1009,13 @@ class Document(BaseDocument):
"""Cancel the document. Sets `docstatus` = 2, then saves."""
return self._cancel()

@whitelist.__func__
def rename(
self, name: str, merge: bool = False, force: bool = False, validate_rename: bool = True
):
"""Rename the document to `name`. This transforms the current object."""
return self._rename(name=name, merge=merge, force=force, validate_rename=validate_rename)

def delete(self, ignore_permissions=False):
"""Delete document."""
frappe.delete_doc(
@@ -1398,21 +1415,22 @@ class Document(BaseDocument):
# See: Stock Reconciliation
from frappe.utils.background_jobs import enqueue

if hasattr(self, "_" + action):
action = "_" + action
if hasattr(self, f"_{action}"):
action = f"_{action}"

if file_lock.lock_exists(self.get_signature()):
try:
self.lock()
except frappe.DocumentLockedError:
frappe.throw(
_("This document is currently queued for execution. Please try again"),
title=_("Document Queued"),
)

self.lock()
enqueue(
return enqueue(
"frappe.model.document.execute_action",
doctype=self.doctype,
name=self.name,
action=action,
__doctype=self.doctype,
__name=self.name,
__action=action,
**kwargs,
)

@@ -1433,10 +1451,13 @@ class Document(BaseDocument):
if lock_exists:
raise frappe.DocumentLockedError
file_lock.create_lock(signature)
frappe.local.locked_documents.append(self)

def unlock(self):
"""Delete the lock file for this document"""
file_lock.delete_lock(self.get_signature())
if self in frappe.local.locked_documents:
frappe.local.locked_documents.remove(self)

# validation helpers
def validate_from_to_dates(self, from_date_field, to_date_field):
@@ -1495,12 +1516,12 @@ class Document(BaseDocument):
return f"{doctype}({name})"


def execute_action(doctype, name, action, **kwargs):
def execute_action(__doctype, __name, __action, **kwargs):
"""Execute an action on a document (called by background worker)"""
doc = frappe.get_doc(doctype, name)
doc = frappe.get_doc(__doctype, __name)
doc.unlock()
try:
getattr(doc, action)(**kwargs)
getattr(doc, __action)(**kwargs)
except Exception:
frappe.db.rollback()

@@ -1511,4 +1532,4 @@ def execute_action(doctype, name, action, **kwargs):
msg = "<pre><code>" + frappe.get_traceback() + "</pre></code>"

doc.add_comment("Comment", _("Action Failed") + "<br><br>" + msg)
doc.notify_update()
doc.notify_update()

+ 3
- 0
frappe/model/meta.py Voir le fichier

@@ -162,6 +162,9 @@ class Meta(Document):
def get_data_fields(self):
return self.get("fields", {"fieldtype": "Data"})

def get_phone_fields(self):
return self.get("fields", {"fieldtype": "Phone"})

def get_dynamic_link_fields(self):
if not hasattr(self, "_dynamic_link_fields"):
self._dynamic_link_fields = self.get("fields", {"fieldtype": "Dynamic Link"})


+ 17
- 20
frappe/model/naming.py Voir le fichier

@@ -14,6 +14,11 @@ if TYPE_CHECKING:
from frappe.model.meta import Meta


# NOTE: This is used to keep track of status of sites
# whether `log_types` have autoincremented naming set for the site or not.
autoincremented_site_status_map = {}


def set_new_name(doc):
"""
Sets the `name` property for the document based on various rules.
@@ -35,9 +40,7 @@ def set_new_name(doc):
doc.name = None

if is_autoincremented(doc.doctype, meta):
from frappe.database.sequence import get_next_val

doc.name = get_next_val(doc.doctype)
doc.name = frappe.db.get_next_sequence_val(doc.doctype)
return

if getattr(doc, "amended_from", None):
@@ -72,12 +75,11 @@ def set_new_name(doc):
doc.name = validate_name(doc.doctype, doc.name, meta.get_field("name_case"))


def is_autoincremented(doctype: str, meta: "Meta" = None):
def is_autoincremented(doctype: str, meta: Optional["Meta"] = None) -> bool:
"""Checks if the doctype has autoincrement autoname set"""

if doctype in log_types:
if (
frappe.local.autoincremented_status_map.get(frappe.local.site) is None
or frappe.local.autoincremented_status_map[frappe.local.site] == -1
):
if autoincremented_site_status_map.get(frappe.local.site) is None:
if (
frappe.db.sql(
f"""select data_type FROM information_schema.columns
@@ -85,22 +87,19 @@ def is_autoincremented(doctype: str, meta: "Meta" = None):
)[0][0]
== "bigint"
):
frappe.local.autoincremented_status_map[frappe.local.site] = 1
autoincremented_site_status_map[frappe.local.site] = 1
return True
else:
frappe.local.autoincremented_status_map[frappe.local.site] = 0
autoincremented_site_status_map[frappe.local.site] = 0

elif frappe.local.autoincremented_status_map[frappe.local.site]:
elif autoincremented_site_status_map[frappe.local.site]:
return True

else:
if not meta:
meta = frappe.get_meta(doctype)

if getattr(meta, "issingle", False):
return False

if meta.autoname == "autoincrement":
if not getattr(meta, "issingle", False) and meta.autoname == "autoincrement":
return True

return False
@@ -329,11 +328,9 @@ def validate_name(doctype: str, name: Union[int, str], case: Optional[str] = Non

if isinstance(name, int):
if is_autoincremented(doctype):
from frappe.database.sequence import set_next_val

# this will set the sequence val to be the provided name and set it to be used
# so that the sequence will start from the next val of the setted val(name)
set_next_val(doctype, name, is_val_used=True)
# this will set the sequence value to be the provided name/value and set it to be used
# so that the sequence will start from the next value
frappe.db.set_next_sequence_val(doctype, name, is_val_used=True)
return name

frappe.throw(_("Invalid name type (integer) for varchar name column"), frappe.NameError)


+ 250
- 221
frappe/model/rename_doc.py Voir le fichier

@@ -4,12 +4,15 @@ from typing import TYPE_CHECKING, Dict, List, Optional

import frappe
from frappe import _, bold
from frappe.model.document import Document
from frappe.model.dynamic_links import get_dynamic_link_map
from frappe.model.naming import validate_name
from frappe.model.utils.user_settings import sync_user_settings, update_user_settings_data
from frappe.query_builder import Field
from frappe.utils import cint
from frappe.query_builder.utils import DocType, Table
from frappe.utils.data import sbool
from frappe.utils.password import rename_password
from frappe.utils.scheduler import is_scheduler_inactive

if TYPE_CHECKING:
from frappe.model.meta import Meta
@@ -23,10 +26,19 @@ def update_document_title(
title: Optional[str] = None,
name: Optional[str] = None,
merge: bool = False,
enqueue: bool = False,
**kwargs,
) -> str:
"""
Update title from header in form view
Update the name or title of a document. Returns `name` if document was renamed,
`docname` if renaming operation was queued.

:param doctype: DocType of the document
:param docname: Name of the document
:param title: New Title of the document
:param name: New Name of the document
:param merge: Merge the current Document with the existing one if exists
:param enqueue: Enqueue the rename operation, title is updated in current process
"""

# to maintain backwards API compatibility
@@ -38,6 +50,10 @@ def update_document_title(
if not isinstance(obj, (str, type(None))):
frappe.throw(f"{obj=} must be of type str or None")

# handle bad API usages
merge = sbool(merge)
enqueue = sbool(enqueue)

doc = frappe.get_doc(doctype, docname)
doc.check_permission(permtype="write")

@@ -49,11 +65,34 @@ def update_document_title(
name_updated = updated_name and (updated_name != doc.name)

if name_updated:
docname = rename_doc(doctype=doctype, old=docname, new=updated_name, merge=merge)
if enqueue and not is_scheduler_inactive():
current_name = doc.name

# before_name hook may have DocType specific validations or transformations
transformed_name = doc.run_method("before_rename", current_name, updated_name, merge)
if isinstance(transformed_name, dict):
transformed_name = transformed_name.get("new")
transformed_name = transformed_name or updated_name

# run rename validations before queueing
# use savepoints to avoid partial renames / commits
validate_rename(
doctype=doctype,
old=current_name,
new=transformed_name,
meta=doc.meta,
merge=merge,
save_point=True,
)

doc.queue_action("rename", name=transformed_name, merge=merge)
else:
doc.rename(updated_name, merge=merge)

if title_updated:
try:
frappe.db.set_value(doctype, docname, title_field, updated_title)
setattr(doc, title_field, updated_title)
doc.save()
frappe.msgprint(_("Saved"), alert=True, indicator="green")
except Exception as e:
if frappe.db.is_duplicate_entry(e):
@@ -64,44 +103,64 @@ def update_document_title(
)
raise

return docname
return doc.name


def rename_doc(
doctype: str,
old: str,
new: str,
doctype: Optional[str] = None,
old: Optional[str] = None,
new: str = None,
force: bool = False,
merge: bool = False,
ignore_permissions: bool = False,
ignore_if_exists: bool = False,
show_alert: bool = True,
rebuild_search: bool = True,
doc: Optional[Document] = None,
validate: bool = True,
) -> str:
"""Rename a doc(dt, old) to doc(dt, new) and update all linked fields of type "Link"."""
if not frappe.db.exists(doctype, old):
frappe.errprint(_("Failed: {0} to {1} because {0} doesn't exist.").format(old, new))
return

if ignore_if_exists and frappe.db.exists(doctype, new):
frappe.errprint(_("Failed: {0} to {1} because {1} already exists.").format(old, new))
return
"""Rename a doc(dt, old) to doc(dt, new) and update all linked fields of type "Link".

doc: Document object to be renamed.
new: New name for the record. If None, and doctype is specified, new name may be automatically generated via before_rename hooks.
doctype: DocType of the document. Not required if doc is passed.
old: Current name of the document. Not required if doc is passed.
force: Allow even if document is not allowed to be renamed.
merge: Merge with existing document of new name.
ignore_permissions: Ignore user permissions while renaming.
ignore_if_exists: Don't raise exception if document with new name already exists. This will quietely overwrite the existing document.
show_alert: Display alert if document is renamed successfully.
rebuild_search: Rebuild linked doctype search after renaming.
validate: Validate before renaming. If False, it is assumed that the caller has already validated.
"""
old_usage_style = doctype and old and new
new_usage_style = doc and new

if old == new:
frappe.errprint(
_("Ignored: {0} to {1} no changes made because old and new name are the same.").format(old, new)
if not (new_usage_style or old_usage_style):
raise TypeError(
"{doctype, old, new} or {doc, new} are required arguments for frappe.model.rename_doc"
)
return

force = cint(force)
merge = cint(merge)
old = old or doc.name
doctype = doctype or doc.doctype
force = sbool(force)
merge = sbool(merge)
meta = frappe.get_meta(doctype)

# call before_rename
old_doc = frappe.get_doc(doctype, old)
out = old_doc.run_method("before_rename", old, new, merge) or {}
new = (out.get("new") or new) if isinstance(out, dict) else (out or new)
new = validate_rename(doctype, new, meta, merge, force, ignore_permissions)
if validate:
old_doc = doc or frappe.get_doc(doctype, old)
out = old_doc.run_method("before_rename", old, new, merge) or {}
new = (out.get("new") or new) if isinstance(out, dict) else (out or new)
new = validate_rename(
doctype=doctype,
old=old,
new=new,
meta=meta,
merge=merge,
force=force,
ignore_permissions=ignore_permissions,
ignore_if_exists=ignore_if_exists,
)

if not merge:
rename_parent_and_child(doctype, old, new, meta)
@@ -139,11 +198,12 @@ def rename_doc(
rename_password(doctype, old, new)

# update user_permissions
frappe.db.sql(
"""UPDATE `tabDefaultValue` SET `defvalue`=%s WHERE `parenttype`='User Permission'
AND `defkey`=%s AND `defvalue`=%s""",
(new, doctype, old),
)
DefaultValue = DocType("DefaultValue")
frappe.qb.update(DefaultValue).set(DefaultValue.defvalue, new).where(
(DefaultValue.parenttype == "User Permission")
& (DefaultValue.defkey == doctype)
& (DefaultValue.defvalue == old)
).run()

if merge:
new_doc.add_comment("Edit", _("merged {0} into {1}").format(frappe.bold(old), frappe.bold(new)))
@@ -207,15 +267,13 @@ def update_user_settings(old: str, new: str, link_fields: List[Dict]) -> None:

# find the user settings for the linked doctypes
linked_doctypes = {d.parent for d in link_fields if not d.issingle}
user_settings_details = frappe.db.sql(
"""SELECT `user`, `doctype`, `data`
FROM `__UserSettings`
WHERE `data` like %s
AND `doctype` IN ('{doctypes}')""".format(
doctypes="', '".join(linked_doctypes)
),
(old),
as_dict=1,
UserSettings = Table("__UserSettings")

user_settings_details = (
frappe.qb.from_(UserSettings)
.select("user", "doctype", "data")
.where(UserSettings.data.like(old) & UserSettings.doctype.isin(linked_doctypes))
.run(as_dict=True)
)

# create the dict using the doctype name as key and values as list of the user settings
@@ -240,37 +298,33 @@ def update_customizations(old: str, new: str) -> None:


def update_attachments(doctype: str, old: str, new: str) -> None:
try:
if old != "File Data" and doctype != "DocType":
frappe.db.sql(
"""update `tabFile` set attached_to_name=%s
where attached_to_name=%s and attached_to_doctype=%s""",
(new, old, doctype),
)
except frappe.db.ProgrammingError as e:
if not frappe.db.is_column_missing(e):
raise
if doctype != "DocType":
File = DocType("File")

frappe.qb.update(File).set(File.attached_to_name, new).where(
(File.attached_to_name == old) & (File.attached_to_doctype == doctype)
).run()


def rename_versions(doctype: str, old: str, new: str) -> None:
frappe.db.sql(
"""UPDATE `tabVersion` SET `docname`=%s WHERE `ref_doctype`=%s AND `docname`=%s""",
(new, doctype, old),
)
Version = DocType("Version")

frappe.qb.update(Version).set(Version.docname, new).where(
(Version.docname == old) & (Version.ref_doctype == doctype)
).run()


def rename_eps_records(doctype: str, old: str, new: str) -> None:
epl = frappe.qb.DocType("Energy Point Log")
(
frappe.qb.update(epl)
.set(epl.reference_name, new)
.where((epl.reference_doctype == doctype) & (epl.reference_name == old))
EPL = DocType("Energy Point Log")

frappe.qb.update(EPL).set(EPL.reference_name, new).where(
(EPL.reference_doctype == doctype) & (EPL.reference_name == old)
).run()


def rename_parent_and_child(doctype: str, old: str, new: str, meta: "Meta") -> None:
# rename the doc
frappe.db.sql("UPDATE `tab{0}` SET `name`={1} WHERE `name`={1}".format(doctype, "%s"), (new, old))
frappe.qb.update(doctype).set("name", new).where(Field("name") == old).run()
update_autoname_field(doctype, new, meta)
update_child_docs(old, new, meta)

@@ -280,20 +334,36 @@ def update_autoname_field(doctype: str, new: str, meta: "Meta") -> None:
if meta.get("autoname"):
field = meta.get("autoname").split(":")
if field and field[0] == "field":
frappe.db.sql(
"UPDATE `tab{0}` SET `{1}`={2} WHERE `name`={2}".format(doctype, field[1], "%s"), (new, new)
)
frappe.qb.update(doctype).set(field[1], new).where(Field("name") == new).run()


def validate_rename(
doctype: str, new: str, meta: "Meta", merge: bool, force: bool, ignore_permissions: bool
doctype: str,
old: str,
new: str,
meta: "Meta",
merge: bool,
force: bool = False,
ignore_permissions: bool = False,
ignore_if_exists: bool = False,
save_point=False,
) -> str:
# using for update so that it gets locked and someone else cannot edit it while this rename is going on!
if save_point:
_SAVE_POINT = f"validate_rename_{frappe.generate_hash(length=8)}"
frappe.db.savepoint(_SAVE_POINT)

exists = (
frappe.qb.from_(doctype).where(Field("name") == new).for_update().select("name").run(pluck=True)
)
exists = exists[0] if exists else None

if not frappe.db.exists(doctype, old):
frappe.throw(_("Can't rename {0} to {1} because {0} doesn't exist.").format(old, new))

if old == new:
frappe.throw(_("No changes made because old and new name are the same.").format(old, new))

if merge and not exists:
frappe.throw(_("{0} {1} does not exist, select a new target to merge").format(doctype, new))

@@ -301,7 +371,7 @@ def validate_rename(
# for fixing case, accents
exists = None

if (not merge) and exists:
if not merge and exists and not ignore_if_exists:
frappe.throw(_("Another {0} with name {1} exists, select another name").format(doctype, new))

if not (
@@ -315,6 +385,9 @@ def validate_rename(
# validate naming like it's done in doc.py
new = validate_name(doctype, new)

if save_point:
frappe.db.rollback(save_point=_SAVE_POINT)

return new


@@ -337,9 +410,7 @@ def rename_doctype(doctype: str, old: str, new: str) -> None:
def update_child_docs(old: str, new: str, meta: "Meta") -> None:
# update "parent"
for df in meta.get_table_fields():
frappe.db.sql(
"update `tab%s` set parent=%s where parent=%s" % (df.options, "%s", "%s"), (new, old)
)
frappe.qb.update(df.options).set("parent", new).where(Field("parent") == old).run()


def update_link_field_values(link_fields: List[Dict], old: str, new: str, doctype: str) -> None:
@@ -384,57 +455,46 @@ def get_link_fields(doctype: str) -> List[Dict]:
frappe.flags.link_fields = {}

if doctype not in frappe.flags.link_fields:
link_fields = frappe.db.sql(
"""\
select parent, fieldname,
(select issingle from tabDocType dt
where dt.name = df.parent) as issingle
from tabDocField df
where
df.options=%s and df.fieldtype='Link'""",
(doctype,),
as_dict=1,
dt = DocType("DocType")
df = DocType("DocField")
cf = DocType("Custom Field")
ps = DocType("Property Setter")

st_issingle = frappe.qb.from_(dt).select(dt.issingle).where(dt.name == df.parent).as_("issingle")
standard_fields = (
frappe.qb.from_(df)
.select(df.parent, df.fieldname, st_issingle)
.where((df.options == doctype) & (df.fieldtype == "Link"))
.run(as_dict=True)
)

# get link fields from tabCustom Field
custom_link_fields = frappe.db.sql(
"""\
select dt as parent, fieldname,
(select issingle from tabDocType dt
where dt.name = df.dt) as issingle
from `tabCustom Field` df
where
df.options=%s and df.fieldtype='Link'""",
(doctype,),
as_dict=1,
cf_issingle = frappe.qb.from_(dt).select(dt.issingle).where(dt.name == cf.dt).as_("issingle")
custom_fields = (
frappe.qb.from_(cf)
.select(cf.dt.as_("parent"), cf.fieldname, cf_issingle)
.where((cf.options == doctype) & (cf.fieldtype == "Link"))
.run(as_dict=True)
)

# add custom link fields list to link fields list
link_fields += custom_link_fields

# remove fields whose options have been changed using property setter
property_setter_link_fields = frappe.db.sql(
"""\
select ps.doc_type as parent, ps.field_name as fieldname,
(select issingle from tabDocType dt
where dt.name = ps.doc_type) as issingle
from `tabProperty Setter` ps
where
ps.property_type='options' and
ps.field_name is not null and
ps.value=%s""",
(doctype,),
as_dict=1,
ps_issingle = (
frappe.qb.from_(dt).select(dt.issingle).where(dt.name == ps.doc_type).as_("issingle")
)
property_setter_fields = (
frappe.qb.from_(ps)
.select(ps.doc_type.as_("parent"), ps.field_name.as_("fieldname"), ps_issingle)
.where((ps.property == "options") & (ps.value == doctype) & (ps.field_name.notnull()))
.run(as_dict=True)
)

link_fields += property_setter_link_fields

frappe.flags.link_fields[doctype] = link_fields
frappe.flags.link_fields[doctype] = standard_fields + custom_fields + property_setter_fields

return frappe.flags.link_fields[doctype]


def update_options_for_fieldtype(fieldtype: str, old: str, new: str) -> None:
CustomField = DocType("Custom Field")
PropertySetter = DocType("Property Setter")

if frappe.conf.developer_mode:
for name in frappe.get_all("DocField", filters={"options": old}, pluck="parent"):
doctype = frappe.get_doc("DocType", name)
@@ -446,23 +506,18 @@ def update_options_for_fieldtype(fieldtype: str, old: str, new: str) -> None:
if save:
doctype.save()
else:
frappe.db.sql(
"""update `tabDocField` set options=%s
where fieldtype=%s and options=%s""",
(new, fieldtype, old),
)
DocField = DocType("DocField")
frappe.qb.update(DocField).set(DocField.options, new).where(
(DocField.fieldtype == fieldtype) & (DocField.options == old)
).run()

frappe.db.sql(
"""update `tabCustom Field` set options=%s
where fieldtype=%s and options=%s""",
(new, fieldtype, old),
)
frappe.qb.update(CustomField).set(CustomField.options, new).where(
(CustomField.fieldtype == fieldtype) & (CustomField.options == old)
).run()

frappe.db.sql(
"""update `tabProperty Setter` set value=%s
where property='options' and value=%s""",
(new, old),
)
frappe.qb.update(PropertySetter).set(PropertySetter.value, new).where(
(PropertySetter.property == "options") & (PropertySetter.value == old)
).run()


def get_select_fields(old: str, new: str) -> List[Dict]:
@@ -470,108 +525,87 @@ def get_select_fields(old: str, new: str) -> List[Dict]:
get select type fields where doctype's name is hardcoded as
new line separated list
"""
df = DocType("DocField")
dt = DocType("DocType")
cf = DocType("Custom Field")
ps = DocType("Property Setter")

# get link fields from tabDocField
select_fields = frappe.db.sql(
"""
select parent, fieldname,
(select issingle from tabDocType dt
where dt.name = df.parent) as issingle
from tabDocField df
where
df.parent != %s and df.fieldtype = 'Select' and
df.options like {0} """.format(
frappe.db.escape("%" + old + "%")
),
(new,),
as_dict=1,
st_issingle = frappe.qb.from_(dt).select(dt.issingle).where(dt.name == df.parent).as_("issingle")
standard_fields = (
frappe.qb.from_(df)
.select(df.parent, df.fieldname, st_issingle)
.where((df.parent != new) & (df.fieldtype == "Select") & (df.options.like(f"%{old}%")))
.run(as_dict=True)
)

# get link fields from tabCustom Field
custom_select_fields = frappe.db.sql(
"""
select dt as parent, fieldname,
(select issingle from tabDocType dt
where dt.name = df.dt) as issingle
from `tabCustom Field` df
where
df.dt != %s and df.fieldtype = 'Select' and
df.options like {0} """.format(
frappe.db.escape("%" + old + "%")
),
(new,),
as_dict=1,
cf_issingle = frappe.qb.from_(dt).select(dt.issingle).where(dt.name == cf.dt).as_("issingle")
custom_select_fields = (
frappe.qb.from_(cf)
.select(cf.dt.as_("parent"), cf.fieldname, cf_issingle)
.where((cf.dt != new) & (cf.fieldtype == "Select") & (cf.options.like(f"%{old}%")))
.run(as_dict=True)
)

# add custom link fields list to link fields list
select_fields += custom_select_fields

# remove fields whose options have been changed using property setter
property_setter_select_fields = frappe.db.sql(
"""
select ps.doc_type as parent, ps.field_name as fieldname,
(select issingle from tabDocType dt
where dt.name = ps.doc_type) as issingle
from `tabProperty Setter` ps
where
ps.doc_type != %s and
ps.property_type='options' and
ps.field_name is not null and
ps.value like {0} """.format(
frappe.db.escape("%" + old + "%")
),
(new,),
as_dict=1,
ps_issingle = (
frappe.qb.from_(dt).select(dt.issingle).where(dt.name == ps.doc_type).as_("issingle")
)
property_setter_select_fields = (
frappe.qb.from_(ps)
.select(ps.doc_type.as_("parent"), ps.field_name.as_("fieldname"), ps_issingle)
.where(
(ps.doc_type != new)
& (ps.property == "options")
& (ps.field_name.notnull())
& (ps.value.like(f"%{old}%"))
)
.run(as_dict=True)
)

select_fields += property_setter_select_fields

return select_fields
return standard_fields + custom_select_fields + property_setter_select_fields


def update_select_field_values(old: str, new: str):
frappe.db.sql(
"""
update `tabDocField` set options=replace(options, %s, %s)
where
parent != %s and fieldtype = 'Select' and
(options like {0} or options like {1})""".format(
frappe.db.escape("%" + "\n" + old + "%"), frappe.db.escape("%" + old + "\n" + "%")
),
(old, new, new),
)
from frappe.query_builder.functions import Replace

frappe.db.sql(
"""
update `tabCustom Field` set options=replace(options, %s, %s)
where
dt != %s and fieldtype = 'Select' and
(options like {0} or options like {1})""".format(
frappe.db.escape("%" + "\n" + old + "%"), frappe.db.escape("%" + old + "\n" + "%")
),
(old, new, new),
)
DocField = DocType("DocField")
CustomField = DocType("Custom Field")
PropertySetter = DocType("Property Setter")

frappe.db.sql(
"""
update `tabProperty Setter` set value=replace(value, %s, %s)
where
doc_type != %s and field_name is not null and
property='options' and
(value like {0} or value like {1})""".format(
frappe.db.escape("%" + "\n" + old + "%"), frappe.db.escape("%" + old + "\n" + "%")
),
(old, new, new),
)
frappe.qb.update(DocField).set(DocField.options, Replace(DocField.options, old, new)).where(
(DocField.fieldtype == "Select")
& (DocField.parent != new)
& (DocField.options.like(f"%\n{old}%") | DocField.options.like(f"%{old}\n%"))
).run()

frappe.qb.update(CustomField).set(
CustomField.options, Replace(CustomField.options, old, new)
).where(
(CustomField.fieldtype == "Select")
& (CustomField.dt != new)
& (CustomField.options.like(f"%\n{old}%") | CustomField.options.like(f"%{old}\n%"))
).run()

frappe.qb.update(PropertySetter).set(
PropertySetter.value, Replace(PropertySetter.value, old, new)
).where(
(PropertySetter.property == "options")
& (PropertySetter.field_name.notnull())
& (PropertySetter.doc_type != new)
& (PropertySetter.value.like(f"%\n{old}%") | PropertySetter.value.like(f"%{old}\n%"))
).run()


def update_parenttype_values(old: str, new: str):
child_doctypes = frappe.db.get_all(
child_doctypes = frappe.get_all(
"DocField",
fields=["options", "fieldname"],
filters={"parent": new, "fieldtype": ["in", frappe.model.table_fields]},
)

custom_child_doctypes = frappe.db.get_all(
custom_child_doctypes = frappe.get_all(
"Custom Field",
fields=["options", "fieldname"],
filters={"dt": new, "fieldtype": ["in", frappe.model.table_fields]},
@@ -586,35 +620,30 @@ def update_parenttype_values(old: str, new: str):
pluck="value",
)

child_doctypes = list(d["options"] for d in child_doctypes)
child_doctypes += property_setter_child_doctypes
child_doctypes = set(list(d["options"] for d in child_doctypes) + property_setter_child_doctypes)

for doctype in child_doctypes:
frappe.db.sql(f"update `tab{doctype}` set parenttype=%s where parenttype=%s", (new, old))
Table = DocType(doctype)
frappe.qb.update(Table).set(Table.parenttype, new).where(Table.parenttype == old).run()


def rename_dynamic_links(doctype: str, old: str, new: str):
Singles = DocType("Singles")
for df in get_dynamic_link_map().get(doctype, []):
# dynamic link in single, just one value to check
if frappe.get_meta(df.parent).issingle:
refdoc = frappe.db.get_singles_dict(df.parent)
if refdoc.get(df.options) == doctype and refdoc.get(df.fieldname) == old:

frappe.db.sql(
"""update tabSingles set value=%s where
field=%s and value=%s and doctype=%s""",
(new, df.fieldname, old, df.parent),
)
frappe.qb.update(Singles).set(Singles.value, new).where(
(Singles.field == df.fieldname) & (Singles.doctype == df.parent) & (Singles.value == old)
).run()
else:
# because the table hasn't been renamed yet!
parent = df.parent if df.parent != new else old
frappe.db.sql(
"""update `tab{parent}` set {fieldname}=%s
where {options}=%s and {fieldname}=%s""".format(
parent=parent, fieldname=df.fieldname, options=df.options
),
(new, doctype, old),
)

frappe.qb.update(parent).set(df.fieldname, new).where(
(Field(df.options) == doctype) & (Field(df.fieldname) == old)
).run()


def bulk_rename(


+ 3
- 3
frappe/parallel_test_runner.py Voir le fichier

@@ -46,7 +46,7 @@ class ParallelTestRunner:

if hasattr(test_module, "global_test_dependencies"):
for doctype in test_module.global_test_dependencies:
make_test_records(doctype)
make_test_records(doctype, commit=True)

elapsed = time.time() - start_time
elapsed = click.style(f" ({elapsed:.03}s)", fg="red")
@@ -76,7 +76,7 @@ class ParallelTestRunner:
def create_test_dependency_records(self, module, path, filename):
if hasattr(module, "test_dependencies"):
for doctype in module.test_dependencies:
make_test_records(doctype)
make_test_records(doctype, commit=True)

if os.path.basename(os.path.dirname(path)) == "doctype":
# test_data_migration_connector.py > data_migration_connector.json
@@ -86,7 +86,7 @@ class ParallelTestRunner:
with open(test_record_file_path, "r") as f:
doc = json.loads(f.read())
doctype = doc["name"]
make_test_records(doctype)
make_test_records(doctype, commit=True)

def get_module(self, path, filename):
app_path = frappe.get_pymodule_path(self.app)


+ 71
- 2
frappe/public/js/frappe/doctype/index.js Voir le fichier

@@ -5,7 +5,6 @@ frappe.provide("frappe.model");
apply to both DocType form and customize form.
*/
frappe.model.DocTypeController = class DocTypeController extends frappe.ui.form.Controller {

max_attachments() {
if (!this.frm.doc.max_attachments) {
return;
@@ -20,4 +19,74 @@ frappe.model.DocTypeController = class DocTypeController extends frappe.ui.form.
__("Number of attachment fields are more than {}, limit updated to {}.", [label, no_of_attach_fields]));
}
}
}

naming_rule() {
// set the "autoname" property based on naming_rule
if (this.frm.doc.naming_rule && !this.frm.__from_autoname) {

// flag to avoid recursion
this.frm.__from_naming_rule = true;

const naming_rule_default_autoname_map = {
"Autoincrement": "autoincrement",
"Set by user": "prompt",
"By fieldname": "field:",
'By "Naming Series" field': "naming_series:",
"Expression": "format:",
"Expression (sld style)": "",
"Random": "hash",
"By script": ""
};
this.frm.set_value("autoname", naming_rule_default_autoname_map[this.frm.doc.naming_rule] || "");
setTimeout(() => (this.frm.__from_naming_rule = false), 500);

this.set_naming_rule_description();
}

}

set_naming_rule_description() {
let naming_rule_description = {
'Set by user': '',
'Autoincrement': 'Uses Auto Increment feature of database.<br><b>WARNING: After using this option, any other naming option will not be accessible.</b>',
'By fieldname': 'Format: <code>field:[fieldname]</code>. Valid fieldname must exist',
'By "Naming Series" field': 'Format: <code>naming_series:[fieldname]</code>. Default fieldname is <code>naming_series</code>',
'Expression': 'Format: <code>format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####}</code> - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.',
'Expression (old style)': 'Format: <code>EXAMPLE-.#####</code> Series by prefix (separated by a dot)',
'Random': '',
'By script': ''
};

if (this.frm.doc.naming_rule) {
this.frm.get_field('autoname').set_description(naming_rule_description[this.frm.doc.naming_rule]);
}
}

autoname() {
// set naming_rule based on autoname (for old doctypes where its not been set)
if (this.frm.doc.autoname && !this.frm.doc.naming_rule && !this.frm.__from_naming_rule) {
// flag to avoid recursion
this.frm.__from_autoname = true;
const autoname = this.frm.doc.autoname.toLowerCase();

if (autoname === "prompt")
this.frm.set_value("naming_rule", "Set by user");
else if (autoname === "autoincrement")
this.frm.set_value("naming_rule", "Autoincrement");
else if (autoname.startsWith("field:"))
this.frm.set_value("naming_rule", "By fieldname");
else if (autoname.startsWith("naming_series:"))
this.frm.set_value("naming_rule", 'By "Naming Series" field');
else if (autoname.startsWith("format:"))
this.frm.set_value("naming_rule", "Expression");
else if (autoname === "hash")
this.frm.set_value("naming_rule", "Random");
else
this.frm.set_value("naming_rule", "Expression (old style)");

setTimeout(() => (this.frm.__from_autoname = false), 500);
}

this.frm.set_df_property('fields', 'reqd', this.frm.doc.autoname !== 'Prompt');
}
};

+ 1
- 5
frappe/public/js/frappe/form/controls/base_input.js Voir le fichier

@@ -65,11 +65,7 @@ frappe.ui.form.ControlInput = class ControlInput extends frappe.ui.form.Control
};

var update_input = function() {
if (me.doctype && me.docname) {
me.set_input(me.value);
} else {
me.set_input(me.value || null);
}
me.set_input(me.value);
};

if (me.disp_status != "None") {


+ 1
- 0
frappe/public/js/frappe/form/controls/control.js Voir le fichier

@@ -39,6 +39,7 @@ import './multiselect_list';
import './rating';
import './duration';
import './icon';
import './phone';
import './json';

frappe.ui.form.make_control = function (opts) {


+ 1
- 1
frappe/public/js/frappe/form/controls/icon.js Voir le fichier

@@ -11,7 +11,7 @@ frappe.ui.form.ControlIcon = class ControlIcon extends frappe.ui.form.ControlDat
get_all_icons() {
frappe.symbols = [];
$("#frappe-symbols > symbol[id]").each(function() {
frappe.symbols.push(this.id.replace('icon-', ''));
this.id.includes('icon-') && frappe.symbols.push(this.id.replace('icon-', ''));
});
}



+ 1
- 1
frappe/public/js/frappe/form/controls/link.js Voir le fichier

@@ -237,7 +237,7 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat
no_spinner: true,
args: args,
callback: function(r) {
if(!me.$input.is(":focus")) {
if (!window.Cypress && !me.$input.is(":focus")) {
return;
}
r.results = me.merge_duplicates(r.results);


+ 197
- 0
frappe/public/js/frappe/form/controls/phone.js Voir le fichier

@@ -0,0 +1,197 @@

import PhonePicker from '../../phone_picker/phone_picker';

frappe.ui.form.ControlPhone = class ControlPhone extends frappe.ui.form.ControlData {

make_input() {
super.make_input();
this.setup_country_code_picker();
this.input_events();
}

input_events() {
this.$input.keydown((e) => {
const key_code = e.keyCode;
if ([frappe.ui.keyCode.BACKSPACE].includes(key_code)) {
if (this.$input.val().length == 0) {
this.country_code_picker.reset();
}
}
});

// Replaces code when selected and removes previously selected.
this.country_code_picker.on_change = (country) => {
if (!country) {
return this.reset_inputx();
}
const country_code = frappe.boot.country_codes[country].code;
const country_isd = frappe.boot.country_codes[country].isd;
this.set_flag(country_code);
this.$icon = this.selected_icon.find('svg');
this.$flag = this.selected_icon.find('img');

if (!this.$icon.hasClass('hide')) {
this.$icon.toggleClass('hide');
}
if (!this.$flag.length) {
this.selected_icon.prepend(this.get_country_flag(country));
}
if (!this.$isd.length) {
this.selected_icon.append($(`<span class= "country"> ${country_isd}</span>`));
} else {
this.$isd.text(country_isd);
}
if (this.$input.val()) {
this.set_value(this.get_country(country) +'-'+ this.$input.val());
}
this.update_padding();
// hide popover and focus input
this.$wrapper.popover('hide');
this.$input.focus();
};

this.$wrapper.find('.selected-phone').on('click', (e) => {
this.$wrapper.popover('toggle');
e.stopPropagation();

$('body').on('click.phone-popover', (ev) => {
if (!$(ev.target).parents().is('.popover')) {
this.$wrapper.popover('hide');
}
});
$(window).on('hashchange.phone-popover', () => {
this.$wrapper.popover('hide');
});
});
}

setup_country_code_picker() {
let picker_wrapper = $('<div>');
this.country_code_picker = new PhonePicker({
parent: picker_wrapper,
countries: frappe.boot.country_codes
});

this.$wrapper.popover({
trigger: 'manual',
offset: `${-this.$wrapper.width() / 4.5}, 5`,
boundary: 'viewport',
placement: 'bottom',
template: `
<div class="popover phone-picker-popover">
<div class="picker-arrow arrow"></div>
<div class="popover-body popover-content"></div>
</div>
`,
content: () => picker_wrapper,
html: true
}).on('show.bs.popover', () => {
setTimeout(() => {
this.country_code_picker.refresh();
this.country_code_picker.search_input.focus();
}, 10);
}).on('hidden.bs.popover', () => {
$('body').off('click.phone-popover');
$(window).off('hashchange.phone-popover');
});

// Default icon when nothing is selected.
this.selected_icon = this.$wrapper.find('.selected-phone');
let input_value = this.get_input_value();
if (!this.selected_icon.length) {
this.selected_icon = $(`<div class="selected-phone">${frappe.utils.icon("down", "sm")}</div>`);
this.selected_icon.insertAfter(this.$input);
this.selected_icon.append($(`<span class= "country"></span>`));
this.$isd = this.selected_icon.find('.country');
if (input_value && input_value.split("-").length == 2) {
this.$isd.text(this.value.split("-")[0]);
}
}
}

refresh() {
super.refresh();
// Previously opened doc values showing up on a new doc

if (this.frm && this.frm.doc.__islocal && !this.get_value()) {
this.reset_input();
}
}

reset_input() {
this.$input.val("");
this.$wrapper.find('.country').text("");
if (this.selected_icon.find('svg').hasClass('hide')) {
this.selected_icon.find('svg').toggleClass('hide');
this.selected_icon.find('img').addClass('hide');
}
this.$input.css("padding-left", 30);
}

set_formatted_input(value) {
if (value && value.includes('-') && value.split('-').length == 2) {
let isd = this.value.split("-")[0];
this.get_country_code_and_change_flag(isd);
this.country_code_picker.set_country(isd);
this.country_code_picker.refresh();
if (this.country_code_picker.country && this.country_code_picker.country !== this.$isd.text()) {
this.$isd.length && this.$isd.text(isd);
}
this.update_padding();
this.$input.val(value.split('-').pop());

} else if (this.$isd.text().trim() && this.value) {
let code_number = this.$isd.text() + '-' + value;
this.set_value(code_number);
}
}

get_value() {
return this.value;
}

set_flag(country_code) {
this.selected_icon.find('img').attr('src', `https://flagcdn.com/${country_code}.svg`);
this.$icon = this.selected_icon.find('img');
this.$icon.hasClass('hide') && this.$icon.toggleClass('hide');
}

// country_code for India is 'in'
get_country_code_and_change_flag(isd) {
let country_data = frappe.boot.country_codes;
let flag = this.selected_icon.find('img');
for (const country in country_data) {
if (Object.values(country_data[country]).includes(isd)) {
let code = country_data[country].code;
flag = this.selected_icon.find('img');
if (!flag.length) {
this.selected_icon.prepend(this.get_country_flag(country));
this.selected_icon.find('svg').addClass('hide');
} else {
this.set_flag(code);
}
}
}
}

get_country(country) {
const country_codes = frappe.boot.country_codes;
return country_codes[country].isd;
}

get_country_flag(country) {
const country_codes = frappe.boot.country_codes;
let code = country_codes[country].code;
return frappe.utils.flag(code);
}

update_padding() {
let len = this.$isd.text().length;
let diff = len - 2;
if (len > 2) {
this.$input.css("padding-left", 60 + (diff * 7));
} else {
this.$input.css("padding-left", 60);
}
}
};

+ 1
- 1
frappe/public/js/frappe/form/dashboard.js Voir le fichier

@@ -375,7 +375,7 @@ frappe.ui.form.Dashboard = class FormDashboard {
}

set_open_count() {
if (!this.data.transactions || !this.data.fieldname) {
if (!this.data || (!this.data.transactions || !this.data.fieldname)) {
return;
}



+ 12
- 9
frappe/public/js/frappe/form/form.js Voir le fichier

@@ -179,7 +179,7 @@ frappe.ui.form.Form = class FrappeForm {
grid_shortcut_keys.forEach(row => {
frappe.ui.keys.add_shortcut({
shortcut: row.shortcut,
page: this,
page: this.page,
description: __(row.description),
ignore_inputs: true,
condition: () => !this.is_new()
@@ -248,7 +248,7 @@ frappe.ui.form.Form = class FrappeForm {
// on main doc
frappe.model.on(me.doctype, "*", function(fieldname, value, doc, skip_dirty_trigger=false) {
// set input
if (cstr(doc.name) === me.docname) {
if (doc.name == me.docname) {
if (!skip_dirty_trigger) {
me.dirty();
}
@@ -273,7 +273,7 @@ frappe.ui.form.Form = class FrappeForm {
// using $.each to preserve df via closure
$.each(table_fields, function(i, df) {
frappe.model.on(df.options, "*", function(fieldname, value, doc) {
if(doc.parent===me.docname && doc.parentfield===df.fieldname) {
if (doc.parent == me.docname && doc.parentfield === df.fieldname) {
me.dirty();
me.fields_dict[df.fieldname].grid.set_value(fieldname, value, doc);
return me.script_manager.trigger(fieldname, doc.doctype, doc.name);
@@ -356,7 +356,7 @@ frappe.ui.form.Form = class FrappeForm {

// check permissions
if (!this.has_read_permission()) {
frappe.show_not_permitted(__(this.doctype) + " " + __(this.docname));
frappe.show_not_permitted(__(this.doctype) + " " + __(cstr(this.docname)));
return;
}

@@ -1765,12 +1765,15 @@ frappe.ui.form.Form = class FrappeForm {
// scroll to input
frappe.utils.scroll_to($el, true, 15);

// highlight input
$el.addClass('has-error');
// focus if text field
$el.find('input, select, textarea').focus();

// highlight control inside field
let control_element = $el.find('.form-control')
control_element.addClass('highlight');
setTimeout(() => {
$el.removeClass('has-error');
$el.find('input, select, textarea').focus();
}, 1000);
control_element.removeClass('highlight');
}, 2000);
}

setup_docinfo_change_listener() {


+ 23
- 13
frappe/public/js/frappe/form/grid_row.js Voir le fichier

@@ -289,19 +289,23 @@ export default class GridRow {
var me = this;
if(this.doc && !this.grid.df.in_place_edit) {
// remove row
if(!this.open_form_button) {
this.open_form_button = $(`
<div class="btn-open-row">
<a>${frappe.utils.icon('edit', 'xs')}</a>
<div class="hidden-xs edit-grid-row">${ __("Edit") }</div>
</div>
`)
.appendTo($('<div class="col col-xs-1"></div>').appendTo(this.row))
.on('click', function() {
me.toggle_view(); return false;
});
if (!this.open_form_button) {
this.open_form_button = $('<div class="col col-xs-1"></div>').appendTo(this.row);

if (!this.configure_columns) {
this.open_form_button = $(`
<div class="btn-open-row">
<a>${frappe.utils.icon('edit', 'xs')}</a>
<div class="hidden-xs edit-grid-row">${ __("Edit") }</div>
</div>
`)
.appendTo(this.open_form_button)
.on('click', function() {
me.toggle_view(); return false;
});
}

if(this.is_too_small()) {
if (this.is_too_small()) {
// narrow
this.open_form_button.css({'margin-right': '-2px'});
}
@@ -310,7 +314,9 @@ export default class GridRow {
}

add_column_configure_button() {
if (this.configure_columns) {
if (this.grid.df.in_place_edit && !this.frm) return;

if (this.configure_columns && this.frm) {
this.configure_columns_button = $(`
<div class="col grid-static-col col-xs-1 d-flex justify-content-center" style="cursor: pointer;">
<a>${frappe.utils.icon('setting-gear', 'sm', '', 'filter: opacity(0.5)')}</a>
@@ -320,6 +326,10 @@ export default class GridRow {
.on('click', () => {
this.configure_dialog_for_columns_selector();
});
} else if (this.configure_columns && !this.frm) {
this.configure_columns_button = $(`
<div class="col grid-static-col col-xs-1"></div>
`).appendTo(this.row);
}
}



+ 2
- 2
frappe/public/js/frappe/form/layout.js Voir le fichier

@@ -98,7 +98,7 @@ frappe.ui.form.Layout = class Layout {
// remove previous color
this.message.removeClass(this.message_color);
}
this.message_color = (color && ['yellow', 'blue', 'red'].includes(color)) ? color : 'blue';
this.message_color = (color && ['yellow', 'blue', 'red', 'green', 'orange'].includes(color)) ? color : 'blue';
if (html) {
if (html.substr(0, 1)!=='<') {
// wrap in a block
@@ -439,7 +439,7 @@ frappe.ui.form.Layout = class Layout {
}

handle_tab(doctype, fieldname, shift) {
let grid_row = null,
let grid_row = null,
prev = null,
fields = this.fields_list,
focused = false;


+ 1
- 1
frappe/public/js/frappe/form/multi_select_dialog.js Voir le fichier

@@ -151,7 +151,7 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog {
}

is_child_selection_enabled() {
return this.dialog.fields_dict['allow_child_item_selection'].get_value();
return this.dialog.fields_dict['allow_child_item_selection']?.get_value();
}

toggle_child_selection() {


+ 36
- 13
frappe/public/js/frappe/form/toolbar.js Voir le fichier

@@ -84,16 +84,15 @@ frappe.ui.form.Toolbar = class Toolbar {
message: __("Unchanged")
});
}
rename_document_title(new_name, new_title, merge=false) {
rename_document_title(input_name, input_title, merge=false) {
let confirm_message = null;
const docname = this.frm.doc.name;
const title_field = this.frm.meta.title_field || '';
const doctype = this.frm.doctype;

let confirm_message=null;

if (new_name) {
if (input_name) {
const warning = __("This cannot be undone");
const message = __("Are you sure you want to merge {0} with {1}?", [docname.bold(), new_name.bold()]);
const message = __("Are you sure you want to merge {0} with {1}?", [docname.bold(), input_name.bold()]);
confirm_message = `${message}<br><b>${warning}<b>`;
}

@@ -101,22 +100,45 @@ frappe.ui.form.Toolbar = class Toolbar {
return frappe.xcall("frappe.model.rename_doc.update_document_title", {
doctype,
docname,
name: new_name,
title: new_title,
name: input_name,
title: input_title,
enqueue: true,
merge,
freeze: true,
freeze_message: __("Updating related fields...")
}).then(new_docname => {
if (new_name != docname) {
$(document).trigger("rename", [doctype, docname, new_docname || new_name]);
const reload_form = (input_name) => {
$(document).trigger("rename", [doctype, docname, input_name]);
if (locals[doctype] && locals[doctype][docname]) delete locals[doctype][docname];
this.frm.reload_doc();
}

// handle document renaming queued action
if (input_name && (new_docname == docname)) {
frappe.socketio.doc_subscribe(doctype, input_name);
frappe.realtime.on("doc_update", data => {
if (data.doctype == doctype && data.name == input_name) {
reload_form(input_name);
frappe.show_alert({
message: __('Document renamed from {0} to {1}', [docname.bold(), input_name.bold()]),
indicator: 'success',
});
}
});
frappe.show_alert(
__('Document renaming from {0} to {1} has been queued', [docname.bold(), input_name.bold()])
);
}

// handle document sync rename action
if (input_name && ((new_docname || input_name) != docname)) {
reload_form(new_docname || input_name);
}
this.frm.reload_doc();
});
};

return new Promise((resolve, reject) => {
if (new_title === this.frm.doc[title_field] && new_name === docname) {
if (input_title === this.frm.doc[title_field] && input_name === docname) {
this.show_unchanged_document_alert();
resolve();
} else if (merge) {
@@ -323,7 +345,7 @@ frappe.ui.form.Toolbar = class Toolbar {
}

// New
if(p[CREATE] && !this.frm.meta.issingle) {
if (p[CREATE] && !this.frm.meta.issingle && !this.frm.meta.in_create) {
this.page.add_menu_item(__("New {0}", [__(me.frm.doctype)]), function() {
frappe.new_doc(me.frm.doctype, true);
}, true, {
@@ -569,7 +591,8 @@ frappe.ui.form.Toolbar = class Toolbar {
primary_action: ({ fieldname }) => {
dialog.hide();
this.frm.scroll_to_field(fieldname);
}
},
animate: false,
});

dialog.show();


+ 1
- 1
frappe/public/js/frappe/model/model.js Voir le fichier

@@ -403,7 +403,7 @@ $.extend(frappe.model, {
}
});
} else {
if(typeof filters==="string" && locals[doctype] && locals[doctype][filters]) {
if (["number", "string"].includes(typeof filters) && locals[doctype] && locals[doctype][filters]) {
return locals[doctype][filters][fieldname];
} else {
var l = frappe.get_list(doctype, filters);


+ 103
- 0
frappe/public/js/frappe/phone_picker/phone_picker.js Voir le fichier

@@ -0,0 +1,103 @@
class PhonePicker {
constructor(opts) {
this.parent = opts.parent;
this.width = opts.width;
this.height = opts.height;
this.country = opts.country;
opts.country && this.set_country(opts.country);
this.countries = opts.countries;
this.setup_picker();
}

refresh() {
this.update_icon_selected(true);
}

setup_picker() {
this.phone_picker_wrapper = $(`
<div class="phone-picker">
<div class="search-phones">
<input type="search" placeholder="${__('Search for countries...')}" class="form-control">
<span class="search-phone">${frappe.utils.icon('search', "sm")}</span>
</div>
<div class="phone-section">
<div class="phones"></div>
</div>
</div>
`);
this.parent.append(this.phone_picker_wrapper);
this.phone_wrapper = this.phone_picker_wrapper.find('.phones');
this.search_input = this.phone_picker_wrapper.find('.search-phones > input');
this.refresh();
this.setup_countries();
}

setup_countries() {
Object.entries(this.countries).forEach(([country, info]) => {
if (!info.isd) {
return;
}
let $country = $(`
<div id="${country.toLowerCase()}" class="phone-wrapper">
${frappe.utils.flag(info.code)}
<span class="country">${country} (${info.isd})</span>
</div>
`);
this.phone_wrapper.append($country);
const set_values = () => {
this.set_country(country);
this.update_icon_selected();
};
$country.on('click', () => {
set_values();
});
$country.hover(() => {
$country.toggleClass("bg-gray-100");
});
this.search_input.keydown((e) => {
const key_code = e.keyCode;
if ([13].includes(key_code)) {
e.preventDefault();
set_values();
}
});
this.search_input.keyup((e) => {
e.preventDefault();
this.filter_icons();
});

this.search_input.on('search', () => {
this.filter_icons();
});
});
}

filter_icons() {
let value = this.search_input.val();
if (!value) {
this.phone_wrapper.find(".phone-wrapper").removeClass('hidden');
} else {
this.phone_wrapper.find(".phone-wrapper").addClass('hidden');
this.phone_wrapper.find(`.phone-wrapper[id*='${value.toLowerCase()}']`).removeClass('hidden');
}
}

update_icon_selected(silent) {
!silent && this.on_change && this.on_change(this.get_country());
}

set_country(country) {
this.country = country || '';
}

get_country() {
return this.country;
}

reset() {
this.set_country();
this.update_icon_selected();
}
}

export default PhonePicker;

+ 7
- 6
frappe/public/js/frappe/ui/field_group.js Voir le fichier

@@ -22,15 +22,17 @@ frappe.ui.FieldGroup = class FieldGroup extends frappe.ui.form.Layout {
super.make();
this.refresh();
// set default
$.each(this.fields_list, (_, field) => {
if (!is_null(field.df.default)) {
let def_value = field.df.default;
$.each(this.fields_list, function(i, field) {
if (field.df["default"]) {
let def_value = field.df["default"];

if (def_value === "Today" && field.df.fieldtype === "Date") {
if (def_value == 'Today' && field.df["fieldtype"] == 'Date') {
def_value = frappe.datetime.get_today();
}

this.set_value(field.df.fieldname, def_value);
field.set_input(def_value);
// if default and has depends_on, render its fields.
me.refresh_dependency();
}
})

@@ -127,7 +129,6 @@ frappe.ui.FieldGroup = class FieldGroup extends frappe.ui.form.Layout {
if (f) {
f.set_value(val).then(() => {
f.set_input(val);
f.refresh();
this.refresh_dependency();
resolve();
});


+ 8
- 10
frappe/public/js/frappe/ui/keyboard.js Voir le fichier

@@ -37,7 +37,7 @@ frappe.ui.keys.add_shortcut = ({shortcut, action, description, page, target, con
if (is_input_focused && !ignore_inputs) return;
if (!condition()) return;

if (!page || page.wrapper.is(':visible')) {
if (action && (!page || page.wrapper.is(':visible'))) {
let prevent_default = action(e);
// prevent default if true is explicitly returned
// or nothing returned (undefined)
@@ -221,11 +221,11 @@ frappe.ui.keys.add_shortcut({
});

frappe.ui.keys.on('escape', function(e) {
close_grid_and_dialog();
handle_escape_key();
});

frappe.ui.keys.on('esc', function(e) {
close_grid_and_dialog();
handle_escape_key();
});

frappe.ui.keys.on('enter', function(e) {
@@ -293,6 +293,11 @@ frappe.ui.keyCode = {
BACKSPACE: 8
}

function handle_escape_key() {
close_grid_and_dialog();
document.activeElement?.blur();
}

function close_grid_and_dialog() {
// close open grid row
var open_row = $(".grid-row-open");
@@ -308,10 +313,3 @@ function close_grid_and_dialog() {
return false;
}
}

// blur when escape is pressed on dropdowns
$(document).on('keydown', '.dropdown-toggle', (e) => {
if (e.which === frappe.ui.keyCode.ESCAPE) {
$(e.currentTarget).blur();
}
});

+ 1
- 1
frappe/public/js/frappe/ui/sort_selector.js Voir le fichier

@@ -132,7 +132,7 @@ frappe.ui.SortSelector = class SortSelector {
// bold, mandatory and fields that are available in list view
meta.fields.forEach(function(df) {
if (
(df.mandatory || df.bold || df.in_list_view)
(df.mandatory || df.bold || df.in_list_view || df.reqd)
&& frappe.model.is_value_type(df.fieldtype)
&& frappe.perm.has_perm(me.doctype, df.permlevel, "read")
) {


+ 1
- 1
frappe/public/js/frappe/utils/dashboard_utils.js Voir le fichier

@@ -249,7 +249,7 @@ frappe.dashboard_utils = {
{args: values}
).then(()=> {
let dashboard_route_html =
`<a href = "#dashboard/${values.dashboard}">${values.dashboard}</a>`;
`<a href = "/app/dashboard/${values.dashboard}">${values.dashboard}</a>`;
let message =
__("{0} {1} added to Dashboard {2}", [doctype, values.name, dashboard_route_html]);



+ 6
- 0
frappe/public/js/frappe/utils/utils.js Voir le fichier

@@ -1192,6 +1192,12 @@ Object.assign(frappe.utils, {
</svg>`;
},

flag(country_code) {
return `<img
src="https://flagcdn.com/${country_code}.svg"
width="20" height="15">`;
},

make_chart(wrapper, custom_options={}) {
let chart_args = {
type: 'bar',


+ 1
- 1
frappe/public/js/frappe/views/file/file_view.js Voir le fichier

@@ -390,7 +390,7 @@ frappe.views.FileView = class FileView extends frappe.views.ListView {
return `
<div class="list-row-col ellipsis list-subject level">
<span class="level-item file-select">
<input class="list-row-checkbox hidden-xs"
<input class="list-row-checkbox"
type="checkbox" data-name="${file.name}">
</span>
<span class="level-item ellipsis" title="${file.file_name}">


+ 1
- 1
frappe/public/js/frappe/views/workspace/blocks/block.js Voir le fichier

@@ -7,7 +7,7 @@ export default class Block {

make(block, block_name, widget_type = block) {
let block_data = this.config.page_data[block+'s'].items.find(obj => {
return frappe.utils.unescape_html(obj.label) == frappe.utils.unescape_html(block_name);
return frappe.utils.unescape_html(obj.label) == frappe.utils.unescape_html(__(block_name));
});
if (!block_data) return false;
this.wrapper.innerHTML = '';


+ 1
- 1
frappe/public/js/frappe/views/workspace/blocks/card.js Voir le fichier

@@ -31,7 +31,7 @@ export default class Card extends Block {
this.new('card', 'links');

if (this.data && this.data.card_name) {
let has_data = this.make('card', __(this.data.card_name), 'links');
let has_data = this.make('card', this.data.card_name, 'links');
if (!has_data) return;
}



+ 1
- 1
frappe/public/js/frappe/views/workspace/blocks/chart.js Voir le fichier

@@ -32,7 +32,7 @@ export default class Chart extends Block {
this.new('chart');

if (this.data && this.data.chart_name) {
let has_data = this.make('chart', __(this.data.chart_name));
let has_data = this.make('chart', this.data.chart_name);
if (!has_data) return;
}



+ 1
- 1
frappe/public/js/frappe/views/workspace/blocks/onboarding.js Voir le fichier

@@ -73,7 +73,7 @@ export default class Onboarding extends Block {

make(block, block_name) {
let block_data = this.config.page_data['onboardings'].items.find(obj => {
return obj.label == block_name;
return obj.label == __(block_name);
});
if (!block_data) return false;
this.wrapper.innerHTML = '';


+ 1
- 1
frappe/public/js/frappe/views/workspace/blocks/shortcut.js Voir le fichier

@@ -51,7 +51,7 @@ export default class Shortcut extends Block {
this.new('shortcut');

if (this.data && this.data.shortcut_name) {
let has_data = this.make('shortcut', __(this.data.shortcut_name));
let has_data = this.make('shortcut', this.data.shortcut_name);
if (!has_data) return;
}



+ 25
- 20
frappe/public/js/frappe/widgets/widget_dialog.js Voir le fichier

@@ -228,30 +228,35 @@ class CardDialog extends WidgetDialog {
}

process_data(data) {
data.links.map((item, idx) => {
let message = '';
let row = idx+1;
let message = '';

if (!item.link_type) {
message = "Following fields have missing values: <br><br><ul>";
message += `<li>Link Type in Row ${row}</li>`;
}
if (!data.links) {
message = "You must add atleast one link.";
} else {
data.links.map((item, idx) => {
let row = idx+1;

if (!item.link_to) {
message += `<li>Link To in Row ${row}</li>`;
}
if (!item.link_type) {
message = "Following fields have missing values: <br><br><ul>";
message += `<li>Link Type in Row ${row}</li>`;
}

if (message) {
message += "</ul>";
frappe.throw({
message: __(message),
title: __("Missing Values Required"),
indicator: 'orange'
});
}
if (!item.link_to) {
message += `<li>Link To in Row ${row}</li>`;
}

item.label = item.label ? item.label : item.link_to;
});
item.label = item.label ? item.label : item.link_to;
});
}

if (message) {
message += "</ul>";
frappe.throw({
message: __(message),
title: __("Missing Values Required"),
indicator: 'orange'
});
}

data.label = data.label ? data.label : data.chart_name;
return data;


+ 3
- 3
frappe/public/scss/common/controls.scss Voir le fichier

@@ -2,6 +2,7 @@
@import "color_picker";
@import "icon_picker";
@import "datepicker";
@import "phone_picker";

// password
.form-control[data-fieldtype="Password"] {
@@ -343,11 +344,10 @@ textarea.form-control {
.duration-picker {
position: absolute;
z-index: 999;

border-radius: var(--border-radius);
box-shadow: var(--shadow-sm);
background: var(--popover-bg);
width: max-content;
&:after,
&:before {
border: solid transparent;
@@ -466,4 +466,4 @@ button.data-pill {
top: 0;
right: 0;
cursor: pointer;
}
}

+ 144
- 0
frappe/public/scss/common/phone_picker.scss Voir le fichier

@@ -0,0 +1,144 @@
.phone-picker {
font-size: var(--text-xs);
color: var(--text-muted);
--phone-picker-width: 290px;
width: var(--phone-picker-width);
.phones {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
overflow-y: scroll;
max-height: 210px;
cursor: pointer;

/* Hide scrollbar for IE, Edge and Firefox */
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */

/* Hide scrollbar for Chrome, Safari and Opera */
&::-webkit-scrollbar {
display: none;
}

.phone-wrapper {
display: flex;
width: 290px;
height: 30px;
text-align: center;
align-items: center;
border-radius: 0.375rem;
padding: 0.5rem;

img {
height: 15px;
}
.country {
display: flex;
margin-left: 0.6rem;
flex-grow: 1;
width: 290px;
}
}
}

.search-phones {
position: relative;

input[type='search'] {
height: inherit;
padding-left: 30px;
}

.search-phone {
position: absolute;
top: 7px;
left: 7px;
}
}
}

.phone-picker-popover {
max-width: 325px;
left: 29px !important;
.picker-arrow {
left: 15px !important;
}
@media (max-width: 992px) {
max-width: 325px;
left: 48px !important;
}
}


.frappe-control[data-fieldtype='Phone']
{
input {
padding-left: 30px;
}
.selected-phone {
display: flex;
cursor: pointer;
height: 20px;
border-radius: 5px;
position: absolute;
top: calc(50% + 2px);
left: 8px;
content: ' ';
align-items: center;
z-index: 1;

.country {
display: flex;
margin-left: 0.6rem;
align-items: flex-end;
flex-grow: 1;
}

img {
height: 15px;
}

}
.like-disabled-input {
.phone-value {
padding-left: 25px;
}
.selected-phone {
top: 20%;
cursor: default;
}
}
}

.modal-body {
.frappe-control[data-fieldtype='Phone']
{
.selected-phone {
top: calc(50% - 0.5px);
}
}
}

.data-row.row {
.selected-phone {
top: calc(50% - 10.1px);
z-index: 2;
}
}

.bg-gray-100 {
--tw-bg-opacity: 1;
background-color: rgba(244,245,246,var(--tw-bg-opacity));
}

.dt-cell__content {
.selected-phone {
display: contents;
}
}

.dt-cell__edit, .filter-field {
.selected-phone {
top: 5.5px !important;
}
}

+ 10
- 0
frappe/public/scss/common/quill.scss Voir le fichier

@@ -7,6 +7,7 @@
font-family: inherit;
}

/*rtl:begin:ignore*/
.ql-editor {
font-family: var(--font-stack);
color: var(--text-color);
@@ -22,7 +23,15 @@
a[href] {
text-decoration: underline;
}
.ql-direction-rtl {
direction: rtl;
+ .table {
direction: ltr;
}
}
}
/*rtl:end:ignore*/


.ql-toolbar.ql-snow {
border-top-left-radius: var(--border-radius);
@@ -70,6 +79,7 @@
min-height: 0;
max-height: none;
overflow: hidden;
resize: none;
}
}



+ 4
- 0
frappe/query_builder/utils.py Voir le fichier

@@ -55,6 +55,10 @@ def DocType(*args, **kwargs):
return frappe.qb.DocType(*args, **kwargs)


def Table(*args, **kwargs):
return frappe.qb.Table(*args, **kwargs)


def patch_query_execute():
"""Patch the Query Builder with helper execute method
This excludes the use of `frappe.db.sql` method while


+ 16
- 13
frappe/test_runner.py Voir le fichier

@@ -226,7 +226,7 @@ def run_tests_for_doctype(
if force:
for name in frappe.db.sql_list("select name from `tab%s`" % doctype):
frappe.delete_doc(doctype, name, force=True)
make_test_records(doctype, verbose=verbose, force=force)
make_test_records(doctype, verbose=verbose, force=force, commit=True)
modules.append(importlib.import_module(test_module))

return _run_unittest(
@@ -245,7 +245,7 @@ def run_tests_for_module(
module = importlib.import_module(module)
if hasattr(module, "test_dependencies"):
for doctype in module.test_dependencies:
make_test_records(doctype, verbose=verbose)
make_test_records(doctype, verbose=verbose, commit=True)

frappe.db.commit()
return _run_unittest(
@@ -330,7 +330,7 @@ def _add_test(app, path, filename, verbose, test_suite=None, ui_tests=False):

if hasattr(module, "test_dependencies"):
for doctype in module.test_dependencies:
make_test_records(doctype, verbose=verbose)
make_test_records(doctype, verbose=verbose, commit=True)

is_ui_test = True if hasattr(module, "TestDriver") else False

@@ -346,12 +346,12 @@ def _add_test(app, path, filename, verbose, test_suite=None, ui_tests=False):
with open(txt_file, "r") as f:
doc = json.loads(f.read())
doctype = doc["name"]
make_test_records(doctype, verbose)
make_test_records(doctype, verbose, commit=True)

test_suite.addTest(unittest.TestLoader().loadTestsFromModule(module))


def make_test_records(doctype, verbose=0, force=False):
def make_test_records(doctype, verbose=0, force=False, commit=False):
if not frappe.db:
frappe.connect()

@@ -364,8 +364,8 @@ def make_test_records(doctype, verbose=0, force=False):

if options not in frappe.local.test_objects:
frappe.local.test_objects[options] = []
make_test_records(options, verbose, force)
make_test_records_for_doctype(options, verbose, force)
make_test_records(options, verbose, force, commit=commit)
make_test_records_for_doctype(options, verbose, force, commit=commit)


def get_modules(doctype):
@@ -405,7 +405,7 @@ def get_dependencies(doctype):
return options_list


def make_test_records_for_doctype(doctype, verbose=0, force=False):
def make_test_records_for_doctype(doctype, verbose=0, force=False, commit=False):
if not force and doctype in get_test_record_log():
return

@@ -420,17 +420,19 @@ def make_test_records_for_doctype(doctype, verbose=0, force=False):
elif hasattr(test_module, "test_records"):
if doctype in frappe.local.test_objects:
frappe.local.test_objects[doctype] += make_test_objects(
doctype, test_module.test_records, verbose, force
doctype, test_module.test_records, verbose, force, commit=commit
)
else:
frappe.local.test_objects[doctype] = make_test_objects(
doctype, test_module.test_records, verbose, force
doctype, test_module.test_records, verbose, force, commit=commit
)

else:
test_records = frappe.get_test_records(doctype)
if test_records:
frappe.local.test_objects[doctype] += make_test_objects(doctype, test_records, verbose, force)
frappe.local.test_objects[doctype] += make_test_objects(
doctype, test_records, verbose, force, commit=commit
)

elif verbose:
print_mandatory_fields(doctype)
@@ -438,7 +440,7 @@ def make_test_records_for_doctype(doctype, verbose=0, force=False):
add_to_test_record_log(doctype)


def make_test_objects(doctype, test_records=None, verbose=None, reset=False):
def make_test_objects(doctype, test_records=None, verbose=None, reset=False, commit=False):
"""Make test objects from given list of `test_records` or from `test_records.json`"""
records = []

@@ -495,7 +497,8 @@ def make_test_objects(doctype, test_records=None, verbose=None, reset=False):

records.append(d.name)

frappe.db.commit()
if commit:
frappe.db.commit()
return records




+ 37
- 0
frappe/tests/test_client.py Voir le fichier

@@ -141,3 +141,40 @@ class TestClient(unittest.TestCase):
self.assertEqual(get("ToDo", filters=filters_json).description, "test")

todo.delete()

def test_client_insert(self):
from frappe.client import insert

def get_random_title():
return "test-{0}".format(frappe.generate_hash())

# test insert dict
doc = {"doctype": "Note", "title": get_random_title(), "content": "test"}
note1 = insert(doc)
self.assertTrue(note1)

# test insert json
doc["title"] = get_random_title()
json_doc = frappe.as_json(doc)
note2 = insert(json_doc)
self.assertTrue(note2)

# test insert child doc without parent fields
child_doc = {"doctype": "Note Seen By", "user": "Administrator"}
with self.assertRaises(frappe.ValidationError):
insert(child_doc)

# test insert child doc with parent fields
child_doc = {
"doctype": "Note Seen By",
"user": "Administrator",
"parenttype": "Note",
"parent": note1.name,
"parentfield": "seen_by",
}
note3 = insert(child_doc)
self.assertTrue(note3)

# cleanup
frappe.delete_doc("Note", note1.name)
frappe.delete_doc("Note", note2.name)

+ 9
- 0
frappe/tests/test_db.py Voir le fichier

@@ -87,6 +87,15 @@ class TestDB(unittest.TestCase):
frappe.db.get_values("User", filters=[["name", "=", "Administrator"]], fieldname="email"),
)

# test multiple orderby's
delimiter = '"' if frappe.db.db_type == "postgres" else "`"
self.assertIn(
"ORDER BY {deli}creation{deli} DESC,{deli}modified{deli} ASC,{deli}name{deli} DESC".format(
deli=delimiter
),
frappe.db.get_value("DocType", "DocField", order_by="creation desc, modified asc, name", run=0),
)

def test_get_value_limits(self):

# check both dict and list style filters


+ 44
- 9
frappe/tests/test_db_query.py Voir le fichier

@@ -61,10 +61,12 @@ class TestReportview(unittest.TestCase):
in build_match_conditions(as_condition=False)
)
# get as conditions
self.assertEqual(
build_match_conditions(as_condition=True),
"""(((ifnull(`tabBlog Post`.`name`, '')='' or `tabBlog Post`.`name` in ('-test-blog-post-1', '-test-blog-post'))))""",
)
if frappe.db.db_type == "mariadb":
assertion_string = """(((ifnull(`tabBlog Post`.`name`, '')='' or `tabBlog Post`.`name` in ('-test-blog-post-1', '-test-blog-post'))))"""
else:
assertion_string = """(((ifnull(cast(`tabBlog Post`.`name` as varchar), '')='' or cast(`tabBlog Post`.`name` as varchar) in ('-test-blog-post-1', '-test-blog-post'))))"""

self.assertEqual(build_match_conditions(as_condition=True), assertion_string)

frappe.set_user("Administrator")

@@ -619,19 +621,22 @@ class TestReportview(unittest.TestCase):
def test_cast_name(self):
from frappe.core.doctype.doctype.test_doctype import new_doctype

frappe.delete_doc_if_exists("DocType", "autoinc_dt_test")
dt = new_doctype("autoinc_dt_test", autoname="autoincrement").insert(ignore_permissions=True)

query = DatabaseQuery("autoinc_dt_test").execute(
fields=["locate('1', `tabautoinc_dt_test`.`name`)", "`tabautoinc_dt_test`.`name`"],
fields=["locate('1', `tabautoinc_dt_test`.`name`)", "name", "locate('1', name)"],
filters={"name": 1},
run=False,
)

if frappe.db.db_type == "postgres":
self.assertTrue('strpos( cast( "tabautoinc_dt_test"."name" as varchar), \'1\')' in query)
self.assertTrue('strpos( cast("tabautoinc_dt_test"."name" as varchar), \'1\')' in query)
self.assertTrue("strpos( cast(name as varchar), '1')" in query)
self.assertTrue('where cast("tabautoinc_dt_test"."name" as varchar) = \'1\'' in query)
else:
self.assertTrue("locate('1', `tabautoinc_dt_test`.`name`)" in query)
self.assertTrue("locate('1', name)" in query)
self.assertTrue("where `tabautoinc_dt_test`.`name` = 1" in query)

dt.delete(ignore_permissions=True)
@@ -639,23 +644,53 @@ class TestReportview(unittest.TestCase):
def test_fieldname_starting_with_int(self):
from frappe.core.doctype.doctype.test_doctype import new_doctype

frappe.delete_doc_if_exists("DocType", "dt_with_int_named_fieldname")
frappe.delete_doc_if_exists("DocType", "table_dt")

table_dt = new_doctype(
"table_dt", istable=1, fields=[{"label": "1field", "fieldname": "2field", "fieldtype": "Data"}]
).insert()

dt = new_doctype(
"dt_with_int_named_fieldname",
fields=[{"label": "1field", "fieldname": "1field", "fieldtype": "Int"}],
fields=[
{"label": "1field", "fieldname": "1field", "fieldtype": "Data"},
{
"label": "2table_field",
"fieldname": "2table_field",
"fieldtype": "Table",
"options": table_dt.name,
},
],
).insert(ignore_permissions=True)

frappe.get_doc({"doctype": "dt_with_int_named_fieldname", "1field": 10}).insert(
dt_data = frappe.get_doc({"doctype": "dt_with_int_named_fieldname", "1field": "10"}).insert(
ignore_permissions=True
)

query = DatabaseQuery("dt_with_int_named_fieldname")
self.assertTrue(query.execute(filters={"1field": 10}))
self.assertTrue(query.execute(filters={"1field": "10"}))
self.assertTrue(query.execute(filters={"1field": ["like", "1%"]}))
self.assertTrue(query.execute(filters={"1field": ["in", "1,2,10"]}))
self.assertTrue(query.execute(filters={"1field": ["is", "set"]}))
self.assertFalse(query.execute(filters={"1field": ["not like", "1%"]}))
self.assertTrue(query.execute(filters=[["table_dt", "2field", "is", "not set"]]))

frappe.get_doc(
{
"doctype": table_dt.name,
"2field": "10",
"parent": dt_data.name,
"parenttype": dt_data.doctype,
"parentfield": "2table_field",
}
).insert(ignore_permissions=True)

self.assertTrue(query.execute(filters=[["table_dt", "2field", "is", "set"]]))

# cleanup
dt.delete()
table_dt.delete()


def add_child_table_to_blog_post():


+ 14
- 13
frappe/tests/test_permissions.py Voir le fichier

@@ -24,24 +24,25 @@ test_dependencies = ["Blogger", "Blog Post", "User", "Contact", "Salutation"]


class TestPermissions(FrappeTestCase):
def setUp(self):
@classmethod
def setUpClass(cls):
super().setUpClass()
frappe.clear_cache(doctype="Blog Post")
user = frappe.get_doc("User", "test1@example.com")
user.add_roles("Website Manager")
user.add_roles("System Manager")

if not frappe.flags.permission_user_setup_done:
user = frappe.get_doc("User", "test1@example.com")
user.add_roles("Website Manager")
user.add_roles("System Manager")

user = frappe.get_doc("User", "test2@example.com")
user.add_roles("Blogger")
user = frappe.get_doc("User", "test2@example.com")
user.add_roles("Blogger")

user = frappe.get_doc("User", "test3@example.com")
user.add_roles("Sales User")
user = frappe.get_doc("User", "test3@example.com")
user.add_roles("Sales User")

user = frappe.get_doc("User", "testperm@example.com")
user.add_roles("Website Manager")
user = frappe.get_doc("User", "testperm@example.com")
user.add_roles("Website Manager")

frappe.flags.permission_user_setup_done = True
def setUp(self):
frappe.clear_cache(doctype="Blog Post")

reset("Blogger")
reset("Blog Post")


+ 26
- 0
frappe/tests/test_rename_doc.py Voir le fichier

@@ -107,8 +107,25 @@ class TestRenameDoc(unittest.TestCase):

def setUp(self):
frappe.flags.link_fields = {}
if self._testMethodName == "test_doc_rename_method":
self.property_setter = frappe.get_doc(
{
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"doc_type": self.test_doctype,
"property": "allow_rename",
"property_type": "Check",
"value": "1",
}
).insert()

super().setUp()

def tearDown(self) -> None:
if self._testMethodName == "test_doc_rename_method":
self.property_setter.delete()
return super().tearDown()

def test_rename_doc(self):
"""Rename an existing document via frappe.rename_doc"""
old_name = choice(self.available_documents)
@@ -247,3 +264,12 @@ class TestRenameDoc(unittest.TestCase):

update_linked_doctypes("User", "ToDo", "str", "str")
self.assertTrue("Function frappe.model.rename_doc.update_linked_doctypes" in stdout.getvalue())

def test_doc_rename_method(self):
name = choice(self.available_documents)
new_name = f"{name}-{frappe.generate_hash(length=4)}"
doc = frappe.get_doc(self.test_doctype, name)
doc.rename(new_name, merge=frappe.db.exists(self.test_doctype, new_name))
self.assertEqual(doc.name, new_name)
self.available_documents.append(new_name)
self.available_documents.remove(name)

+ 54
- 0
frappe/tests/test_sequence.py Voir le fichier

@@ -0,0 +1,54 @@
import psycopg2
import pymysql

import frappe
from frappe.tests.utils import FrappeTestCase


class TestSequence(FrappeTestCase):
def generate_sequence_name(self) -> str:
return self._testMethodName + "_" + frappe.generate_hash(length=5)

def test_set_next_val(self):
seq_name = self.generate_sequence_name()
frappe.db.create_sequence(seq_name, check_not_exists=True, temporary=True)

next_val = frappe.db.get_next_sequence_val(seq_name)
frappe.db.set_next_sequence_val(seq_name, next_val + 1)
self.assertEqual(next_val + 1, frappe.db.get_next_sequence_val(seq_name))

next_val = frappe.db.get_next_sequence_val(seq_name)
frappe.db.set_next_sequence_val(seq_name, next_val + 1, is_val_used=True)
self.assertEqual(next_val + 2, frappe.db.get_next_sequence_val(seq_name))

def test_create_sequence(self):
seq_name = self.generate_sequence_name()
frappe.db.create_sequence(seq_name, max_value=2, cycle=True, temporary=True)
frappe.db.get_next_sequence_val(seq_name)
frappe.db.get_next_sequence_val(seq_name)
self.assertEqual(1, frappe.db.get_next_sequence_val(seq_name))

seq_name = self.generate_sequence_name()
frappe.db.create_sequence(seq_name, max_value=2, temporary=True)
frappe.db.get_next_sequence_val(seq_name)
frappe.db.get_next_sequence_val(seq_name)

try:
frappe.db.get_next_sequence_val(seq_name)
except pymysql.err.OperationalError as e:
self.assertEqual(e.args[0], 4084)
except psycopg2.errors.SequenceGeneratorLimitExceeded:
pass
else:
self.fail("NEXTVAL didn't raise any error upon sequence's end")

# without this, we're not able to move further
# as postgres doesn't allow moving further in a transaction
# when an error occurs
frappe.db.rollback()

seq_name = self.generate_sequence_name()
frappe.db.create_sequence(seq_name, min_value=10, max_value=20, increment_by=5, temporary=True)
self.assertEqual(10, frappe.db.get_next_sequence_val(seq_name))
self.assertEqual(15, frappe.db.get_next_sequence_val(seq_name))
self.assertEqual(20, frappe.db.get_next_sequence_val(seq_name))

+ 34
- 0
frappe/tests/test_test_utils.py Voir le fichier

@@ -0,0 +1,34 @@
import frappe
from frappe.tests.utils import FrappeTestCase, change_settings


class TestTestUtils(FrappeTestCase):
SHOW_TRANSACTION_COMMIT_WARNINGS = True

def test_document_assertions(self):

currency = frappe.new_doc("Currency")
currency.currency_name = "STONKS"
currency.smallest_currency_fraction_value = 0.420_001
currency.save()

self.assertDocumentEqual(currency.as_dict(), currency)

def test_thread_locals(self):
frappe.flags.temp_flag_to_be_discarded = True

def test_temp_setting_changes(self):
current_setting = frappe.get_system_settings("logout_on_password_reset")

with change_settings("System Settings", {"logout_on_password_reset": int(not current_setting)}):
updated_settings = frappe.get_system_settings("logout_on_password_reset")
self.assertNotEqual(current_setting, updated_settings)

restored_settings = frappe.get_system_settings("logout_on_password_reset")
self.assertEqual(current_setting, restored_settings)


def tearDownModule():
"""assertions for ensuring tests didn't leave state behind"""
assert "temp_flag_to_be_discarded" not in frappe.flags
assert not frappe.db.exists("Currency", "STONKS")

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff

Chargement…
Annuler
Enregistrer