@@ -5,7 +5,7 @@ | |||
"es6": true | |||
}, | |||
"parserOptions": { | |||
"ecmaVersion": 9, | |||
"ecmaVersion": 11, | |||
"sourceType": "module" | |||
}, | |||
"extends": "eslint:recommended", | |||
@@ -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 | |||
@@ -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> |
@@ -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" | |||
@@ -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 | |||
@@ -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 | |||
}; |
@@ -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(); | |||
}); | |||
}); |
@@ -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'); | |||
}); | |||
}); | |||
}); |
@@ -16,7 +16,7 @@ context("Control Markdown Editor", () => { | |||
cy.click_modal_primary_button("Upload"); | |||
cy.get_field("main_section_md", "Markdown Editor").should( | |||
"contain", | |||
"" | |||
"; | |||
}); | |||
}); |
@@ -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"); | |||
}); | |||
}); |
@@ -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); | |||
}); | |||
}); | |||
}); |
@@ -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(); | |||
@@ -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'); | |||
// }); | |||
}); |
@@ -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(); | |||
@@ -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'); | |||
}); | |||
}); |
@@ -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(); | |||
}); |
@@ -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 |
@@ -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}) | |||
@@ -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) | |||
@@ -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": [] | |||
} | |||
} |
@@ -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", { | |||
@@ -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", | |||
@@ -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: | |||
@@ -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): | |||
@@ -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", | |||
@@ -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", | |||
} | |||
@@ -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", | |||
@@ -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 | |||
}, | |||
{ | |||
@@ -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); | |||
@@ -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,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", | |||
} | |||
@@ -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": [] | |||
} | |||
} |
@@ -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 | |||
@@ -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,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. | |||
@@ -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 | |||
@@ -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,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 | |||
@@ -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)): | |||
@@ -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})", | |||
} | |||
) |
@@ -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) { | |||
@@ -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: | |||
@@ -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() | |||
@@ -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 | |||
@@ -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() | |||
@@ -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""" | |||
@@ -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); | |||
} | |||
}, | |||
@@ -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 | |||
@@ -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", | |||
) | |||
@@ -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) |
@@ -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) | |||
@@ -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": [ | |||
@@ -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()) | |||
@@ -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", | |||
@@ -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") | |||
@@ -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 | |||
@@ -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() |
@@ -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"}) | |||
@@ -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) | |||
@@ -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( | |||
@@ -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) | |||
@@ -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'); | |||
} | |||
}; |
@@ -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") { | |||
@@ -39,6 +39,7 @@ import './multiselect_list'; | |||
import './rating'; | |||
import './duration'; | |||
import './icon'; | |||
import './phone'; | |||
import './json'; | |||
frappe.ui.form.make_control = function (opts) { | |||
@@ -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-', '')); | |||
}); | |||
} | |||
@@ -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); | |||
@@ -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); | |||
} | |||
} | |||
}; |
@@ -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; | |||
} | |||
@@ -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() { | |||
@@ -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); | |||
} | |||
} | |||
@@ -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; | |||
@@ -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() { | |||
@@ -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(); | |||
@@ -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); | |||
@@ -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; |
@@ -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(); | |||
}); | |||
@@ -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(); | |||
} | |||
}); |
@@ -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") | |||
) { | |||
@@ -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]); | |||
@@ -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', | |||
@@ -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}"> | |||
@@ -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 = ''; | |||
@@ -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; | |||
} | |||
@@ -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; | |||
} | |||
@@ -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 = ''; | |||
@@ -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; | |||
} | |||
@@ -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; | |||
@@ -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; | |||
} | |||
} |
@@ -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; | |||
} | |||
} |
@@ -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; | |||
} | |||
} | |||
@@ -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 | |||
@@ -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 | |||
@@ -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) |
@@ -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 | |||
@@ -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(): | |||
@@ -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") | |||
@@ -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) |
@@ -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)) |
@@ -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") |