diff --git a/.eslintrc b/.eslintrc
index cc7f555669..937f11586c 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -148,6 +148,7 @@
"context": true,
"before": true,
"beforeEach": true,
+ "after": true,
"qz": true,
"localforage": true,
"extend_cscript": true
diff --git a/.github/helper/documentation.py b/.github/helper/documentation.py
index f8ee3fa10b..aece5f543b 100644
--- a/.github/helper/documentation.py
+++ b/.github/helper/documentation.py
@@ -24,6 +24,8 @@ def docs_link_exists(body):
parts = parsed_url.path.split('/')
if len(parts) == 5 and parts[1] == "frappe" and parts[2] in docs_repos:
return True
+ if parsed_url.netloc in ["docs.erpnext.com", "frappeframework.com"]:
+ return True
if __name__ == "__main__":
diff --git a/.github/helper/install.sh b/.github/helper/install.sh
index 454cc89694..19a7c68e19 100644
--- a/.github/helper/install.sh
+++ b/.github/helper/install.sh
@@ -17,7 +17,7 @@ if [ "$TYPE" == "server" ]; then
fi
if [ "$DB" == "mariadb" ];then
- sudo apt install mariadb-client-10.3
+ sudo apt update && sudo apt install mariadb-client-10.3
mysql --host 127.0.0.1 --port 3306 -u root -e "SET GLOBAL character_set_server = 'utf8mb4'";
mysql --host 127.0.0.1 --port 3306 -u root -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'";
diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml
index 510e7c7678..dba13f9358 100644
--- a/.github/workflows/docker-release.yml
+++ b/.github/workflows/docker-release.yml
@@ -12,4 +12,4 @@ jobs:
- name: curl
run: |
apk add curl bash
- curl -s -X POST -H "Content-Type: application/json" -H "Accept: application/json" -H "Travis-API-Version: 3" -H "Authorization: token ${{ secrets.TRAVIS_CI_TOKEN }}" -d '{"request":{"branch":"master"}}' https://api.travis-ci.com/repo/frappe%2Ffrappe_docker/requests
+ curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: Bearer ${{ secrets.CI_PAT }}" https://api.github.com/repos/frappe/frappe_docker/actions/workflows/build_stable.yml/dispatches -d '{"ref":"main"}'
diff --git a/.github/workflows/patch-mariadb-tests.yml b/.github/workflows/patch-mariadb-tests.yml
index 8758c4e273..c8294886a0 100644
--- a/.github/workflows/patch-mariadb-tests.yml
+++ b/.github/workflows/patch-mariadb-tests.yml
@@ -10,6 +10,7 @@ concurrency:
jobs:
test:
runs-on: ubuntu-latest
+ timeout-minutes: 60
name: Patch Test
@@ -31,6 +32,12 @@ jobs:
with:
python-version: '3.9'
+ - name: Setup Node
+ uses: actions/setup-node@v2
+ with:
+ node-version: 14
+ check-latest: true
+
- name: Check if build should be run
id: check-build
run: |
@@ -106,16 +113,14 @@ jobs:
source env/bin/activate
cd apps/frappe/
git remote set-url upstream https://github.com/frappe/frappe.git
- git fetch --all --tags
- taglist=$(git tag --sort version:refname | grep -v "beta")
- last_release=$(echo "$taglist" | tail -1 | cut -d . -f 1 | cut -c 2-)
-
- for version in $(seq 12 "$last_release")
+ for version in $(seq 12 13)
do
- last_tag=$(echo "$taglist" | grep "v$version" | tail -1)
- echo "Updating to $last_tag"
- git checkout -q -f "$last_tag"
+ echo "Updating to v$version"
+ branch_name="version-$version-hotfix"
+ git fetch --depth 1 upstream $branch_name:$branch_name
+
+ git checkout -q -f $branch_name
pip install -q -r requirements.txt
bench --site test_site migrate
done
diff --git a/.github/workflows/server-mariadb-tests.yml b/.github/workflows/server-mariadb-tests.yml
index 588f357f26..4edf74ba71 100644
--- a/.github/workflows/server-mariadb-tests.yml
+++ b/.github/workflows/server-mariadb-tests.yml
@@ -14,6 +14,7 @@ concurrency:
jobs:
test:
runs-on: ubuntu-latest
+ timeout-minutes: 60
strategy:
fail-fast: false
@@ -128,4 +129,4 @@ jobs:
fail_ci_if_error: true
files: /home/runner/frappe-bench/sites/coverage.xml
verbose: true
- flags: server
\ No newline at end of file
+ flags: server
diff --git a/.github/workflows/server-postgres-tests.yml b/.github/workflows/server-postgres-tests.yml
index 78f379837b..895af5184e 100644
--- a/.github/workflows/server-postgres-tests.yml
+++ b/.github/workflows/server-postgres-tests.yml
@@ -13,6 +13,7 @@ concurrency:
jobs:
test:
runs-on: ubuntu-latest
+ timeout-minutes: 60
strategy:
fail-fast: false
diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml
index fcc53ba59c..cb502f68a7 100644
--- a/.github/workflows/ui-tests.yml
+++ b/.github/workflows/ui-tests.yml
@@ -13,6 +13,7 @@ concurrency:
jobs:
test:
runs-on: ubuntu-latest
+ timeout-minutes: 60
strategy:
fail-fast: false
diff --git a/.gitignore b/.gitignore
index c9dd8f38f3..7e3d178630 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,7 @@ dist/
frappe/docs/current
frappe/public/dist
.vscode
+.vs
node_modules
.kdev4/
*.kdev4
diff --git a/CODEOWNERS b/CODEOWNERS
index 69ca578b6c..f7d759c123 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -3,18 +3,18 @@
# These owners will be the default owners for everything in
# the repo. Unless a later match takes precedence,
-* @frappe/frappe-review-team
-templates/ @surajshetty3416
-www/ @surajshetty3416
-integrations/ @leela
-patches/ @surajshetty3416 @gavindsouza
-email/ @leela
-event_streaming/ @ruchamahabal
-data_import* @netchampfaris
-core/ @surajshetty3416
+* @frappe/frappe-review-team
+templates/ @surajshetty3416
+www/ @surajshetty3416
+integrations/ @leela
+patches/ @surajshetty3416 @gavindsouza
+email/ @leela
+event_streaming/ @ruchamahabal
+data_import* @netchampfaris
+core/ @surajshetty3416
database @gavindsouza
model @gavindsouza
-requirements.txt @gavindsouza
-query_builder/ @gavindsouza
-commands/ @gavindsouza
+requirements.txt @gavindsouza
+query_builder/ @gavindsouza
+commands/ @gavindsouza
workspace @shariquerik
diff --git a/codecov.yml b/codecov.yml
index a9f6df0296..bc59416d2f 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -11,6 +11,15 @@ coverage:
threshold: 0.5%
flags:
- server
+ patch:
+ default: false
+ server:
+ target: 85%
+ threshold: 0%
+ only_pulls: true
+ if_ci_failed: ignore
+ flags:
+ - server
comment:
layout: "diff, flags"
diff --git a/cypress.json b/cypress.json
index f2508ca66e..15f8f230fa 100644
--- a/cypress.json
+++ b/cypress.json
@@ -4,8 +4,12 @@
"adminPassword": "admin",
"defaultCommandTimeout": 20000,
"pageLoadTimeout": 15000,
+ "video": true,
+ "videoUploadOnPasses": false,
"retries": {
"runMode": 2,
"openMode": 2
- }
+ },
+ "integrationFolder": ".",
+ "testFiles": ["cypress/integration/*.js", "**/ui_test_*.js"]
}
diff --git a/cypress/fixtures/doctype_with_tab_break.js b/cypress/fixtures/doctype_with_tab_break.js
index bc346e8fb8..74e5e6abba 100644
--- a/cypress/fixtures/doctype_with_tab_break.js
+++ b/cypress/fixtures/doctype_with_tab_break.js
@@ -30,11 +30,6 @@ export default {
"link_doctype": "Contact",
"link_fieldname": "user"
},
- {
- "group": "Profile",
- "link_doctype": "Chat Profile",
- "link_fieldname": "user"
- },
],
modified_by: 'Administrator',
module: 'Custom',
diff --git a/cypress/integration/control_duration.js b/cypress/integration/control_duration.js
index 266d421e70..09629a344f 100644
--- a/cypress/integration/control_duration.js
+++ b/cypress/integration/control_duration.js
@@ -33,12 +33,13 @@ context('Control Duration', () => {
cy.get('@dialog').then(dialog => {
let value = dialog.get_value('duration');
expect(value).to.equal(3889800);
+ cy.hide_dialog();
});
});
it('should hide days or seconds according to duration options', () => {
get_dialog_with_duration(1, 1).as('dialog');
- cy.get('.frappe-control[data-fieldname=duration] input').first().click();
+ cy.get('.frappe-control[data-fieldname=duration] input').first();
cy.get('.duration-input[data-duration=days]').should('not.be.visible');
cy.get('.duration-input[data-duration=seconds]').should('not.be.visible');
});
diff --git a/cypress/integration/control_link.js b/cypress/integration/control_link.js
index 2a81338c59..6d16769b37 100644
--- a/cypress/integration/control_link.js
+++ b/cypress/integration/control_link.js
@@ -49,19 +49,19 @@ context('Control Link', () => {
it('should unset invalid value', () => {
get_dialog_with_link().as('dialog');
- cy.intercept('GET', '/api/method/frappe.client.get_value*').as('get_value');
+ cy.intercept('POST', '/api/method/frappe.client.validate_link').as('validate_link');
cy.get('.frappe-control[data-fieldname=link] input')
.type('invalid value', { delay: 100 })
.blur();
- cy.wait('@get_value');
+ cy.wait('@validate_link');
cy.get('.frappe-control[data-fieldname=link] input').should('have.value', '');
});
it('should route to form on arrow click', () => {
get_dialog_with_link().as('dialog');
- cy.intercept('GET', '/api/method/frappe.client.get_value*').as('get_value');
+ cy.intercept('POST', '/api/method/frappe.client.validate_link').as('validate_link');
cy.intercept('POST', '/api/method/frappe.desk.search.search_link').as('search_link');
cy.get('@todos').then(todos => {
@@ -69,7 +69,7 @@ context('Control Link', () => {
cy.get('@input').focus();
cy.wait('@search_link');
cy.get('@input').type(todos[0]).blur();
- cy.wait('@get_value');
+ cy.wait('@validate_link');
cy.get('@input').focus();
cy.findByTitle('Open Link')
.should('be.visible')
@@ -81,11 +81,11 @@ context('Control Link', () => {
it('should fetch valid value', () => {
cy.get('@todos').then(todos => {
cy.visit(`/app/todo/${todos[0]}`);
- cy.intercept('GET', '/api/method/frappe.client.get_value*').as('get_value');
+ cy.intercept('POST', '/api/method/frappe.client.validate_link').as('validate_link');
cy.get('.frappe-control[data-fieldname=assigned_by] input').focus().as('input');
cy.get('@input').type('Administrator', {delay: 100}).blur();
- cy.wait('@get_value');
+ cy.wait('@validate_link');
cy.get('.frappe-control[data-fieldname=assigned_by_full_name] .control-value').should(
'contain', 'Administrator'
);
diff --git a/cypress/integration/control_rating.js b/cypress/integration/control_rating.js
index 592ed87004..15c11b352b 100644
--- a/cypress/integration/control_rating.js
+++ b/cypress/integration/control_rating.js
@@ -10,6 +10,7 @@ context('Control Rating', () => {
fields: [{
'fieldname': 'rate',
'fieldtype': 'Rating',
+ 'options': 7
}]
});
}
@@ -19,12 +20,13 @@ context('Control Rating', () => {
cy.get('div.rating')
.children('svg')
+ .find('.right-half')
.first()
.click()
.should('have.class', 'star-click');
cy.get('@dialog').then(dialog => {
var value = dialog.get_value('rate');
- expect(value).to.equal(1);
+ expect(value).to.equal(1/7);
dialog.hide();
});
});
@@ -34,10 +36,21 @@ context('Control Rating', () => {
cy.get('div.rating')
.children('svg')
+ .find('.right-half')
.first()
.invoke('trigger', 'mouseenter')
.should('have.class', 'star-hover')
.invoke('trigger', 'mouseleave')
.should('not.have.class', 'star-hover');
});
+
+ it('check number of stars in rating', () => {
+ get_dialog_with_rating();
+
+ cy.get('div.rating')
+ .first()
+ .children('svg')
+ .should('have.length', 7);
+ });
+
});
diff --git a/cypress/integration/dashboard_chart.js b/cypress/integration/dashboard_chart.js
new file mode 100644
index 0000000000..ae71fcda3a
--- /dev/null
+++ b/cypress/integration/dashboard_chart.js
@@ -0,0 +1,22 @@
+context('Dashboard Chart', () => {
+ before(() => {
+ cy.login();
+ cy.visit('/app/website');
+ });
+
+ it('Check filter populate for child table doctype', () => {
+ cy.visit('/app/dashboard-chart/new-dashboard-chart-1');
+ cy.get('[data-fieldname="parent_document_type"]').should('have.css', 'display', 'none');
+
+ cy.get_field('document_type', 'Link');
+ cy.fill_field('document_type', 'Workspace Link', 'Link').focus().blur();
+ cy.get_field('document_type', 'Link').should('have.value', 'Workspace Link');
+
+ cy.fill_field('chart_name', 'Test Chart', 'Data');
+
+ cy.get('[data-fieldname="filters_json"]').click().wait(200);
+ cy.get('.modal-body .filter-action-buttons .add-filter').click();
+ cy.get('.modal-body .fieldname-select-area').click();
+ cy.get('.modal-actions .btn-modal-close').click();
+ });
+});
\ No newline at end of file
diff --git a/cypress/integration/datetime.js b/cypress/integration/datetime.js
index b310526c7c..ef1952dc94 100644
--- a/cypress/integration/datetime.js
+++ b/cypress/integration/datetime.js
@@ -92,15 +92,15 @@ context('Control Date, Time and DateTime', () => {
date_format: 'dd.mm.yyyy',
time_format: 'HH:mm:ss',
value: ' 02.12.2019 11:00:12',
- doc_value: '2019-12-02 11:00:12',
- input_value: '02.12.2019 11:00:12'
+ doc_value: '2019-12-02 00:30:12', // system timezone (America/New_York)
+ input_value: '02.12.2019 11:00:12' // admin timezone (Asia/Kolkata)
},
{
date_format: 'mm-dd-yyyy',
time_format: 'HH:mm',
value: ' 12-02-2019 11:00:00',
- doc_value: '2019-12-02 11:00:00',
- input_value: '12-02-2019 11:00'
+ doc_value: '2019-12-02 00:30:00', // system timezone (America/New_York)
+ input_value: '12-02-2019 11:00' // admin timezone (Asia/Kolkata)
}
];
datetime_formats.forEach(d => {
diff --git a/cypress/integration/first_day_of_the_week.js b/cypress/integration/first_day_of_the_week.js
new file mode 100644
index 0000000000..1e65b78990
--- /dev/null
+++ b/cypress/integration/first_day_of_the_week.js
@@ -0,0 +1,45 @@
+context("First Day of the Week", () => {
+ before(() => {
+ cy.login();
+ });
+
+ beforeEach(() => {
+ cy.visit('/app/system-settings');
+ cy.findByText('Date and Number Format').click();
+ });
+
+ it("Date control starts with same day as selected in System Settings", () => {
+ cy.intercept('POST', '/api/method/frappe.core.doctype.system_settings.system_settings.load').as("load_settings");
+ cy.fill_field('first_day_of_the_week', 'Tuesday', 'Select');
+ cy.findByRole('button', {name: 'Save'}).click();
+ cy.wait("@load_settings");
+ cy.dialog({
+ title: 'Date',
+ fields: [
+ {
+ label: 'Date',
+ fieldname: 'date',
+ fieldtype: 'Date'
+ }
+ ]
+ });
+ cy.get_field('date').click();
+ cy.get('.datepicker--day-name').eq(0).should('have.text', 'Tu');
+ });
+
+ it("Calendar view starts with same day as selected in System Settings", () => {
+ cy.intercept('POST', '/api/method/frappe.core.doctype.system_settings.system_settings.load').as("load_settings");
+ cy.fill_field('first_day_of_the_week', 'Monday', 'Select');
+ cy.findByRole('button', {name: 'Save'}).click();
+ cy.wait("@load_settings");
+ cy.visit("app/todo/view/calendar/default");
+ cy.get('.fc-day-header > span').eq(0).should('have.text', 'Mon');
+ });
+
+ after(() => {
+ cy.visit('/app/system-settings');
+ cy.findByText('Date and Number Format').click();
+ cy.fill_field('first_day_of_the_week', 'Sunday', 'Select');
+ cy.findByRole('button', {name: 'Save'}).click();
+ });
+});
\ No newline at end of file
diff --git a/cypress/integration/form.js b/cypress/integration/form.js
index f860a742ef..71cc6f4f0d 100644
--- a/cypress/integration/form.js
+++ b/cypress/integration/form.js
@@ -8,11 +8,7 @@ context('Form', () => {
});
it('create a new form', () => {
cy.visit('/app/todo/new');
- cy.get('[data-fieldname="description"] .ql-editor')
- .first()
- .click()
- .type('this is a test todo');
- cy.wait(300);
+ cy.get_field('description', 'Text Editor').type('this is a test todo', {force: true}).wait(200);
cy.get('.page-title').should('contain', 'Not Saved');
cy.intercept({
method: 'POST',
@@ -20,29 +16,34 @@ context('Form', () => {
}).as('form_save');
cy.get('.primary-action').click();
cy.wait('@form_save').its('response.statusCode').should('eq', 200);
+
cy.visit('/app/todo');
- cy.wait(300);
- cy.get('.title-text').should('be.visible').and('contain', 'To Do');
+ cy.get('.page-head').findByTitle('To Do').should('exist');
cy.get('.list-row').should('contain', 'this is a test todo');
});
+
it('navigates between documents with child table list filters applied', () => {
cy.visit('/app/contact');
- cy.add_filter();
- cy.get('.filter-field .input-with-feedback.form-control').type('123', { force: true });
- cy.findByRole('button', {name: 'Apply Filters'}).click({ force: true });
- cy.visit('/app/contact/Test Form Contact 3');
+
+ 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.get('.prev-doc').should('be.visible').click();
cy.get('.msgprint-dialog .modal-body').contains('No further records').should('be.visible');
cy.hide_dialog();
- cy.get('.next-doc').click();
- cy.wait(200);
+
+ cy.get('.next-doc').should('be.visible').click();
+ cy.get('.msgprint-dialog .modal-body').contains('No further records').should('be.visible');
cy.hide_dialog();
- cy.contains('Test Form Contact 2').should('not.exist');
- cy.get('.title-text').should('contain', 'Test Form Contact 3');
+
+ cy.get('#page-Contact .page-head').findByTitle('Test Form Contact 3').should('exist');
+
// clear filters
cy.visit('/app/contact');
cy.clear_filters();
});
+
it('validates behaviour of Data options validations in child table', () => {
// test email validations for set_invalid controller
let website_input = 'website.in';
diff --git a/cypress/integration/grid_keyboard_shortcut.js b/cypress/integration/grid_keyboard_shortcut.js
new file mode 100644
index 0000000000..9cf39165ad
--- /dev/null
+++ b/cypress/integration/grid_keyboard_shortcut.js
@@ -0,0 +1,40 @@
+context('Grid Keyboard Shortcut', () => {
+ let total_count = 0;
+ before(() => {
+ cy.login();
+ });
+ beforeEach(() => {
+ cy.reload();
+ cy.visit('/app/contact/new-contact-1');
+ cy.get('.frappe-control[data-fieldname="email_ids"]').find(".grid-add-row").click();
+ });
+ it('Insert new row at the end', () => {
+ cy.add_new_row_in_grid('{ctrl}{shift}{downarrow}', (cy, total_count) => {
+ cy.get('[data-name="new-contact-email-1"]').should('have.attr', 'data-idx', `${total_count+1}`);
+ }, total_count);
+ });
+ it('Insert new row at the top', () => {
+ cy.add_new_row_in_grid('{ctrl}{shift}{uparrow}', (cy) => {
+ cy.get('[data-name="new-contact-email-1"]').should('have.attr', 'data-idx', '2');
+ });
+ });
+ it('Insert new row below', () => {
+ cy.add_new_row_in_grid('{ctrl}{downarrow}', (cy) => {
+ cy.get('[data-name="new-contact-email-1"]').should('have.attr', 'data-idx', '1');
+ });
+ });
+ it('Insert new row above', () => {
+ cy.add_new_row_in_grid('{ctrl}{uparrow}', (cy) => {
+ cy.get('[data-name="new-contact-email-1"]').should('have.attr', 'data-idx', '2');
+ });
+ });
+});
+
+Cypress.Commands.add('add_new_row_in_grid', (shortcut_keys, callbackFn, total_count) => {
+ cy.get('.frappe-control[data-fieldname="email_ids"]').as('table');
+ cy.get('@table').find('.grid-body [data-fieldname="email_id"]').first().click();
+ cy.get('@table').find('.grid-body [data-fieldname="email_id"]')
+ .first().type(shortcut_keys);
+
+ callbackFn(cy, total_count);
+});
\ No newline at end of file
diff --git a/cypress/integration/grid_pagination.js b/cypress/integration/grid_pagination.js
index c07230d2b8..84b3320282 100644
--- a/cypress/integration/grid_pagination.js
+++ b/cypress/integration/grid_pagination.js
@@ -13,7 +13,7 @@ context('Grid Pagination', () => {
it('creates pages for child table', () => {
cy.visit('/app/contact/Test Contact');
cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table');
- cy.get('@table').find('.current-page-number').should('contain', '1');
+ cy.get('@table').find('.current-page-number').should('have.value', '1');
cy.get('@table').find('.total-page-number').should('contain', '20');
cy.get('@table').find('.grid-body .grid-row').should('have.length', 50);
});
@@ -21,10 +21,10 @@ context('Grid Pagination', () => {
cy.visit('/app/contact/Test Contact');
cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table');
cy.get('@table').find('.next-page').click();
- cy.get('@table').find('.current-page-number').should('contain', '2');
+ cy.get('@table').find('.current-page-number').should('have.value', '2');
cy.get('@table').find('.grid-body .grid-row').first().should('have.attr', 'data-idx', '51');
cy.get('@table').find('.prev-page').click();
- cy.get('@table').find('.current-page-number').should('contain', '1');
+ cy.get('@table').find('.current-page-number').should('have.value', '1');
cy.get('@table').find('.grid-body .grid-row').first().should('have.attr', 'data-idx', '1');
});
it('adds and deletes rows and changes page', () => {
@@ -32,14 +32,35 @@ context('Grid Pagination', () => {
cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table');
cy.get('@table').findByRole('button', {name: 'Add Row'}).click();
cy.get('@table').find('.grid-body .row-index').should('contain', 1001);
- cy.get('@table').find('.current-page-number').should('contain', '21');
+ cy.get('@table').find('.current-page-number').should('have.value', '21');
cy.get('@table').find('.total-page-number').should('contain', '21');
cy.get('@table').find('.grid-body .grid-row .grid-row-check').click({ force: true });
cy.get('@table').findByRole('button', {name: 'Delete'}).click();
cy.get('@table').find('.grid-body .row-index').last().should('contain', 1000);
- cy.get('@table').find('.current-page-number').should('contain', '20');
+ cy.get('@table').find('.current-page-number').should('have.value', '20');
cy.get('@table').find('.total-page-number').should('contain', '20');
});
+ it('go to specific page, use up and down arrow, type characters, 0 page and more than existing page', () => {
+ cy.visit('/app/contact/Test Contact');
+ cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table');
+ cy.get('@table').find('.current-page-number').focus().clear().type('17').blur();
+ cy.get('@table').find('.grid-body .row-index').should('contain', 801);
+
+ cy.get('@table').find('.current-page-number').focus().type('{uparrow}{uparrow}');
+ cy.get('@table').find('.current-page-number').should('have.value', '19');
+
+ cy.get('@table').find('.current-page-number').focus().type('{downarrow}{downarrow}');
+ cy.get('@table').find('.current-page-number').should('have.value', '17');
+
+ cy.get('@table').find('.current-page-number').focus().clear().type('700').blur();
+ cy.get('@table').find('.current-page-number').should('have.value', '20');
+
+ cy.get('@table').find('.current-page-number').focus().clear().type('0').blur();
+ cy.get('@table').find('.current-page-number').should('have.value', '1');
+
+ cy.get('@table').find('.current-page-number').focus().clear().type('abc').blur();
+ cy.get('@table').find('.current-page-number').should('have.value', '1');
+ });
// it('deletes all rows', ()=> {
// cy.visit('/app/contact/Test Contact');
// cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table');
diff --git a/cypress/integration/list_view.js b/cypress/integration/list_view.js
index ce9e87274b..b161af2df7 100644
--- a/cypress/integration/list_view.js
+++ b/cypress/integration/list_view.js
@@ -7,19 +7,13 @@ context('List View', () => {
});
});
- it('Keep checkbox checked after Bulk Update', () => {
+ it('Keep checkbox checked after Refresh', () => {
cy.go_to_list('ToDo');
cy.get('.list-row-container .list-row-checkbox').click({ multiple: true, force: true });
- cy.get('.actions-btn-group button').contains('Actions').should('be.visible').click();
- cy.get('.dropdown-menu li:visible .dropdown-item .menu-item-label[data-label="Edit"]').click();
-
- cy.get('.modal-body .form-control[data-fieldname="field"]').first().select('Due Date').wait(200);
- cy.fill_field('value', '09-28-21', 'Date');
-
- cy.get('.modal-footer .standard-actions .btn-primary').click();
- cy.wait(500);
-
- cy.get('.actions-btn-group button').contains('Actions').should('be.visible').click();
+ cy.get('.actions-btn-group button').contains('Actions').should('be.visible');
+ cy.intercept('/api/method/frappe.desk.reportview.get').as('list-refresh');
+ cy.get('button[data-original-title="Refresh"]').click();
+ cy.wait('@list-refresh');
cy.get('.list-row-container .list-row-checkbox:checked').should('be.visible');
});
diff --git a/cypress/integration/multi_select_dialog.js b/cypress/integration/multi_select_dialog.js
index a45fba8d32..7752ad0f0b 100644
--- a/cypress/integration/multi_select_dialog.js
+++ b/cypress/integration/multi_select_dialog.js
@@ -2,32 +2,47 @@ context('MultiSelectDialog', () => {
before(() => {
cy.login();
cy.visit('/app');
+ const contact_template = {
+ "doctype": "Contact",
+ "first_name": "Test",
+ "status": "Passive",
+ "email_ids": [
+ {
+ "doctype": "Contact Email",
+ "email_id": "test@example.com",
+ "is_primary": 0
+ }
+ ]
+ };
+ const promises = Array.from({length: 25})
+ .map(() => cy.insert_doc('Contact', contact_template, true));
+ Promise.all(promises);
});
function open_multi_select_dialog() {
cy.window().its('frappe').then(frappe => {
new frappe.ui.form.MultiSelectDialog({
- doctype: "Assignment Rule",
+ doctype: "Contact",
target: {},
setters: {
- document_type: null,
- priority: null
+ status: null,
+ gender: null
},
add_filters_group: 1,
allow_child_item_selection: 1,
- child_fieldname: "assignment_days",
- child_columns: ["day"]
+ child_fieldname: "email_ids",
+ child_columns: ["email_id", "is_primary"]
});
});
}
- it('multi select dialog api works', () => {
+ it('checks multi select dialog api works', () => {
open_multi_select_dialog();
- cy.get_open_dialog().should('contain', 'Select Assignment Rules');
+ cy.get_open_dialog().should('contain', 'Select Contacts');
});
it('checks for filters', () => {
- ['search_term', 'document_type', 'priority'].forEach(fieldname => {
+ ['search_term', 'status', 'gender'].forEach(fieldname => {
cy.get_open_dialog().get(`.frappe-control[data-fieldname="${fieldname}"]`).should('exist');
});
@@ -42,17 +57,43 @@ context('MultiSelectDialog', () => {
cy.get_open_dialog()
.get(`.frappe-control[data-fieldname="allow_child_item_selection"]`)
+ .find('input[data-fieldname="allow_child_item_selection"]')
.should('exist')
- .click();
+ .click({force: true});
cy.get_open_dialog()
.get(`.frappe-control[data-fieldname="child_selection_area"]`)
.should('exist');
cy.get_open_dialog()
- .get(`.dt-row-header`).should('contain', 'Assignment Rule');
+ .get(`.dt-row-header`).should('contain', 'Contact');
cy.get_open_dialog()
- .get(`.dt-row-header`).should('contain', 'Day');
+ .get(`.dt-row-header`).should('contain', 'Email Id');
+
+ cy.get_open_dialog()
+ .get(`.dt-row-header`).should('contain', 'Is Primary');
+ });
+
+ it('tests more button', () => {
+ cy.get_open_dialog()
+ .get(`.frappe-control[data-fieldname="more_btn"]`)
+ .should('exist')
+ .as('more-btn');
+
+ cy.get_open_dialog().get('.list-item-container').should(($rows) => {
+ expect($rows).to.have.length(20);
+ });
+
+ cy.intercept('POST', 'api/method/frappe.client.get_list').as('get-more-records');
+ cy.get('@more-btn').find('button').click({force: true});
+ cy.wait('@get-more-records');
+
+ cy.get_open_dialog().get('.list-item-container').should(($rows) => {
+ if ($rows.length <= 20) {
+ throw new Error("More button doesn't work");
+ }
+ });
+
});
});
\ No newline at end of file
diff --git a/cypress/integration/navigation.js b/cypress/integration/navigation.js
index c4d0638f26..b4e023c53e 100644
--- a/cypress/integration/navigation.js
+++ b/cypress/integration/navigation.js
@@ -13,10 +13,10 @@ context('Navigation', () => {
it.only('Navigate to previous page after login', () => {
cy.visit('/app/todo');
- cy.findByTitle('To Do').should('be.visible');
+ cy.get('.page-head').findByTitle('To Do').should('be.visible');
cy.request('/api/method/logout');
- cy.reload();
- cy.get('.btn-primary').contains('Login').click();
+ cy.reload().as('reload');
+ cy.get('@reload').get('.page-card .btn-primary').contains('Login').click();
cy.location('pathname').should('eq', '/login');
cy.login();
cy.visit('/app');
diff --git a/cypress/integration/query_report.js b/cypress/integration/query_report.js
index e2a1c3fc79..43f26f8b50 100644
--- a/cypress/integration/query_report.js
+++ b/cypress/integration/query_report.js
@@ -2,32 +2,62 @@ context('Query Report', () => {
before(() => {
cy.login();
cy.visit('/app/website');
+ cy.insert_doc('Report', {
+ 'report_name': 'Test ToDo Report',
+ 'ref_doctype': 'ToDo',
+ 'report_type': 'Query Report',
+ 'query': 'select * from tabToDo'
+ }, true).as('doc');
+ cy.create_records({
+ doctype: 'ToDo',
+ description: 'this is a test todo for query report'
+ }).as('todos');
});
it('add custom column in report', () => {
cy.visit('/app/query-report/Permitted Documents For User');
cy.get('.page-form.flex', { timeout: 60000 }).should('have.length', 1).then(() => {
- cy.get('#page-query-report input[data-fieldname="user"]').as('input');
- cy.get('@input').focus().type('test@erpnext.com', { delay: 100 }).blur();
+ cy.get('#page-query-report input[data-fieldname="user"]').as('input-user');
+ cy.get('@input-user').focus().type('test@erpnext.com', { delay: 100 }).blur();
cy.wait(300);
- cy.get('#page-query-report input[data-fieldname="doctype"]').as('input-test');
- cy.get('@input-test').focus().type('Role', { delay: 100 }).blur();
+ cy.get('#page-query-report input[data-fieldname="doctype"]').as('input-role');
+ cy.get('@input-role').focus().type('Role', { delay: 100 }).blur();
cy.get('.datatable').should('exist');
- cy.get('.menu-btn-group button').click({ force: true });
- cy.get('.dropdown-menu li').contains('Add Column').click({ force: true });
- cy.get('.modal-dialog').should('contain', 'Add Column');
+ cy.get('#page-query-report .page-actions .menu-btn-group button').click({ force: true });
+ cy.get('#page-query-report .menu-btn-group .dropdown-menu').contains('Add Column').click({ force: true });
+ cy.get_open_dialog().get('.modal-title').should('contain', 'Add Column');
cy.get('select[data-fieldname="doctype"]').select("Role", { force: true });
cy.get('select[data-fieldname="field"]').select("Role Name", { force: true });
cy.get('select[data-fieldname="insert_after"]').select("Name", { force: true });
- cy.get('button').contains('Submit').click({ force: true });
- cy.get('.menu-btn-group button').click({ force: true });
- cy.get('.dropdown-menu li').contains('Save').click({ force: true });
- cy.get('.modal-dialog').should('contain', 'Save Report');
+ cy.get_open_dialog().findByRole('button', {name: 'Submit'}).click({ force: true });
+ cy.get('#page-query-report .page-actions .menu-btn-group button').click({ force: true });
+ cy.get('#page-query-report .menu-btn-group .dropdown-menu').contains('Save').click({ timeout: 100, force: true });
+ cy.get_open_dialog().get('.modal-title').should('contain', 'Save Report');
cy.get('input[data-fieldname="report_name"]').type("Test Report", { delay: 100, force: true });
- cy.get('button').contains('Submit').click({ timeout: 1000, force: true });
+ cy.get_open_dialog().findByRole('button', {name: 'Submit'}).click({ timeout: 1000, force: true });
});
});
+
+ let save_report_and_open = (report, update_name) => {
+ cy.get('#page-query-report .page-actions .menu-btn-group button').click({ force: true });
+ cy.get('#page-query-report .menu-btn-group .dropdown-menu').contains('Save').click({ timeout: 100, force: true });
+ cy.get_open_dialog().get('.modal-title').should('contain', 'Save Report');
+
+ cy.get('input[data-fieldname="report_name"]').type(update_name, { delay: 100, force: true });
+ cy.get_open_dialog().findByRole('button', {name: 'Submit'}).click({ timeout: 1000, force: true });
+
+ cy.visit('/app/query-report/'+report);
+ cy.get('.datatable').should('exist');
+ };
+
+ it('test multi level query report', () => {
+ cy.visit('/app/query-report/Test ToDo Report');
+ cy.get('.datatable').should('exist');
+
+ save_report_and_open('Test ToDo Report 1', ' 1');
+ save_report_and_open('Test ToDo Report 11', '1');
+ });
});
\ No newline at end of file
diff --git a/cypress/integration/recorder.js b/cypress/integration/recorder.js
index caf1349e6e..7d4c83abf5 100644
--- a/cypress/integration/recorder.js
+++ b/cypress/integration/recorder.js
@@ -14,48 +14,51 @@ context('Recorder', () => {
});
it('Recorder Empty State', () => {
- cy.findByTitle('Recorder').should('exist');
+ cy.get('.page-head').findByTitle('Recorder').should('exist');
cy.get('.indicator-pill').should('contain', 'Inactive').should('have.class', 'red');
- cy.findByRole('button', {name: 'Start'}).should('exist');
- cy.findByRole('button', {name: 'Clear'}).should('exist');
+ cy.get('.page-actions').findByRole('button', {name: 'Start'}).should('exist');
+ cy.get('.page-actions').findByRole('button', {name: 'Clear'}).should('exist');
- cy.get('.msg-box').should('contain', 'Inactive');
- cy.findByRole('button', {name: 'Start Recording'}).should('exist');
+ cy.get('.msg-box').should('contain', 'Recorder is Inactive');
+ cy.get('.msg-box').findByRole('button', {name: 'Start Recording'}).should('exist');
});
it('Recorder Start', () => {
- cy.findByRole('button', {name: 'Start'}).click();
+ cy.get('.page-actions').findByRole('button', {name: 'Start'}).click();
cy.get('.indicator-pill').should('contain', 'Active').should('have.class', 'green');
- cy.get('.msg-box').should('contain', 'No Requests');
+ cy.get('.msg-box').should('contain', 'No Requests found');
cy.visit('/app/List/DocType/List');
cy.intercept('POST', '/api/method/frappe.desk.reportview.get').as('list_refresh');
cy.wait('@list_refresh');
- cy.get('.title-text').should('contain', 'DocType');
+ cy.get('.page-head').findByTitle('DocType').should('exist');
cy.get('.list-count').should('contain', '20 of ');
cy.visit('/app/recorder');
- cy.findByTitle('Recorder').should('exist');
- cy.get('.result-list').should('contain', '/api/method/frappe.desk.reportview.get');
+ cy.get('.page-head').findByTitle('Recorder').should('exist');
+ cy.get('.frappe-list .result-list').should('contain', '/api/method/frappe.desk.reportview.get');
});
it('Recorder View Request', () => {
- cy.findByRole('button', {name: 'Start'}).click();
+ cy.get('.page-actions').findByRole('button', {name: 'Start'}).click();
cy.visit('/app/List/DocType/List');
cy.intercept('POST', '/api/method/frappe.desk.reportview.get').as('list_refresh');
cy.wait('@list_refresh');
- cy.get('.title-text').should('contain', 'DocType');
+ cy.get('.page-head').findByTitle('DocType').should('exist');
cy.get('.list-count').should('contain', '20 of ');
cy.visit('/app/recorder');
- cy.get('.list-row-container span').contains('/api/method/frappe').click();
+ cy.get('.frappe-list .list-row-container span')
+ .contains('/api/method/frappe')
+ .should('be.visible')
+ .click({force: true});
cy.url().should('include', '/recorder/request');
cy.get('form').should('contain', '/api/method/frappe');
diff --git a/cypress/integration/report_view.js b/cypress/integration/report_view.js
index e762eebea1..629ae72eb8 100644
--- a/cypress/integration/report_view.js
+++ b/cypress/integration/report_view.js
@@ -7,6 +7,8 @@ context('Report View', () => {
cy.visit('/app/website');
cy.insert_doc('DocType', custom_submittable_doctype, true);
cy.clear_cache();
+ });
+ it('Field with enabled allow_on_submit should be editable.', () => {
cy.insert_doc(doctype_name, {
'title': 'Doc 1',
'description': 'Random Text',
@@ -14,8 +16,6 @@ context('Report View', () => {
// submit document
'docstatus': 1
}, true).as('doc');
- });
- it('Field with enabled allow_on_submit should be editable.', () => {
cy.intercept('POST', 'api/method/frappe.client.set_value').as('value-update');
cy.visit(`/app/List/${doctype_name}/Report`);
// check status column added from docstatus
@@ -23,8 +23,7 @@ context('Report View', () => {
let cell = cy.get('.dt-row-0 > .dt-cell--col-4');
// select the cell
cell.dblclick();
- cell.findByRole('checkbox').check({ force: true });
- cy.get('.dt-row-0 > .dt-cell--col-5').click();
+ cell.get('.dt-cell__edit--col-4').findByRole('checkbox').check({ force: true });
cy.wait('@value-update');
cy.get('@doc').then(doc => {
cy.call('frappe.client.get_value', {
diff --git a/cypress/integration/timeline.js b/cypress/integration/timeline.js
index 191b5a2b2c..6c4733400d 100644
--- a/cypress/integration/timeline.js
+++ b/cypress/integration/timeline.js
@@ -8,22 +8,18 @@ context('Timeline', () => {
it('Adding new ToDo, adding new comment, verifying comment addition & deletion and deleting ToDo', () => {
//Adding new ToDo
+ cy.visit('/app/todo/new-todo-1');
+ cy.get('[data-fieldname="description"] .ql-editor.ql-blank').type('Test ToDo', {force: true}).wait(200);
+ cy.get('.page-head .page-actions').findByRole('button', {name: 'Save'}).click();
+
cy.visit('/app/todo');
- cy.click_listview_primary_button('Add ToDo');
- cy.findByRole('button', {name: 'Edit in full page'}).click();
- cy.findByTitle('New ToDo').should('be.visible');
- cy.get('[data-fieldname="description"] .ql-editor').eq(0).type('Test ToDo', {force: true});
- cy.wait(200);
- cy.findByRole('button', {name: 'Save'}).click();
- cy.wait(700);
- cy.visit('/app/todo');
- cy.get('.level-item.ellipsis').eq(0).click();
+ cy.click_listview_row_item(0);
//To check if the comment box is initially empty and tying some text into it
cy.get('[data-fieldname="comment"] .ql-editor').should('contain', '').type('Testing Timeline');
//Adding new comment
- cy.findByRole('button', {name: 'Comment'}).click();
+ cy.get('.comment-box').findByRole('button', {name: 'Comment'}).click();
//To check if the commented text is visible in the timeline content
cy.get('.timeline-content').should('contain', 'Testing Timeline');
@@ -38,21 +34,17 @@ context('Timeline', () => {
//Discarding comment
cy.click_timeline_action_btn("Edit");
- cy.findByRole('button', {name: 'Dismiss'}).click();
+ cy.click_timeline_action_btn("Dismiss");
//To check if after discarding the timeline content is same as previous
cy.get('.timeline-content').should('contain', 'Testing Timeline 123');
//Deleting the added comment
- cy.get('.more-actions > .action-btn').click();
- cy.get('.more-actions .dropdown-item').contains('Delete').click();
- cy.findByRole('button', {name: 'Yes'}).click();
- cy.click_modal_primary_button('Yes');
+ cy.get('.timeline-message-box .more-actions > .action-btn').click(); //Menu button in timeline item
+ cy.get('.timeline-message-box .more-actions .dropdown-item').contains('Delete').click({ force: true });
+ cy.get_open_dialog().findByRole('button', {name: 'Yes'}).click({ force: true });
- //Deleting the added ToDo
- cy.get('[id="page-ToDo"] .menu-btn-group [data-original-title="Menu"]').click();
- cy.get('[id="page-ToDo"] .menu-btn-group .dropdown-item').contains('Delete').click();
- cy.findByRole('button', {name: 'Yes'}).click();
+ cy.get('.timeline-content').should('not.contain', 'Testing Timeline 123');
});
it('Timeline should have submit and cancel activity information', () => {
@@ -66,31 +58,32 @@ context('Timeline', () => {
//Adding a new entry for the created custom doctype
cy.fill_field('title', 'Test');
- cy.findByRole('button', {name: 'Save'}).click();
- cy.findByRole('button', {name: 'Submit'}).click();
+ cy.click_modal_primary_button('Save');
+ cy.click_modal_primary_button('Submit');
+
cy.visit('/app/custom-submittable-doctype');
- cy.get('.list-subject > .bold > .ellipsis').eq(0).click();
+ cy.click_listview_row_item(0);
//To check if the submission of the documemt is visible in the timeline content
cy.get('.timeline-content').should('contain', 'Administrator submitted this document');
- cy.findByRole('button', {name: 'Cancel'}).click({delay: 900});
- cy.findByRole('button', {name: 'Yes'}).click();
+ cy.get('[id="page-Custom Submittable DocType"] .page-actions').findByRole('button', {name: 'Cancel'}).click();
+ cy.get_open_dialog().findByRole('button', {name: 'Yes'}).click();
//To check if the cancellation of the documemt is visible in the timeline content
cy.get('.timeline-content').should('contain', 'Administrator cancelled this document');
//Deleting the document
cy.visit('/app/custom-submittable-doctype');
- cy.get('.list-subject > .select-like > .list-row-checkbox').eq(0).click();
- cy.findByRole('button', {name: 'Actions'}).click();
- cy.get('.actions-btn-group > .dropdown-menu > li > .dropdown-item').contains("Delete").click();
- cy.click_modal_primary_button('Yes', {force: true, delay: 700});
+ cy.select_listview_row_checkbox(0);
+ cy.get('.page-actions').findByRole('button', {name: 'Actions'}).click();
+ cy.get('.page-actions .actions-btn-group [data-label="Delete"]').click();
+ cy.click_modal_primary_button('Yes');
//Deleting the custom doctype
cy.visit('/app/doctype');
- cy.get('.list-subject > .select-like > .list-row-checkbox').eq(0).click();
- cy.findByRole('button', {name: 'Actions'}).click();
- cy.get('.actions-btn-group [data-label="Delete"]').click();
+ cy.select_listview_row_checkbox(0);
+ cy.get('.page-actions').findByRole('button', {name: 'Actions'}).click();
+ cy.get('.page-actions .actions-btn-group [data-label="Delete"]').click();
cy.click_modal_primary_button('Yes');
});
});
\ No newline at end of file
diff --git a/cypress/integration/web_form.js b/cypress/integration/web_form.js
new file mode 100644
index 0000000000..8346c96313
--- /dev/null
+++ b/cypress/integration/web_form.js
@@ -0,0 +1,29 @@
+context('Web Form', () => {
+ before(() => {
+ cy.login();
+ });
+
+ it('Navigate and Submit a WebForm', () => {
+ cy.visit('/update-profile');
+ cy.get_field('last_name', 'Data').type('_Test User', {force: true}).wait(200);
+ cy.get('.web-form-actions .btn-primary').click();
+ cy.wait(500);
+ cy.get('.modal.show > .modal-dialog').should('be.visible');
+ });
+
+ it('Navigate and Submit a MultiStep WebForm', () => {
+ cy.call('frappe.tests.ui_test_helpers.update_webform_to_multistep').then(() => {
+ cy.visit('/update-profile-duplicate');
+ cy.get_field('last_name', 'Data').type('_Test User', {force: true}).wait(200);
+ cy.get('.btn-next').should('be.visible');
+ cy.get('.web-form-footer .btn-primary').should('not.be.visible');
+ cy.get('.btn-next').click();
+ cy.get('.btn-previous').should('be.visible');
+ cy.get('.btn-next').should('not.be.visible');
+ cy.get('.web-form-footer .btn-primary').should('be.visible');
+ cy.get('.web-form-actions .btn-primary').click();
+ cy.wait(500);
+ cy.get('.modal.show > .modal-dialog').should('be.visible');
+ });
+ });
+});
diff --git a/cypress/support/commands.js b/cypress/support/commands.js
index 64a3b18b2f..758b3cde2b 100644
--- a/cypress/support/commands.js
+++ b/cypress/support/commands.js
@@ -30,7 +30,7 @@ Cypress.Commands.add('login', (email, password) => {
email = 'Administrator';
}
if (!password) {
- password = Cypress.config('adminPassword');
+ password = Cypress.env('adminPassword');
}
cy.request({
url: '/api/method/login',
@@ -161,7 +161,7 @@ Cypress.Commands.add('remove_doc', (doctype, name) => {
Cypress.Commands.add('create_records', doc => {
return cy
- .call('frappe.tests.ui_test_helpers.create_if_not_exists', {doc})
+ .call('frappe.tests.ui_test_helpers.create_if_not_exists', {doc: JSON.stringify(doc)})
.then(r => r.message);
});
@@ -193,7 +193,8 @@ Cypress.Commands.add('fill_field', (fieldname, value, fieldtype = 'Data') => {
});
Cypress.Commands.add('get_field', (fieldname, fieldtype = 'Data') => {
- let selector = `[data-fieldname="${fieldname}"] input:visible`;
+ let field_element = fieldtype === 'Select' ? 'select': 'input';
+ let selector = `[data-fieldname="${fieldname}"] ${field_element}:visible`;
if (fieldtype === 'Text Editor') {
selector = `[data-fieldname="${fieldname}"] .ql-editor[contenteditable=true]:visible`;
@@ -341,7 +342,7 @@ Cypress.Commands.add('click_sidebar_button', (btn_name) => {
});
Cypress.Commands.add('click_listview_row_item', (row_no) => {
- cy.get('.list-row > .level-left > .list-subject > .bold > .ellipsis').eq(row_no).click({force: true});
+ cy.get('.list-row > .level-left > .list-subject > .level-item > .ellipsis').eq(row_no).click({force: true});
});
Cypress.Commands.add('click_filter_button', () => {
@@ -353,5 +354,9 @@ Cypress.Commands.add('click_listview_primary_button', (btn_name) => {
});
Cypress.Commands.add('click_timeline_action_btn', (btn_name) => {
- cy.get('.timeline-message-box .custom-actions > .btn').contains(btn_name).click();
+ cy.get('.timeline-message-box .actions .action-btn').contains(btn_name).click();
+});
+
+Cypress.Commands.add('select_listview_row_checkbox', (row_no) => {
+ cy.get('.frappe-list .select-like > .list-row-checkbox').eq(row_no).click();
});
diff --git a/dev-requirements.txt b/dev-requirements.txt
index df3ae9484a..f4045c6bed 100644
--- a/dev-requirements.txt
+++ b/dev-requirements.txt
@@ -1,3 +1,4 @@
+coverage==5.5
Faker~=8.1.0
pyngrok~=5.0.5
unittest-xml-reporting~=3.0.4
diff --git a/esbuild/build-cleanup.js b/esbuild/build-cleanup.js
new file mode 100644
index 0000000000..cf03606a34
--- /dev/null
+++ b/esbuild/build-cleanup.js
@@ -0,0 +1,38 @@
+/* eslint-disable no-console */
+const path = require("path");
+const fs = require("fs");
+const glob = require("fast-glob");
+
+module.exports = {
+ name: 'build_cleanup',
+ setup(build) {
+ build.onEnd(result => {
+ if (result.errors.length) return;
+ clean_dist_files(Object.keys(result.metafile.outputs));
+ });
+ },
+};
+
+function clean_dist_files(new_files) {
+ new_files.forEach(
+ file => {
+ if (file.endsWith(".map")) return;
+
+ const pattern = file.split(".").slice(0, -2).join(".") + "*";
+ glob.sync(pattern).forEach(
+ file_to_delete => {
+ if (file_to_delete.startsWith(file)) return;
+
+ fs.unlink(path.resolve(file_to_delete), err => {
+ if (!err) return;
+
+ console.error(
+ `Error deleting ${file.split(path.sep).pop()}`
+ );
+ });
+ }
+
+ );
+ }
+ );
+}
\ No newline at end of file
diff --git a/esbuild/esbuild.js b/esbuild/esbuild.js
index 18de95b40d..792cb56198 100644
--- a/esbuild/esbuild.js
+++ b/esbuild/esbuild.js
@@ -1,18 +1,20 @@
/* eslint-disable no-console */
-let path = require("path");
-let fs = require("fs");
-let glob = require("fast-glob");
-let esbuild = require("esbuild");
-let vue = require("esbuild-vue");
-let yargs = require("yargs");
-let cliui = require("cliui")();
-let chalk = require("chalk");
-let html_plugin = require("./frappe-html");
-let rtlcss = require('rtlcss');
-let postCssPlugin = require("esbuild-plugin-postcss2").default;
-let ignore_assets = require("./ignore-assets");
-let sass_options = require("./sass_options");
-let {
+const path = require("path");
+const fs = require("fs");
+const glob = require("fast-glob");
+const esbuild = require("esbuild");
+const vue = require("esbuild-vue");
+const yargs = require("yargs");
+const cliui = require("cliui")();
+const chalk = require("chalk");
+const html_plugin = require("./frappe-html");
+const rtlcss = require('rtlcss');
+const postCssPlugin = require("esbuild-plugin-postcss2").default;
+const ignore_assets = require("./ignore-assets");
+const sass_options = require("./sass_options");
+const build_cleanup_plugin = require("./build-cleanup");
+
+const {
app_list,
assets_path,
apps_path,
@@ -26,7 +28,7 @@ let {
get_redis_subscriber
} = require("./utils");
-let argv = yargs
+const argv = yargs
.usage("Usage: node esbuild [options]")
.option("apps", {
type: "string",
@@ -98,9 +100,6 @@ if (WATCH_MODE) {
async function execute() {
console.time(TOTAL_BUILD_TIME);
- if (!FILES_TO_BUILD.length) {
- await clean_dist_folders(APPS);
- }
let results;
try {
@@ -231,12 +230,13 @@ function get_files_to_build(files) {
function build_files({ files, outdir }) {
let build_plugins = [
html_plugin,
+ build_cleanup_plugin,
vue(),
];
return esbuild.build(get_build_options(files, outdir, build_plugins));
}
-function build_style_files({ files, outdir, rtl_style=false }) {
+function build_style_files({ files, outdir, rtl_style = false }) {
let plugins = [];
if (rtl_style) {
plugins.push(rtlcss);
@@ -244,6 +244,7 @@ function build_style_files({ files, outdir, rtl_style=false }) {
let build_plugins = [
ignore_assets,
+ build_cleanup_plugin,
postCssPlugin({
plugins: plugins,
sassOptions: sass_options
@@ -313,24 +314,6 @@ function get_watch_config() {
return null;
}
-async function clean_dist_folders(apps) {
- for (let app of apps) {
- let public_path = get_public_path(app);
- let paths = [
- path.resolve(public_path, "dist", "js"),
- path.resolve(public_path, "dist", "css"),
- path.resolve(public_path, "dist", "css-rtl")
- ];
- for (let target of paths) {
- if (fs.existsSync(target)) {
- // rmdir is deprecated in node 16, this will work in both node 14 and 16
- let rmdir = fs.promises.rm || fs.promises.rmdir;
- await rmdir(target, { recursive: true });
- }
- }
- }
-}
-
function log_built_assets(results) {
let outputs = {};
for (const result of results) {
diff --git a/frappe/__init__.py b/frappe/__init__.py
index 43246a7fd6..08c0f794b3 100644
--- a/frappe/__init__.py
+++ b/frappe/__init__.py
@@ -28,7 +28,11 @@ from .exceptions import *
from .utils.jinja import (get_jenv, get_template, render_template, get_email_from_template, get_jloader)
from .utils.lazy_loader import lazy_import
-from frappe.query_builder import get_query_builder, patch_query_execute
+from frappe.query_builder import (
+ get_query_builder,
+ patch_query_execute,
+ patch_query_aggregation,
+)
__version__ = '14.0.0-dev'
@@ -41,7 +45,8 @@ class _dict(dict):
"""dict like object that exposes keys as attributes"""
def __getattr__(self, key):
ret = self.get(key)
- if not ret and key.startswith("__"):
+ # "__deepcopy__" exception added to fix frappe#14833 via DFP
+ if not ret and key.startswith("__") and key != "__deepcopy__":
raise AttributeError()
return ret
def __setattr__(self, key, value):
@@ -210,6 +215,7 @@ def init(site, sites_path=None, new_site=False):
setup_module_map()
patch_query_execute()
+ patch_query_aggregation()
local.initialised = True
@@ -734,17 +740,26 @@ def has_permission(doctype=None, ptype="read", doc=None, user=None, verbose=Fals
:param doc: [optional] Checks User permissions for given doc.
:param user: [optional] Check for given user. Default: current user.
:param parent_doctype: Required when checking permission for a child DocType (unless doc is specified)."""
+ import frappe.permissions
+
if not doctype and doc:
doctype = doc.doctype
- import frappe.permissions
out = frappe.permissions.has_permission(doctype, ptype, doc=doc, verbose=verbose, user=user,
raise_exception=throw, parent_doctype=parent_doctype)
+
if throw and not out:
- if doc:
- frappe.throw(_("No permission for {0}").format(doc.doctype + " " + doc.name))
- else:
- frappe.throw(_("No permission for {0}").format(doctype))
+ # mimics frappe.throw
+ document_label = f"{doc.doctype} {doc.name}" if doc else doctype
+ msgprint(
+ _("No permission for {0}").format(document_label),
+ raise_exception=ValidationError,
+ title=None,
+ indicator='red',
+ is_minimizable=None,
+ wide=None,
+ as_list=False
+ )
return out
@@ -789,7 +804,9 @@ def has_website_permission(doc=None, ptype='read', user=None, verbose=False, doc
def is_table(doctype):
"""Returns True if `istable` property (indicating child Table) is set for given DocType."""
def get_tables():
- return db.sql_list("select name from tabDocType where istable=1")
+ return db.get_values(
+ "DocType", filters={"istable": 1}, order_by=None, pluck=True
+ )
tables = cache().get_value("is_table", get_tables)
return doctype in tables
@@ -1195,7 +1212,7 @@ def read_file(path, raise_not_found=False):
def get_attr(method_string):
"""Get python method object from its name."""
app_name = method_string.split(".")[0]
- if not local.flags.in_install and app_name not in get_installed_apps():
+ if not local.flags.in_uninstall and not local.flags.in_install and app_name not in get_installed_apps():
throw(_("App {0} is not installed").format(app_name), AppNotInstalledError)
modulename = '.'.join(method_string.split('.')[:-1])
@@ -1522,8 +1539,8 @@ def format(*args, **kwargs):
import frappe.utils.formatters
return frappe.utils.formatters.format_value(*args, **kwargs)
-def get_print(doctype=None, name=None, print_format=None, style=None,
- html=None, as_pdf=False, doc=None, output=None, no_letterhead=0, password=None):
+def get_print(doctype=None, name=None, print_format=None, style=None, html=None,
+ as_pdf=False, doc=None, output=None, no_letterhead=0, password=None, pdf_options=None):
"""Get Print Format for given document.
:param doctype: DocType of document.
@@ -1542,15 +1559,15 @@ def get_print(doctype=None, name=None, print_format=None, style=None,
local.form_dict.doc = doc
local.form_dict.no_letterhead = no_letterhead
- options = None
+ pdf_options = pdf_options or {}
if password:
- options = {'password': password}
+ pdf_options['password'] = password
if not html:
html = get_response_content("printview")
if as_pdf:
- return get_pdf(html, output = output, options = options)
+ return get_pdf(html, options=pdf_options, output=output)
else:
return html
@@ -1797,7 +1814,7 @@ def get_version(doctype, name, limit=None, head=False, raise_err=True):
'limit': limit
}, as_list=1)
- from frappe.chat.util import squashify, dictify, safe_json_loads
+ from frappe.utils import squashify, dictify, safe_json_loads
versions = []
@@ -1855,7 +1872,7 @@ def mock(type, size=1, locale='en'):
data = getattr(fake, type)()
results.append(data)
- from frappe.chat.util import squashify
+ from frappe.utils import squashify
return squashify(results)
def validate_and_sanitize_search_inputs(fn):
diff --git a/frappe/app.py b/frappe/app.py
index 8e1534e7ef..d73dd67983 100644
--- a/frappe/app.py
+++ b/frappe/app.py
@@ -120,6 +120,8 @@ def init_request(request):
else:
frappe.connect(set_admin_as_user=False)
+ request.max_content_length = frappe.local.conf.get('max_file_size') or 10 * 1024 * 1024
+
make_form_dict(request)
if request.method != "OPTIONS":
@@ -183,7 +185,9 @@ def make_form_dict(request):
if 'application/json' in (request.content_type or '') and request_data:
args = json.loads(request_data)
else:
- args = request.form or request.args
+ args = {}
+ args.update(request.args or {})
+ args.update(request.form or {})
if not isinstance(args, dict):
frappe.throw(_("Invalid request arguments"))
diff --git a/frappe/auth.py b/frappe/auth.py
index 2c875c4437..078a6bb165 100644
--- a/frappe/auth.py
+++ b/frappe/auth.py
@@ -128,7 +128,6 @@ class LoginManager:
self.make_session()
self.set_user_info()
- @frappe.whitelist()
def login(self):
# clear cache
frappe.clear_cache(user = frappe.form_dict.get('usr'))
diff --git a/frappe/automation/doctype/assignment_rule/assignment_rule.py b/frappe/automation/doctype/assignment_rule/assignment_rule.py
index a3e27d4da5..a8c75bffd9 100644
--- a/frappe/automation/doctype/assignment_rule/assignment_rule.py
+++ b/frappe/automation/doctype/assignment_rule/assignment_rule.py
@@ -1,32 +1,47 @@
-# -*- coding: utf-8 -*-
-# Copyright (c) 2019, Frappe Technologies and contributors
+# Copyright (c) 2022, Frappe Technologies and contributors
# License: MIT. See LICENSE
+from typing import Dict, Iterable, List
+
import frappe
-from frappe.model.document import Document
-from frappe.desk.form import assign_to
-import frappe.cache_manager
from frappe import _
+from frappe.cache_manager import clear_doctype_map, get_doctype_map
+from frappe.desk.form import assign_to
from frappe.model import log_types
+from frappe.model.document import Document
+
class AssignmentRule(Document):
-
def validate(self):
+ self.validate_document_types()
+ self.validate_assignment_days()
+
+ def clear_cache(self):
+ super().clear_cache()
+ clear_doctype_map(self.doctype, self.document_type)
+ clear_doctype_map(self.doctype, f"due_date_rules_for_{self.document_type}")
+
+ def validate_document_types(self):
+ if self.document_type == "ToDo":
+ frappe.throw(
+ _('Assignment Rule is not allowed on {0} document type').format(
+ frappe.bold("ToDo")
+ )
+ )
+
+ def validate_assignment_days(self):
assignment_days = self.get_assignment_days()
- if not len(set(assignment_days)) == len(assignment_days):
+
+ if len(set(assignment_days)) != len(assignment_days):
repeated_days = get_repeated(assignment_days)
- frappe.throw(_("Assignment Day {0} has been repeated.").format(frappe.bold(repeated_days)))
- if self.document_type == 'ToDo':
- frappe.throw(_('Assignment Rule is not allowed on {0} document type').format(frappe.bold("ToDo")))
+ plural = "s" if len(repeated_days) > 1 else ""
- def on_update(self):
- clear_assignment_rule_cache(self)
-
- def after_rename(self, old, new, merge):
- clear_assignment_rule_cache(self)
-
- def on_trash(self):
- clear_assignment_rule_cache(self)
+ frappe.throw(
+ _("Assignment Day{0} {1} has been repeated.").format(
+ plural,
+ frappe.bold(", ".join(repeated_days))
+ )
+ )
def apply_unassign(self, doc, assignments):
if (self.unassign_condition and
@@ -35,7 +50,6 @@ class AssignmentRule(Document):
return False
-
def apply_assign(self, doc):
if self.safe_eval('assign_condition', doc):
return self.do_assignment(doc)
@@ -109,7 +123,7 @@ class AssignmentRule(Document):
user = d.user,
count = frappe.db.count('ToDo', dict(
reference_type = self.document_type,
- owner = d.user,
+ allocated_to = d.user,
status = "Open"))
))
@@ -141,65 +155,68 @@ class AssignmentRule(Document):
def is_rule_not_applicable_today(self):
today = frappe.flags.assignment_day or frappe.utils.get_weekday()
assignment_days = self.get_assignment_days()
- if assignment_days and not today in assignment_days:
- return True
+ return assignment_days and today not in assignment_days
- return False
-def get_assignments(doc):
+def get_assignments(doc) -> List[Dict]:
return frappe.get_all('ToDo', fields = ['name', 'assignment_rule'], filters = dict(
reference_type = doc.get('doctype'),
reference_name = doc.get('name'),
status = ('!=', 'Cancelled')
- ), limit = 5)
+ ), limit=5)
+
@frappe.whitelist()
def bulk_apply(doctype, docnames):
- import json
- docnames = json.loads(docnames)
-
+ docnames = frappe.parse_json(docnames)
background = len(docnames) > 5
+
for name in docnames:
if background:
frappe.enqueue('frappe.automation.doctype.assignment_rule.assignment_rule.apply', doc=None, doctype=doctype, name=name)
else:
- apply(None, doctype=doctype, name=name)
+ apply(doctype=doctype, name=name)
+
def reopen_closed_assignment(doc):
- todo_list = frappe.db.get_all('ToDo', filters = dict(
- reference_type = doc.doctype,
- reference_name = doc.name,
- status = 'Closed'
- ))
- if not todo_list:
- return False
+ todo_list = frappe.get_all("ToDo", filters={
+ "reference_type": doc.doctype,
+ "reference_name": doc.name,
+ "status": "Closed",
+ }, pluck="name")
+
for todo in todo_list:
- todo_doc = frappe.get_doc('ToDo', todo.name)
+ todo_doc = frappe.get_doc('ToDo', todo)
todo_doc.status = 'Open'
todo_doc.save(ignore_permissions=True)
- return True
-def apply(doc, method=None, doctype=None, name=None):
- if not doctype:
- doctype = doc.doctype
+ return bool(todo_list)
- if (frappe.flags.in_patch
+
+def apply(doc=None, method=None, doctype=None, name=None):
+ doctype = doctype or doc.doctype
+
+ skip_assignment_rules = (
+ frappe.flags.in_patch
or frappe.flags.in_install
or frappe.flags.in_setup_wizard
- or doctype in log_types):
+ or doctype in log_types
+ )
+
+ if skip_assignment_rules:
return
if not doc and doctype and name:
doc = frappe.get_doc(doctype, name)
- assignment_rules = frappe.cache_manager.get_doctype_map('Assignment Rule', doc.doctype, dict(
- document_type = doc.doctype, disabled = 0), order_by = 'priority desc')
-
- assignment_rule_docs = []
+ assignment_rules = get_doctype_map("Assignment Rule", doc.doctype, filters={
+ "document_type": doc.doctype, "disabled": 0
+ }, order_by="priority desc")
# multiple auto assigns
- for d in assignment_rules:
- assignment_rule_docs.append(frappe.get_cached_doc('Assignment Rule', d.get('name')))
+ assignment_rule_docs: List[AssignmentRule] = [
+ frappe.get_cached_doc("Assignment Rule", d.get('name')) for d in assignment_rules
+ ]
if not assignment_rule_docs:
return
@@ -235,6 +252,7 @@ def apply(doc, method=None, doctype=None, name=None):
# apply close rule only if assignments exists
assignments = get_assignments(doc)
+
if assignments:
for assignment_rule in assignment_rule_docs:
if assignment_rule.is_rule_not_applicable_today():
@@ -242,38 +260,74 @@ def apply(doc, method=None, doctype=None, name=None):
if not new_apply:
# only reopen if close condition is not satisfied
- if not assignment_rule.safe_eval('close_condition', doc):
- reopen = reopen_closed_assignment(doc)
- if reopen:
+ to_close_todos = assignment_rule.safe_eval('close_condition', doc)
+
+ if to_close_todos:
+ # close todo status
+ todos_to_close = frappe.get_all("ToDo", filters={
+ "reference_type": doc.doctype,
+ "reference_name": doc.name,
+ }, pluck="name")
+
+ for todo in todos_to_close:
+ _todo = frappe.get_doc("ToDo", todo)
+ _todo.status = "Closed"
+ _todo.save()
+ break
+
+ else:
+ reopened = reopen_closed_assignment(doc)
+ if reopened:
break
+
+ # print(f"Rule:{assignment_rule}\nDoc: {doc}\nReOpened: {reopened}")
+
assignment_rule.close_assignments(doc)
+
def update_due_date(doc, state=None):
- # called from hook
- if (frappe.flags.in_patch
- or frappe.flags.in_install
- or frappe.flags.in_migrate
+ """Run on_update on every Document (via hooks.py)
+ """
+ skip_document_update = (
+ frappe.flags.in_migrate
+ or frappe.flags.in_patch
or frappe.flags.in_import
- or frappe.flags.in_setup_wizard):
+ or frappe.flags.in_setup_wizard
+ or frappe.flags.in_install
+ )
+
+ if skip_document_update:
return
- assignment_rules = frappe.cache_manager.get_doctype_map('Assignment Rule', 'due_date_rules_for_' + doc.doctype, dict(
- document_type = doc.doctype,
- disabled = 0,
- due_date_based_on = ['is', 'set']
- ))
+
+ assignment_rules = get_doctype_map(
+ doctype="Assignment Rule",
+ name=f"due_date_rules_for_{doc.doctype}",
+ filters={
+ "due_date_based_on": ["is", "set"],
+ "document_type": doc.doctype,
+ "disabled": 0,
+ }
+ )
+
for rule in assignment_rules:
- rule_doc = frappe.get_cached_doc('Assignment Rule', rule.get('name'))
+ rule_doc = frappe.get_cached_doc("Assignment Rule", rule.get("name"))
due_date_field = rule_doc.due_date_based_on
- if doc.meta.has_field(due_date_field) and \
- doc.has_value_changed(due_date_field) and rule.get('name'):
- assignment_todos = frappe.get_all('ToDo', {
- 'assignment_rule': rule.get('name'),
- 'status': 'Open',
- 'reference_type': doc.doctype,
- 'reference_name': doc.name
- })
+ field_updated = (
+ doc.meta.has_field(due_date_field)
+ and doc.has_value_changed(due_date_field)
+ and rule.get("name")
+ )
+
+ if field_updated:
+ assignment_todos = frappe.get_all("ToDo", filters={
+ "assignment_rule": rule.get("name"),
+ "reference_type": doc.doctype,
+ "reference_name": doc.name,
+ "status": "Open",
+ }, pluck="name")
+
for todo in assignment_todos:
- todo_doc = frappe.get_doc('ToDo', todo.name)
+ todo_doc = frappe.get_doc('ToDo', todo)
todo_doc.date = doc.get(due_date_field)
todo_doc.flags.updater_reference = {
'doctype': 'Assignment Rule',
@@ -282,20 +336,19 @@ def update_due_date(doc, state=None):
}
todo_doc.save(ignore_permissions=True)
-def get_assignment_rules():
- return [d.document_type for d in frappe.db.get_all('Assignment Rule', fields=['document_type'], filters=dict(disabled = 0))]
-def get_repeated(values):
- unique_list = []
- diff = []
+def get_assignment_rules() -> List[str]:
+ return frappe.get_all("Assignment Rule", filters={"disabled": 0}, pluck="document_type")
+
+
+def get_repeated(values: Iterable) -> List:
+ unique = set()
+ repeated = set()
+
for value in values:
- if value not in unique_list:
- unique_list.append(str(value))
+ if value in unique:
+ repeated.add(value)
else:
- if value not in diff:
- diff.append(str(value))
- return " ".join(diff)
+ unique.add(value)
-def clear_assignment_rule_cache(rule):
- frappe.cache_manager.clear_doctype_map('Assignment Rule', rule.document_type)
- frappe.cache_manager.clear_doctype_map('Assignment Rule', 'due_date_rules_for_' + rule.document_type)
+ return [str(x) for x in repeated]
diff --git a/frappe/automation/doctype/assignment_rule/test_assignment_rule.py b/frappe/automation/doctype/assignment_rule/test_assignment_rule.py
index 1c9e177f94..63dbf69d3b 100644
--- a/frappe/automation/doctype/assignment_rule/test_assignment_rule.py
+++ b/frappe/automation/doctype/assignment_rule/test_assignment_rule.py
@@ -1,12 +1,22 @@
-# -*- coding: utf-8 -*-
-# Copyright (c) 2019, Frappe Technologies and Contributors
+# Copyright (c) 2021, Frappe Technologies and Contributors
# License: MIT. See LICENSE
-import frappe
+
import unittest
-from frappe.utils import random_string
+
+import frappe
from frappe.test_runner import make_test_records
+from frappe.utils import random_string
+
class TestAutoAssign(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ frappe.db.delete("Assignment Rule")
+
+ @classmethod
+ def tearDownClass(cls):
+ frappe.db.rollback()
+
def setUp(self):
make_test_records("User")
days = [
@@ -30,7 +40,7 @@ class TestAutoAssign(unittest.TestCase):
reference_type = 'Note',
reference_name = note.name,
status = 'Open'
- ), 'owner'), 'test@example.com')
+ ), 'allocated_to'), 'test@example.com')
note = make_note(dict(public=1))
@@ -39,7 +49,7 @@ class TestAutoAssign(unittest.TestCase):
reference_type = 'Note',
reference_name = note.name,
status = 'Open'
- ), 'owner'), 'test1@example.com')
+ ), 'allocated_to'), 'test1@example.com')
clear_assignments()
@@ -51,7 +61,7 @@ class TestAutoAssign(unittest.TestCase):
reference_type = 'Note',
reference_name = note.name,
status = 'Open'
- ), 'owner'), 'test2@example.com')
+ ), 'allocated_to'), 'test2@example.com')
# check loop back to first user
note = make_note(dict(public=1))
@@ -60,7 +70,7 @@ class TestAutoAssign(unittest.TestCase):
reference_type = 'Note',
reference_name = note.name,
status = 'Open'
- ), 'owner'), 'test@example.com')
+ ), 'allocated_to'), 'test@example.com')
def test_load_balancing(self):
self.assignment_rule.rule = 'Load Balancing'
@@ -71,11 +81,11 @@ class TestAutoAssign(unittest.TestCase):
# check if each user has 10 assignments (?)
for user in ('test@example.com', 'test1@example.com', 'test2@example.com'):
- self.assertEqual(len(frappe.get_all('ToDo', dict(owner = user, reference_type = 'Note'))), 10)
+ self.assertEqual(len(frappe.get_all('ToDo', dict(allocated_to = user, reference_type = 'Note'))), 10)
# clear 5 assignments for first user
# can't do a limit in "delete" since postgres does not support it
- for d in frappe.get_all('ToDo', dict(reference_type = 'Note', owner = 'test@example.com'), limit=5):
+ for d in frappe.get_all('ToDo', dict(reference_type = 'Note', allocated_to = 'test@example.com'), limit=5):
frappe.db.delete("ToDo", {"name": d.name})
# add 5 more assignments
@@ -84,7 +94,7 @@ class TestAutoAssign(unittest.TestCase):
# check if each user still has 10 assignments
for user in ('test@example.com', 'test1@example.com', 'test2@example.com'):
- self.assertEqual(len(frappe.get_all('ToDo', dict(owner = user, reference_type = 'Note'))), 10)
+ self.assertEqual(len(frappe.get_all('ToDo', dict(allocated_to = user, reference_type = 'Note'))), 10)
def test_based_on_field(self):
self.assignment_rule.rule = 'Based on Field'
@@ -119,7 +129,7 @@ class TestAutoAssign(unittest.TestCase):
reference_type = 'Note',
reference_name = note.name,
status = 'Open'
- ), 'owner'), None)
+ ), 'allocated_to'), None)
def test_clear_assignment(self):
note = make_note(dict(public=1))
@@ -129,10 +139,10 @@ class TestAutoAssign(unittest.TestCase):
reference_type = 'Note',
reference_name = note.name,
status = 'Open'
- ))[0]
+ ), limit=1)[0]
todo = frappe.get_doc('ToDo', todo['name'])
- self.assertEqual(todo.owner, 'test@example.com')
+ self.assertEqual(todo.allocated_to, 'test@example.com')
# test auto unassign
note.public = 0
@@ -151,10 +161,10 @@ class TestAutoAssign(unittest.TestCase):
reference_type = 'Note',
reference_name = note.name,
status = 'Open'
- ))[0]
+ ), limit=1)[0]
todo = frappe.get_doc('ToDo', todo['name'])
- self.assertEqual(todo.owner, 'test@example.com')
+ self.assertEqual(todo.allocated_to, 'test@example.com')
note.content="Closed"
note.save()
@@ -164,7 +174,7 @@ class TestAutoAssign(unittest.TestCase):
# check if todo is closed
self.assertEqual(todo.status, 'Closed')
# check if closed todo retained assignment
- self.assertEqual(todo.owner, 'test@example.com')
+ self.assertEqual(todo.allocated_to, 'test@example.com')
def check_multiple_rules(self):
note = make_note(dict(public=1, notify_on_login=1))
@@ -174,7 +184,7 @@ class TestAutoAssign(unittest.TestCase):
reference_type = 'Note',
reference_name = note.name,
status = 'Open'
- ), 'owner'), 'test@example.com')
+ ), 'allocated_to'), 'test@example.com')
def check_assignment_rule_scheduling(self):
frappe.db.delete("Assignment Rule")
@@ -192,7 +202,7 @@ class TestAutoAssign(unittest.TestCase):
reference_type = 'Note',
reference_name = note.name,
status = 'Open'
- ), 'owner'), ['test@example.com', 'test1@example.com', 'test2@example.com'])
+ ), 'allocated_to'), ['test@example.com', 'test1@example.com', 'test2@example.com'])
frappe.flags.assignment_day = "Friday"
note = make_note(dict(public=1))
@@ -201,7 +211,7 @@ class TestAutoAssign(unittest.TestCase):
reference_type = 'Note',
reference_name = note.name,
status = 'Open'
- ), 'owner'), ['test3@example.com'])
+ ), 'allocated_to'), ['test3@example.com'])
def test_assignment_rule_condition(self):
frappe.db.delete("Assignment Rule")
diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.py b/frappe/automation/doctype/auto_repeat/auto_repeat.py
index 5ab6c86c00..0277b8e402 100644
--- a/frappe/automation/doctype/auto_repeat/auto_repeat.py
+++ b/frappe/automation/doctype/auto_repeat/auto_repeat.py
@@ -96,7 +96,15 @@ class AutoRepeat(Document):
auto_repeat_days = self.get_auto_repeat_days()
if not len(set(auto_repeat_days)) == len(auto_repeat_days):
repeated_days = get_repeated(auto_repeat_days)
- frappe.throw(_('Auto Repeat Day {0} has been repeated.').format(frappe.bold(repeated_days)))
+ plural = "s" if len(repeated_days) > 1 else ""
+
+ frappe.throw(
+ _("Auto Repeat Day{0} {1} has been repeated.").format(
+ plural,
+ frappe.bold(", ".join(repeated_days))
+ )
+ )
+
def update_auto_repeat_id(self):
#check if document is already on auto repeat
diff --git a/frappe/boot.py b/frappe/boot.py
index 4b764dabfc..723e80313d 100644
--- a/frappe/boot.py
+++ b/frappe/boot.py
@@ -18,6 +18,7 @@ from frappe.model.base_document import get_controller
from frappe.social.doctype.post.post import frequently_visited_links
from frappe.core.doctype.navbar_settings.navbar_settings import get_navbar_settings, get_app_logo
from frappe.geo.country_info import get_all
+from frappe.utils import get_time_zone
def get_bootinfo():
"""build and return boot info"""
@@ -60,6 +61,7 @@ def get_bootinfo():
bootinfo.navbar_settings = get_navbar_settings()
bootinfo.notification_settings = get_notification_settings()
get_country_codes(bootinfo)
+ set_time_zone(bootinfo)
# ipinfo
if frappe.session.data.get('ipinfo'):
@@ -222,8 +224,8 @@ def load_translations(bootinfo):
bootinfo["__messages"] = messages
def get_user_info():
- user_info = frappe.db.get_all('User', fields=['`name`', 'full_name as fullname', 'user_image as image',
- 'gender', 'email', 'username', 'bio', 'location', 'interest', 'banner_image', 'allowed_in_mentions', 'user_type'],
+ user_info = frappe.db.get_all('User', fields=['`name`', 'full_name as fullname', 'user_image as image', 'gender',
+ 'email', 'username', 'bio', 'location', 'interest', 'banner_image', 'allowed_in_mentions', 'user_type', 'time_zone'],
filters=dict(enabled=1))
user_info_map = {d.name: d for d in user_info}
@@ -330,3 +332,9 @@ def get_notification_settings():
def get_country_codes(bootinfo):
country_codes = get_all()
bootinfo.country_codes = frappe._dict(country_codes)
+
+def set_time_zone(bootinfo):
+ bootinfo.time_zone = {
+ "system": get_time_zone(),
+ "user": bootinfo.get("user_info", {}).get(frappe.session.user, {}).get("time_zone", None) or get_time_zone()
+ }
diff --git a/frappe/chat/__init__.py b/frappe/chat/__init__.py
deleted file mode 100644
index 4c9b1c5db7..0000000000
--- a/frappe/chat/__init__.py
+++ /dev/null
@@ -1,23 +0,0 @@
-
-import frappe
-from frappe import _
-
-session = frappe.session
-
-def authenticate(user, raise_err = True):
- if session.user == 'Guest':
- if not frappe.db.exists('Chat Token', user):
- if raise_err:
- frappe.throw(_("Sorry, you're not authorized."))
- else:
- return False
- else:
- return True
- else:
- if user != session.user:
- if raise_err:
- frappe.throw(_("Sorry, you're not authorized."))
- else:
- return False
- else:
- return True
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_message/chat_message.js b/frappe/chat/doctype/chat_message/chat_message.js
deleted file mode 100644
index edaad011db..0000000000
--- a/frappe/chat/doctype/chat_message/chat_message.js
+++ /dev/null
@@ -1,10 +0,0 @@
-// Copyright (c) 2017, Frappe Technologies and contributors
-// For license information, please see license.txt
-
-frappe.ui.form.on('Chat Message', {
- onload: function(frm) {
- if(frm.doc.type == 'File') {
- frm.set_df_property('content', 'read_only', 1);
- }
- }
-});
diff --git a/frappe/chat/doctype/chat_message/chat_message.json b/frappe/chat/doctype/chat_message/chat_message.json
deleted file mode 100644
index 9d2d70c5e0..0000000000
--- a/frappe/chat/doctype/chat_message/chat_message.json
+++ /dev/null
@@ -1,91 +0,0 @@
-{
- "beta": 1,
- "creation": "2017-11-10 11:10:40.011099",
- "doctype": "DocType",
- "editable_grid": 1,
- "engine": "InnoDB",
- "field_order": [
- "room_type",
- "type",
- "user",
- "room",
- "content",
- "mentions",
- "urls"
- ],
- "fields": [
- {
- "fieldname": "room_type",
- "fieldtype": "Select",
- "in_list_view": 1,
- "label": "Room Type",
- "options": "Direct\nGroup\nVisitor",
- "reqd": 1
- },
- {
- "fieldname": "type",
- "fieldtype": "Data",
- "label": "Type",
- "options": "Content\nFile"
- },
- {
- "fieldname": "user",
- "fieldtype": "Link",
- "hidden": 1,
- "label": "User",
- "options": "User",
- "read_only": 1
- },
- {
- "fieldname": "room",
- "fieldtype": "Link",
- "label": "Room",
- "options": "Chat Room",
- "reqd": 1
- },
- {
- "fieldname": "content",
- "fieldtype": "Text",
- "label": "Content",
- "reqd": 1
- },
- {
- "fieldname": "mentions",
- "fieldtype": "Code",
- "hidden": 1,
- "label": "Mentions"
- },
- {
- "fieldname": "urls",
- "fieldtype": "Data",
- "hidden": 1,
- "label": "URLs"
- }
- ],
- "modified": "2020-09-18 17:26:09.703215",
- "modified_by": "Administrator",
- "module": "Chat",
- "name": "Chat Message",
- "owner": "Administrator",
- "permissions": [
- {
- "create": 1,
- "delete": 1,
- "email": 1,
- "export": 1,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "System Manager",
- "share": 1,
- "write": 1
- }
- ],
- "quick_entry": 1,
- "search_fields": "content, user",
- "sort_field": "modified",
- "sort_order": "DESC",
- "title_field": "content",
- "track_changes": 1,
- "track_seen": 1
-}
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_message/chat_message.py b/frappe/chat/doctype/chat_message/chat_message.py
deleted file mode 100644
index bc470a5e9c..0000000000
--- a/frappe/chat/doctype/chat_message/chat_message.py
+++ /dev/null
@@ -1,215 +0,0 @@
-# imports - standard imports
-import json
-
-# imports - third-party imports
-import requests
-from bs4 import BeautifulSoup as Soup
-
-# imports - module imports
-from frappe.model.document import Document
-from frappe import _, _dict
-import frappe
-
-# imports - frappe module imports
-from frappe.chat import authenticate
-from frappe.chat.util import (
- get_if_empty,
- check_url,
- dictify,
- get_emojis,
- safe_json_loads,
- get_user_doc,
- squashify
-)
-
-session = frappe.session
-
-class ChatMessage(Document):
- pass
-
-def get_message_urls(content):
- soup = Soup(content, 'html.parser')
- anchors = soup.find_all('a')
- urls = [ ]
-
- for anchor in anchors:
- text = anchor.text
-
- if check_url(text):
- urls.append(text)
-
- return urls
-
-def get_message_mentions(content):
- mentions = [ ]
- tokens = content.split(' ')
-
- for token in tokens:
- if token.startswith('@'):
- what = token[1:]
- if frappe.db.exists('User', what):
- mentions.append(what)
- else:
- if frappe.db.exists('User', token):
- mentions.append(token)
-
- return mentions
-
-def get_message_meta(content):
- '''
- Assumes content to be HTML. Sanitizes the content
- into a dict of metadata values.
- '''
- meta = _dict(
- links = [ ],
- mentions = [ ]
- )
-
- meta.content = content
- meta.urls = get_message_urls(content)
- meta.mentions = get_message_mentions(content)
-
- return meta
-
-def sanitize_message_content(content):
- emojis = get_emojis()
-
- tokens = content.split(' ')
- for token in tokens:
- if token.startswith(':') and token.endswith(':'):
- what = token[1:-1]
-
- # Expensive, I know.
- for emoji in emojis:
- for alias in emoji.aliases:
- if what == alias:
- content = content.replace(token, emoji.emoji)
-
- return content
-
-def get_new_chat_message_doc(user, room, content, type = "Content", link = True):
- user = get_user_doc(user)
- room = frappe.get_doc('Chat Room', room)
-
- meta = get_message_meta(content)
- mess = frappe.new_doc('Chat Message')
- mess.room = room.name
- mess.room_type = room.type
- mess.content = sanitize_message_content(content)
- mess.type = type
- mess.user = user.name
-
- mess.mentions = json.dumps(meta.mentions)
- mess.urls = ','.join(meta.urls)
- mess.save(ignore_permissions = True)
-
- if link:
- room.update(dict(
- last_message = mess.name
- ))
- room.save(ignore_permissions = True)
-
- return mess
-
-def get_new_chat_message(user, room, content, type = "Content"):
- mess = get_new_chat_message_doc(user, room, content, type)
-
- resp = dict(
- name = mess.name,
- user = mess.user,
- room = mess.room,
- room_type = mess.room_type,
- content = json.loads(mess.content) if mess.type in ["File"] else mess.content,
- urls = mess.urls,
- mentions = json.loads(mess.mentions),
- creation = mess.creation,
- seen = json.loads(mess._seen) if mess._seen else [ ],
- )
-
- return resp
-
-@frappe.whitelist(allow_guest = True)
-def send(user, room, content, type = "Content"):
- mess = get_new_chat_message(user, room, content, type)
-
- frappe.publish_realtime('frappe.chat.message:create', mess, room = room,
- after_commit = True)
-
-@frappe.whitelist(allow_guest = True)
-def seen(message, user = None):
- authenticate(user)
-
- has_message = frappe.db.exists('Chat Message', message)
-
- if has_message:
- mess = frappe.get_doc('Chat Message', message)
- mess.add_seen(user)
- mess.load_from_db()
- room = mess.room
- resp = dict(message = message, data = dict(seen = json.loads(mess._seen) if mess._seen else []))
-
- frappe.publish_realtime('frappe.chat.message:update', resp, room = room, after_commit = True)
-
-def history(room, fields = None, limit = 10, start = None, end = None):
- room = frappe.get_doc('Chat Room', room)
- mess = frappe.get_all('Chat Message',
- filters = [
- ('Chat Message', 'room', '=', room.name),
- ('Chat Message', 'room_type', '=', room.type)
- ],
- fields = fields if fields else [
- 'name', 'room_type', 'room', 'content', 'type', 'user', 'mentions', 'urls', 'creation', '_seen'
- ],
- order_by = 'creation'
- )
-
- if not fields or 'seen' in fields:
- for m in mess:
- m['seen'] = json.loads(m._seen) if m._seen else [ ]
- del m['_seen']
- if not fields or 'content' in fields:
- for m in mess:
- m['content'] = json.loads(m.content) if m.type in ["File"] else m.content
-
- frappe.enqueue('frappe.chat.doctype.chat_message.chat_message.mark_messages_as_seen',
- message_names=[m.name for m in mess], user=frappe.session.user)
-
- return mess
-
-def mark_messages_as_seen(message_names, user):
- '''
- Marks chat messages as seen, updates the _seen for each message
- (should be run in background process)
- '''
- for name in message_names:
- seen = frappe.db.get_value('Chat Message', name, '_seen') or '[]'
- seen = json.loads(seen)
- seen.append(user)
- seen = json.dumps(seen)
- frappe.db.set_value('Chat Message', name, '_seen', seen, update_modified=False)
-
- frappe.db.commit()
-
-
-@frappe.whitelist()
-def get(name, rooms = None, fields = None):
- rooms, fields = safe_json_loads(rooms, fields)
-
- has_message = frappe.db.exists('Chat Message', name)
-
- if has_message:
- dmess = frappe.get_doc('Chat Message', name)
- data = dict(
- name = dmess.name,
- user = dmess.user,
- room = dmess.room,
- room_type = dmess.room_type,
- content = json.loads(dmess.content) if dmess.type in ["File"] else dmess.content,
- type = dmess.type,
- urls = dmess.urls,
- mentions = dmess.mentions,
- creation = dmess.creation,
- seen = get_if_empty(dmess._seen, [ ])
- )
-
- return data
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_message/chat_message_list.js b/frappe/chat/doctype/chat_message/chat_message_list.js
deleted file mode 100644
index c5b717048b..0000000000
--- a/frappe/chat/doctype/chat_message/chat_message_list.js
+++ /dev/null
@@ -1,8 +0,0 @@
-frappe.listview_settings['Chat Message'] = {
- filters: [
- ['Chat Message', 'user', '==', frappe.session.user, true]
- // I need an or_filter here.
- // ['Chat Room', 'owner', '==', frappe.session.user, true],
- // ['Chat Room', frappe.session.user, 'in', 'users', true]
- ]
-};
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_profile/chat_profile.js b/frappe/chat/doctype/chat_profile/chat_profile.js
deleted file mode 100644
index b27a98faf5..0000000000
--- a/frappe/chat/doctype/chat_profile/chat_profile.js
+++ /dev/null
@@ -1,10 +0,0 @@
-/* eslint semi: "never" */
-frappe.ui.form.on('Chat Profile', {
- refresh: function (form) {
- if ( form.doc.name !== frappe.session.user ) {
- form.disable_save()
- form.set_read_only(true)
- // There's one more that faris@frappe.io told me to add here. form.refresh_fields()?
- }
- }
-});
diff --git a/frappe/chat/doctype/chat_profile/chat_profile.json b/frappe/chat/doctype/chat_profile/chat_profile.json
deleted file mode 100644
index eb36f803fe..0000000000
--- a/frappe/chat/doctype/chat_profile/chat_profile.json
+++ /dev/null
@@ -1,98 +0,0 @@
-{
- "autoname": "field:user",
- "beta": 1,
- "creation": "2017-11-13 18:26:57.943027",
- "doctype": "DocType",
- "editable_grid": 1,
- "engine": "InnoDB",
- "field_order": [
- "user",
- "status",
- "chat_background",
- "notifications",
- "message_preview",
- "notification_tones",
- "conversation_tones",
- "settings",
- "enable_chat"
- ],
- "fields": [
- {
- "fieldname": "user",
- "fieldtype": "Link",
- "label": "User",
- "options": "User",
- "reqd": 1
- },
- {
- "default": "Online",
- "fieldname": "status",
- "fieldtype": "Select",
- "in_list_view": 1,
- "label": "Status",
- "options": "Online\nAway\nBusy\nOffline"
- },
- {
- "fieldname": "chat_background",
- "fieldtype": "Attach Image",
- "label": "Chat Background"
- },
- {
- "fieldname": "notifications",
- "fieldtype": "Section Break",
- "label": "Notifications"
- },
- {
- "default": "1",
- "fieldname": "message_preview",
- "fieldtype": "Check",
- "label": "Message Preview"
- },
- {
- "default": "1",
- "fieldname": "notification_tones",
- "fieldtype": "Check",
- "label": "Notification Tones"
- },
- {
- "default": "1",
- "fieldname": "conversation_tones",
- "fieldtype": "Check",
- "label": "Conversation Tones"
- },
- {
- "fieldname": "settings",
- "fieldtype": "Section Break",
- "label": "Settings"
- },
- {
- "default": "1",
- "fieldname": "enable_chat",
- "fieldtype": "Check",
- "label": "Enable Chat"
- }
- ],
- "in_create": 1,
- "modified": "2019-11-07 13:21:36.414961",
- "modified_by": "Administrator",
- "module": "Chat",
- "name": "Chat Profile",
- "owner": "Administrator",
- "permissions": [
- {
- "create": 1,
- "delete": 1,
- "email": 1,
- "export": 1,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "System Manager",
- "share": 1,
- "write": 1
- }
- ],
- "sort_field": "modified",
- "sort_order": "DESC",
- "track_changes": 1
-}
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_profile/chat_profile.py b/frappe/chat/doctype/chat_profile/chat_profile.py
deleted file mode 100644
index da10a836c4..0000000000
--- a/frappe/chat/doctype/chat_profile/chat_profile.py
+++ /dev/null
@@ -1,98 +0,0 @@
-# imports - module imports
-from frappe.model.document import Document
-from frappe import _
-import frappe
-
-# imports - frappe module imports
-from frappe.core.doctype.version.version import get_diff
-from frappe.chat.doctype.chat_room import chat_room
-from frappe.chat.util import (
- safe_json_loads,
- filter_dict,
- dictify
-)
-
-session = frappe.session
-
-class ChatProfile(Document):
- def on_update(self):
- if not self.is_new():
- b, a = self.get_doc_before_save(), self
- diff = dictify(get_diff(a, b))
- if diff:
- user = session.user
-
- fields = [changed[0] for changed in diff.changed]
-
- if 'status' in fields:
- rooms = chat_room.get(user, filters = ['Chat Room', 'type', '=', 'Direct'])
- update = dict(user = user, data = dict(status = self.status))
-
- for room in rooms:
- frappe.publish_realtime('frappe.chat.profile:update', update, room = room.name, after_commit = True)
-
- if 'enable_chat' in fields:
- update = dict(user = user, data = dict(enable_chat = bool(self.enable_chat)))
- frappe.publish_realtime('frappe.chat.profile:update', update, user = user, after_commit = True)
-
-def authenticate(user):
- if user != session.user:
- frappe.throw(_("Sorry, you're not authorized."))
-
-@frappe.whitelist()
-def get(user, fields = None):
- duser = frappe.get_doc('User', user)
-
- if frappe.db.exists('Chat Profile', user):
- dprof = frappe.get_doc('Chat Profile', user)
-
- # If you're adding something here, make sure the client recieves it.
- profile = dict(
- # User
- name = duser.name,
- email = duser.email,
- first_name = duser.first_name,
- last_name = duser.last_name,
- username = duser.username,
- avatar = duser.user_image,
- bio = duser.bio,
- # Chat Profile
- status = dprof.status,
- chat_background = dprof.chat_background,
- message_preview = bool(dprof.message_preview),
- notification_tones = bool(dprof.notification_tones),
- conversation_tones = bool(dprof.conversation_tones),
- enable_chat = bool(dprof.enable_chat)
- )
- profile = filter_dict(profile, fields)
-
- return dictify(profile)
-
-@frappe.whitelist()
-def create(user, exists_ok = False, fields = None):
- authenticate(user)
-
- exists_ok, fields = safe_json_loads(exists_ok, fields)
-
- try:
- dprof = frappe.new_doc('Chat Profile')
- dprof.user = user
- dprof.save(ignore_permissions = True)
- except frappe.DuplicateEntryError:
- frappe.clear_messages()
- if not exists_ok:
- frappe.throw(_('Chat Profile for User {0} exists.').format(user))
-
- profile = get(user, fields = fields)
-
- return profile
-
-@frappe.whitelist()
-def update(user, data):
- authenticate(user)
-
- data = safe_json_loads(data)
-
- dprof = frappe.get_doc('Chat Profile', user)
- dprof.update(data)
- dprof.save(ignore_permissions = True)
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_profile/chat_profile_list.js b/frappe/chat/doctype/chat_profile/chat_profile_list.js
deleted file mode 100644
index 4d97b75e65..0000000000
--- a/frappe/chat/doctype/chat_profile/chat_profile_list.js
+++ /dev/null
@@ -1,11 +0,0 @@
-frappe.listview_settings['Chat Profile'] =
-{
- get_indicator: function (doc)
- {
- const status = frappe.utils.squash(frappe.chat.profile.STATUSES.filter(
- s => s.name === doc.status
- ));
-
- return [__(status.name), status.color, `status,=,${status.name}`]
- }
-};
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_room/__init__.py b/frappe/chat/doctype/chat_room/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/frappe/chat/doctype/chat_room/chat_room.js b/frappe/chat/doctype/chat_room/chat_room.js
deleted file mode 100644
index 00b9c8d8f7..0000000000
--- a/frappe/chat/doctype/chat_room/chat_room.js
+++ /dev/null
@@ -1,8 +0,0 @@
-// Copyright (c) 2017, Frappe Technologies and contributors
-// For license information, please see license.txt
-
-frappe.ui.form.on('Chat Room', {
- refresh: function (form) {
-
- }
-});
diff --git a/frappe/chat/doctype/chat_room/chat_room.json b/frappe/chat/doctype/chat_room/chat_room.json
deleted file mode 100644
index 1417306c45..0000000000
--- a/frappe/chat/doctype/chat_room/chat_room.json
+++ /dev/null
@@ -1,100 +0,0 @@
-{
- "autoname": "CR.#####",
- "beta": 1,
- "creation": "2017-11-08 15:27:21.156667",
- "doctype": "DocType",
- "editable_grid": 1,
- "engine": "InnoDB",
- "field_order": [
- "type",
- "room_name",
- "avatar",
- "last_message",
- "message_count",
- "owner",
- "user_list",
- "users"
- ],
- "fields": [
- {
- "default": "Direct",
- "fieldname": "type",
- "fieldtype": "Select",
- "in_list_view": 1,
- "label": "Type",
- "options": "Direct\nGroup\nVisitor",
- "reqd": 1,
- "set_only_once": 1
- },
- {
- "depends_on": "eval:doc.type==\"Group\"",
- "fieldname": "room_name",
- "fieldtype": "Data",
- "label": "Name"
- },
- {
- "depends_on": "eval:doc.type==\"Group\"",
- "fieldname": "avatar",
- "fieldtype": "Attach Image",
- "hidden": 1,
- "label": "Avatar"
- },
- {
- "fieldname": "last_message",
- "fieldtype": "Data",
- "hidden": 1,
- "label": "Last Message"
- },
- {
- "fieldname": "message_count",
- "fieldtype": "Int",
- "hidden": 1,
- "label": "Message Count"
- },
- {
- "fieldname": "owner",
- "fieldtype": "Data",
- "hidden": 1,
- "label": "Owner",
- "read_only": 1
- },
- {
- "fieldname": "user_list",
- "fieldtype": "Section Break",
- "label": "Users"
- },
- {
- "fieldname": "users",
- "fieldtype": "Table",
- "label": "Users",
- "options": "Chat Room User"
- }
- ],
- "image_field": "avatar",
- "modified": "2019-11-07 13:20:24.625329",
- "modified_by": "Administrator",
- "module": "Chat",
- "name": "Chat Room",
- "owner": "Administrator",
- "permissions": [
- {
- "create": 1,
- "delete": 1,
- "email": 1,
- "export": 1,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "System Manager",
- "set_user_permissions": 1,
- "share": 1,
- "write": 1
- }
- ],
- "search_fields": "room_name",
- "show_name_in_global_search": 1,
- "sort_field": "modified",
- "sort_order": "DESC",
- "title_field": "room_name",
- "track_changes": 1
-}
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_room/chat_room.py b/frappe/chat/doctype/chat_room/chat_room.py
deleted file mode 100644
index bdbee44d7a..0000000000
--- a/frappe/chat/doctype/chat_room/chat_room.py
+++ /dev/null
@@ -1,227 +0,0 @@
-# imports - module imports
-from frappe.model.document import Document
-from frappe import _
-import frappe
-
-# imports - frappe module imports
-from frappe.chat import authenticate
-from frappe.core.doctype.version.version import get_diff
-from frappe.chat.doctype.chat_message import chat_message
-from frappe.chat.util import (
- safe_json_loads,
- dictify,
- listify,
- squashify,
- get_if_empty
-)
-
-session = frappe.session
-
-
-def is_direct(owner, other, bidirectional=False):
- def get_room(owner, other):
- room = frappe.get_all('Chat Room', filters=[
- ['Chat Room', 'type', 'in', ('Direct', 'Visitor')],
- ['Chat Room', 'owner', '=', owner],
- ['Chat Room User', 'user', '=', other]
- ], distinct=True)
-
- return room
-
- exists = len(get_room(owner, other)) == 1
- if bidirectional:
- exists = exists or len(get_room(other, owner)) == 1
-
- return exists
-
-
-def get_chat_room_user_set(users, filter_=None):
- seen, uset = set(), list()
-
- for u in users:
- if filter_(u) and u.user not in seen:
- uset.append(u)
- seen.add(u.user)
-
- return uset
-
-
-class ChatRoom(Document):
- def validate(self):
- if self.is_new():
- users = get_chat_room_user_set(self.users, filter_=lambda u: u.user != session.user)
- self.update(dict(
- users=users
- ))
-
- if self.type == "Direct":
- if len(self.users) != 1:
- frappe.throw(_('{0} room must have atmost one user.').format(self.type))
-
- other = squashify(self.users)
-
- if self.is_new():
- if is_direct(self.owner, other.user, bidirectional=True):
- frappe.throw(_('Direct room with {0} already exists.').format(other.user))
-
- if self.type == "Group" and not self.room_name:
- frappe.throw(_('Group name cannot be empty.'))
-
- def on_update(self):
- if not self.is_new():
- before = self.get_doc_before_save()
- if not before: return
-
- after = self
- diff = dictify(get_diff(before, after))
- if diff:
- update = {}
- for changed in diff.changed:
- field, old, new = changed
-
- if field == 'last_message':
- new = chat_message.get(new)
-
- update.update({field: new})
-
- if diff.added or diff.removed:
- update.update(dict(users=[u.user for u in self.users]))
-
- update = dict(room=self.name, data=update)
-
- frappe.publish_realtime('frappe.chat.room:update', update, room=self.name,
- after_commit=True)
-
-
-@frappe.whitelist(allow_guest=True)
-def get(user=None, token=None, rooms=None, fields=None, filters=None):
- # There is this horrible bug out here.
- # Looks like if frappe.call sends optional arguments (not in right order),
- # the argument turns to an empty string.
- # I'm not even going to think searching for it.
- # Hence, the hack was get_if_empty (previous assign_if_none)
- # - Achilles Rasquinha achilles@frappe.io
- data = user or token
- authenticate(data)
-
- rooms, fields, filters = safe_json_loads(rooms, fields, filters)
-
- rooms = listify(get_if_empty(rooms, []))
- fields = listify(get_if_empty(fields, []))
-
- const = [] # constraints
- if rooms:
- const.append(['Chat Room', 'name', 'in', rooms])
- if filters:
- if isinstance(filters[0], list):
- const = const + filters
- else:
- const.append(filters)
-
- default = ['name', 'type', 'room_name', 'creation', 'owner', 'avatar']
- handle = ['users', 'last_message']
-
- param = [f for f in fields if f not in handle]
-
- rooms = frappe.get_all('Chat Room',
- or_filters=[
- ['Chat Room', 'owner', '=', frappe.session.user],
- ['Chat Room User', 'user', '=', frappe.session.user]
- ],
- filters=const,
- fields=param + ['name'] if param else default,
- distinct=True
- )
-
- if not fields or 'users' in fields:
- for i, r in enumerate(rooms):
- droom = frappe.get_doc('Chat Room', r.name)
- rooms[i]['users'] = []
-
- for duser in droom.users:
- rooms[i]['users'].append(duser.user)
-
- if not fields or 'last_message' in fields:
- for i, r in enumerate(rooms):
- droom = frappe.get_doc('Chat Room', r.name)
- if droom.last_message:
- rooms[i]['last_message'] = chat_message.get(droom.last_message)
- else:
- rooms[i]['last_message'] = None
-
- rooms = squashify(dictify(rooms))
-
- return rooms
-
-
-@frappe.whitelist(allow_guest=True)
-def create(kind, token, users=None, name=None):
- authenticate(token)
-
- users = safe_json_loads(users)
- create = True
-
- if kind == 'Visitor':
- room = squashify(frappe.db.sql("""
- SELECT name
- FROM `tabChat Room`
- WHERE owner=%s
- """, (frappe.session.user), as_dict=True))
-
- if room:
- room = frappe.get_doc('Chat Room', room.name)
- create = False
-
- if create:
- room = frappe.new_doc('Chat Room')
- room.type = kind
- room.owner = frappe.session.user
- room.room_name = name
-
- dusers = []
-
- if kind != 'Visitor':
- if users:
- users = listify(users)
- for user in users:
- duser = frappe.new_doc('Chat Room User')
- duser.user = user
- dusers.append(duser)
-
- room.users = dusers
- else:
- dsettings = frappe.get_single('Website Settings')
- room.room_name = dsettings.chat_room_name
-
- users = [user for user in room.users] if hasattr(room, 'users') else []
-
- for user in dsettings.chat_operators:
- if user.user not in users:
- # appending user to room.users will remove the user from chat_operators
- # this is undesirable, create a new Chat Room User instead
- chat_room_user = {"doctype": "Chat Room User", "user": user.user}
- room.append('users', chat_room_user)
-
- room.save(ignore_permissions=True)
-
- room = get(token=token, rooms=room.name)
- if room:
- users = [room.owner] + [u for u in room.users]
-
- for user in users:
- frappe.publish_realtime('frappe.chat.room:create', room, user=user, after_commit=True)
-
- return room
-
-
-@frappe.whitelist(allow_guest=True)
-def history(room, user, fields=None, limit=10, start=None, end=None):
- if frappe.get_doc('Chat Room', room).type != 'Visitor':
- authenticate(user)
-
- fields = safe_json_loads(fields)
-
- mess = chat_message.history(room, limit=limit, start=start, end=end)
- mess = squashify(mess)
-
- return dictify(mess)
diff --git a/frappe/chat/doctype/chat_room/chat_room_list.js b/frappe/chat/doctype/chat_room/chat_room_list.js
deleted file mode 100644
index 70c708c7bd..0000000000
--- a/frappe/chat/doctype/chat_room/chat_room_list.js
+++ /dev/null
@@ -1,6 +0,0 @@
-frappe.listview_settings['Chat Room'] = {
- filters: [
- ['Chat Room', 'owner', '=', frappe.session.user, true],
- ['Chat Room User', 'user', '=', frappe.session.user, true]
- ]
-};
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_room_user/__init__.py b/frappe/chat/doctype/chat_room_user/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/frappe/chat/doctype/chat_room_user/chat_room_user.json b/frappe/chat/doctype/chat_room_user/chat_room_user.json
deleted file mode 100644
index f7bdf6706b..0000000000
--- a/frappe/chat/doctype/chat_room_user/chat_room_user.json
+++ /dev/null
@@ -1,40 +0,0 @@
-{
- "beta": 1,
- "creation": "2017-11-08 15:24:21.029314",
- "doctype": "DocType",
- "editable_grid": 1,
- "engine": "InnoDB",
- "field_order": [
- "user",
- "is_admin"
- ],
- "fields": [
- {
- "fieldname": "user",
- "fieldtype": "Link",
- "in_list_view": 1,
- "label": "User",
- "options": "User",
- "reqd": 1
- },
- {
- "default": "0",
- "fieldname": "is_admin",
- "fieldtype": "Check",
- "label": "Admin"
- }
- ],
- "in_create": 1,
- "istable": 1,
- "modified": "2019-11-07 13:21:05.297337",
- "modified_by": "Administrator",
- "module": "Chat",
- "name": "Chat Room User",
- "owner": "Administrator",
- "permissions": [],
- "quick_entry": 1,
- "read_only": 1,
- "sort_field": "modified",
- "sort_order": "DESC",
- "track_changes": 1
-}
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_room_user/chat_room_user.py b/frappe/chat/doctype/chat_room_user/chat_room_user.py
deleted file mode 100644
index f6dbdc7659..0000000000
--- a/frappe/chat/doctype/chat_room_user/chat_room_user.py
+++ /dev/null
@@ -1,8 +0,0 @@
-# imports - module imports
-from frappe.model.document import Document
-import frappe
-
-session = frappe.session
-
-class ChatRoomUser(Document):
- pass
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_token/__init__.py b/frappe/chat/doctype/chat_token/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/frappe/chat/doctype/chat_token/chat_token.js b/frappe/chat/doctype/chat_token/chat_token.js
deleted file mode 100644
index 78f03026ec..0000000000
--- a/frappe/chat/doctype/chat_token/chat_token.js
+++ /dev/null
@@ -1,8 +0,0 @@
-// Copyright (c) 2018, Frappe Technologies and contributors
-// For license information, please see license.txt
-
-frappe.ui.form.on('Chat Token', {
- refresh: function(frm) {
-
- }
-});
diff --git a/frappe/chat/doctype/chat_token/chat_token.json b/frappe/chat/doctype/chat_token/chat_token.json
deleted file mode 100644
index b73505ac2c..0000000000
--- a/frappe/chat/doctype/chat_token/chat_token.json
+++ /dev/null
@@ -1,57 +0,0 @@
-{
- "autoname": "field:token",
- "beta": 1,
- "creation": "2018-03-26 18:20:13.825652",
- "doctype": "DocType",
- "editable_grid": 1,
- "engine": "InnoDB",
- "field_order": [
- "token",
- "ip_address",
- "country"
- ],
- "fields": [
- {
- "fieldname": "token",
- "fieldtype": "Data",
- "in_list_view": 1,
- "label": "Token",
- "reqd": 1
- },
- {
- "fieldname": "ip_address",
- "fieldtype": "Data",
- "label": "IP Address"
- },
- {
- "fieldname": "country",
- "fieldtype": "Data",
- "label": "Country"
- }
- ],
- "in_create": 1,
- "modified": "2019-11-07 13:21:24.514558",
- "modified_by": "Administrator",
- "module": "Chat",
- "name": "Chat Token",
- "owner": "Administrator",
- "permissions": [
- {
- "create": 1,
- "delete": 1,
- "email": 1,
- "export": 1,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "System Manager",
- "share": 1,
- "write": 1
- }
- ],
- "quick_entry": 1,
- "read_only": 1,
- "sort_field": "modified",
- "sort_order": "DESC",
- "track_changes": 1
-}
\ No newline at end of file
diff --git a/frappe/chat/doctype/chat_token/chat_token.py b/frappe/chat/doctype/chat_token/chat_token.py
deleted file mode 100644
index 0be51b6081..0000000000
--- a/frappe/chat/doctype/chat_token/chat_token.py
+++ /dev/null
@@ -1,9 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright (c) 2018, Frappe Technologies and contributors
-# License: MIT. See LICENSE
-
-import frappe
-from frappe.model.document import Document
-
-class ChatToken(Document):
- pass
diff --git a/frappe/chat/util/__init__.py b/frappe/chat/util/__init__.py
deleted file mode 100644
index 383df581cd..0000000000
--- a/frappe/chat/util/__init__.py
+++ /dev/null
@@ -1,13 +0,0 @@
-# imports - module imports
-from frappe.chat.util.util import (
- get_user_doc,
- squashify,
- safe_json_loads,
- filter_dict,
- get_if_empty,
- listify,
- dictify,
- check_url,
- create_test_user,
- get_emojis
-)
\ No newline at end of file
diff --git a/frappe/chat/util/test_util.py b/frappe/chat/util/test_util.py
deleted file mode 100644
index e2d05a4024..0000000000
--- a/frappe/chat/util/test_util.py
+++ /dev/null
@@ -1,35 +0,0 @@
-# imports - standard imports
-import unittest
-
-# imports - module imports
-from frappe.chat.util import (
- get_user_doc,
- safe_json_loads
-)
-import frappe
-
-class TestChatUtil(unittest.TestCase):
- def test_safe_json_loads(self):
- number = safe_json_loads("1")
- self.assertEqual(type(number), int)
-
- number = safe_json_loads("1.0")
- self.assertEqual(type(number), float)
-
- string = safe_json_loads("foobar")
- self.assertEqual(type(string), str)
-
- array = safe_json_loads('[{ "foo": "bar" }]')
- self.assertEqual(type(array), list)
-
- objekt = safe_json_loads('{ "foo": "bar" }')
- self.assertEqual(type(objekt), dict)
-
- true, null = safe_json_loads("true", "null")
- self.assertEqual(true, True)
- self.assertEqual(null, None)
-
- def test_get_user_doc(self):
- # Needs more test cases.
- user = get_user_doc()
- self.assertEqual(user.name, frappe.session.user)
\ No newline at end of file
diff --git a/frappe/chat/util/util.py b/frappe/chat/util/util.py
deleted file mode 100644
index b7e7991c2b..0000000000
--- a/frappe/chat/util/util.py
+++ /dev/null
@@ -1,108 +0,0 @@
-# imports - standard imports
-import json
-from collections.abc import MutableMapping, MutableSequence, Sequence
-
-# imports - third-party imports
-import requests
-from urllib.parse import urlparse
-
-# imports - module imports
-import frappe
-from frappe.exceptions import DuplicateEntryError
-from frappe.model.document import Document
-
-session = frappe.session
-
-
-def get_user_doc(user = None):
- if isinstance(user, Document):
- return user
-
- user = user or session.user
- user = frappe.get_doc('User', user)
-
- return user
-
-def squashify(what):
- if isinstance(what, Sequence) and len(what) == 1:
- return what[0]
-
- return what
-
-def safe_json_loads(*args):
- results = []
-
- for arg in args:
- try:
- arg = json.loads(arg)
- except Exception:
- pass
-
- results.append(arg)
-
- return squashify(results)
-
-def filter_dict(what, keys, ignore = False):
- copy = dict()
-
- if keys:
- for k in keys:
- if k not in what and not ignore:
- raise KeyError('{key} not in dict.'.format(key = k))
- else:
- copy.update({
- k: what[k]
- })
- else:
- copy = what.copy()
-
- return copy
-
-def get_if_empty(a, b):
- if not a:
- a = b
- return a
-
-def listify(arg):
- if not isinstance(arg, list):
- arg = [arg]
- return arg
-
-def dictify(arg):
- if isinstance(arg, MutableSequence):
- for i, a in enumerate(arg):
- arg[i] = dictify(a)
- elif isinstance(arg, MutableMapping):
- arg = frappe._dict(arg)
-
- return arg
-
-def check_url(what, raise_err = False):
- if not urlparse(what).scheme:
- if raise_err:
- raise ValueError('{what} not a valid URL.')
- else:
- return False
-
- return True
-
-def create_test_user(module):
- try:
- test_user = frappe.new_doc('User')
- test_user.first_name = '{module}'.format(module = module)
- test_user.email = 'testuser.{module}@example.com'.format(module = module)
- test_user.save()
- except DuplicateEntryError:
- frappe.log('Test User Chat Profile exists.')
-
-def get_emojis():
- redis = frappe.cache()
- emojis = redis.hget('frappe_emojis', 'emojis')
-
- if not emojis:
- resp = requests.get('http://git.io/frappe-emoji')
- if resp.ok:
- emojis = resp.json()
- redis.hset('frappe_emojis', 'emojis', emojis)
-
- return dictify(emojis)
diff --git a/frappe/chat/website/__init__.py b/frappe/chat/website/__init__.py
deleted file mode 100644
index 12affd2782..0000000000
--- a/frappe/chat/website/__init__.py
+++ /dev/null
@@ -1,42 +0,0 @@
-
-import frappe
-from frappe.chat.util import filter_dict, safe_json_loads
-
-from frappe.sessions import get_geo_ip_country
-
-@frappe.whitelist(allow_guest = True)
-def settings(fields = None):
- fields = safe_json_loads(fields)
-
- dsettings = frappe.get_single('Website Settings')
- response = dict(
- socketio = dict(
- port = frappe.conf.socketio_port
- ),
- enable = bool(dsettings.chat_enable),
- enable_from = dsettings.chat_enable_from,
- enable_to = dsettings.chat_enable_to,
- room_name = dsettings.chat_room_name,
- welcome_message = dsettings.chat_welcome_message,
- operators = [
- duser.user for duser in dsettings.chat_operators
- ]
- )
-
- if fields:
- response = filter_dict(response, fields)
-
- return response
-
-@frappe.whitelist(allow_guest = True)
-def token():
- dtoken = frappe.new_doc('Chat Token')
-
- dtoken.token = frappe.generate_hash()
- dtoken.ip_address = frappe.local.request_ip
- country = get_geo_ip_country(dtoken.ip_address)
- if country:
- dtoken.country = country['iso_code']
- dtoken.save(ignore_permissions = True)
-
- return dtoken.token
\ No newline at end of file
diff --git a/frappe/client.py b/frappe/client.py
index 0e9be0a7ee..e835e7fee7 100644
--- a/frappe/client.py
+++ b/frappe/client.py
@@ -18,7 +18,7 @@ Requests via FrappeClient are also handled here.
@frappe.whitelist()
def get_list(doctype, fields=None, filters=None, order_by=None,
- limit_start=None, limit_page_length=20, parent=None, debug=False, as_dict=True):
+ limit_start=None, limit_page_length=20, parent=None, debug=False, as_dict=True, or_filters=None):
'''Returns a list of records by filters, fields, ordering and limit
:param doctype: DocType of the data to be queried
@@ -32,8 +32,10 @@ def get_list(doctype, fields=None, filters=None, order_by=None,
args = frappe._dict(
doctype=doctype,
+ parent_doctype=parent,
fields=fields,
filters=filters,
+ or_filters=or_filters,
order_by=order_by,
limit_start=limit_start,
limit_page_length=limit_page_length,
@@ -87,7 +89,7 @@ def get_value(doctype, fieldname, filters=None, as_dict=True, debug=False, paren
filters = {"name": filters}
try:
- fields = json.loads(fieldname)
+ fields = frappe.parse_json(fieldname)
except (TypeError, ValueError):
# name passed, not json
fields = [fieldname]
@@ -405,3 +407,45 @@ def is_document_amended(doctype, docname):
pass
return False
+
+@frappe.whitelist()
+def validate_link(doctype: str, docname: str, fields=None):
+ if not isinstance(doctype, str):
+ frappe.throw(_("DocType must be a string"))
+
+ if not isinstance(docname, str):
+ frappe.throw(_("Document Name must be a string"))
+
+ if doctype != "DocType" and not (
+ frappe.has_permission(doctype, "select")
+ or frappe.has_permission(doctype, "read")
+ ):
+ frappe.throw(
+ _("You do not have Read or Select Permissions for {}")
+ .format(frappe.bold(doctype)),
+ frappe.PermissionError
+ )
+
+ values = frappe._dict()
+ values.name = frappe.db.get_value(doctype, docname, cache=True)
+
+ fields = frappe.parse_json(fields)
+ if not values.name or not fields:
+ return values
+
+ try:
+ values.update(get_value(doctype, fields, docname))
+ except frappe.PermissionError:
+ frappe.clear_last_message()
+ frappe.msgprint(
+ _("You need {0} permission to fetch values from {1} {2}")
+ .format(
+ frappe.bold(_("Read")),
+ frappe.bold(doctype),
+ frappe.bold(docname)
+ ),
+ title=_("Cannot Fetch Values"),
+ indicator="orange"
+ )
+
+ return values
diff --git a/frappe/commands/site.py b/frappe/commands/site.py
index 2bd3110481..677325e02d 100755
--- a/frappe/commands/site.py
+++ b/frappe/commands/site.py
@@ -55,8 +55,11 @@ def new_site(site, mariadb_root_username=None, mariadb_root_password=None, admin
@click.option('--with-public-files', help='Restores the public files of the site, given path to its tar file')
@click.option('--with-private-files', help='Restores the private files of the site, given path to its tar file')
@click.option('--force', is_flag=True, default=False, help='Ignore the validations and downgrade warnings. This action is not recommended')
+@click.option('--encryption-key', help='Backup encryption key')
@pass_context
-def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_password=None, db_name=None, verbose=None, install_app=None, admin_password=None, force=None, with_public_files=None, with_private_files=None):
+def restore(context, sql_file_path, encryption_key=None, mariadb_root_username=None, mariadb_root_password=None,
+ db_name=None, verbose=None, install_app=None, admin_password=None, force=None, with_public_files=None,
+ with_private_files=None):
"Restore site database from an sql file"
from frappe.installer import (
_new_site,
@@ -66,26 +69,74 @@ def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_pas
is_partial,
validate_database_sql
)
+ from frappe.utils.backups import Backup
+ if not os.path.exists(sql_file_path):
+ print("Invalid path", sql_file_path)
+ sys.exit(1)
+
+ _backup = Backup(sql_file_path)
site = get_site(context)
frappe.init(site=site)
-
force = context.force or force
- decompressed_file_name = extract_sql_from_archive(sql_file_path)
- # check if partial backup
- if is_partial(decompressed_file_name):
- click.secho(
- "Partial Backup file detected. You cannot use a partial file to restore a Frappe Site.",
- fg="red"
- )
- click.secho(
- "Use `bench partial-restore` to restore a partial backup to an existing site.",
- fg="yellow"
- )
- sys.exit(1)
+ try:
+ decompressed_file_name = extract_sql_from_archive(sql_file_path)
+ if is_partial(decompressed_file_name):
+ click.secho(
+ "Partial Backup file detected. You cannot use a partial file to restore a Frappe site.",
+ fg="red"
+ )
+ click.secho(
+ "Use `bench partial-restore` to restore a partial backup to an existing site.",
+ fg="yellow"
+ )
+ _backup.decryption_rollback()
+ sys.exit(1)
+
+ except UnicodeDecodeError:
+ _backup.decryption_rollback()
+ if encryption_key:
+ click.secho(
+ "Encrypted backup file detected. Decrypting using provided key.",
+ fg="yellow"
+ )
+ _backup.backup_decryption(encryption_key)
+
+ else:
+ click.secho(
+ "Encrypted backup file detected. Decrypting using site config.",
+ fg="yellow"
+ )
+ encryption_key = frappe.get_site_config().encryption_key
+ _backup.backup_decryption(encryption_key)
+
+ # Rollback on unsuccessful decryrption
+ if not os.path.exists(sql_file_path):
+ click.secho(
+ "Decryption failed. Please provide a valid key and try again.",
+ fg="red"
+ )
+
+ _backup.decryption_rollback()
+ sys.exit(1)
+
+ decompressed_file_name = extract_sql_from_archive(sql_file_path)
+
+ if is_partial(decompressed_file_name):
+ click.secho(
+ "Partial Backup file detected. You cannot use a partial file to restore a Frappe site.",
+ fg="red"
+ )
+ click.secho(
+ "Use `bench partial-restore` to restore a partial backup to an existing site.",
+ fg="yellow"
+ )
+ _backup.decryption_rollback()
+ sys.exit(1)
+
+
- # check if valid SQL file
validate_database_sql(decompressed_file_name, _raise=not force)
# dont allow downgrading to older versions of frappe without force
@@ -96,23 +147,51 @@ def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_pas
)
click.confirm(warn_message, abort=True)
- _new_site(frappe.conf.db_name, site, mariadb_root_username=mariadb_root_username,
- mariadb_root_password=mariadb_root_password, admin_password=admin_password,
- verbose=context.verbose, install_apps=install_app, source_sql=decompressed_file_name,
- force=True, db_type=frappe.conf.db_type)
- # Extract public and/or private files to the restored site, if user has given the path
- if with_public_files:
- public = extract_files(site, with_public_files)
- os.remove(public)
- if with_private_files:
- private = extract_files(site, with_private_files)
- os.remove(private)
+ try:
+ _new_site(frappe.conf.db_name, site, mariadb_root_username=mariadb_root_username,
+ mariadb_root_password=mariadb_root_password, admin_password=admin_password,
+ verbose=context.verbose, install_apps=install_app, source_sql=decompressed_file_name,
+ force=True, db_type=frappe.conf.db_type)
+
+ except Exception as err:
+ print(err.args[1])
+ _backup.decryption_rollback()
+ sys.exit(1)
# Removing temporarily created file
if decompressed_file_name != sql_file_path:
os.remove(decompressed_file_name)
+ _backup.decryption_rollback()
+
+ # Extract public and/or private files to the restored site, if user has given the path
+ if with_public_files:
+ # Decrypt data if there is a Key
+ if encryption_key:
+ _backup = Backup(with_public_files)
+ _backup.backup_decryption(encryption_key)
+ if not os.path.exists(with_public_files):
+ _backup.decryption_rollback()
+ public = extract_files(site, with_public_files)
+
+ # Removing temporarily created file
+ os.remove(public)
+ _backup.decryption_rollback()
+
+
+ if with_private_files:
+ # Decrypt data if there is a Key
+ if encryption_key:
+ _backup = Backup(with_private_files)
+ _backup.backup_decryption(encryption_key)
+ if not os.path.exists(with_private_files):
+ _backup.decryption_rollback()
+ private = extract_files(site, with_private_files)
+
+ # Removing temporarily created file
+ os.remove(private)
+ _backup.decryption_rollback()
success_message = "Site {0} has been restored{1}".format(
site,
@@ -120,19 +199,92 @@ def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_pas
)
click.secho(success_message, fg="green")
-
@click.command('partial-restore')
@click.argument('sql-file-path')
@click.option("--verbose", "-v", is_flag=True)
+@click.option('--encryption-key', help='Backup encryption key')
@pass_context
-def partial_restore(context, sql_file_path, verbose):
- from frappe.installer import partial_restore
- verbose = context.verbose or verbose
+def partial_restore(context, sql_file_path, verbose, encryption_key=None):
+ from frappe.installer import partial_restore, extract_sql_from_archive
+ from frappe.utils.backups import Backup
+
+ if not os.path.exists(sql_file_path):
+ print("Invalid path", sql_file_path)
+ sys.exit(1)
site = get_site(context)
frappe.init(site=site)
+
+ _backup = Backup(sql_file_path)
+
+ verbose = context.verbose or verbose
+
frappe.connect(site=site)
+ try:
+ decompressed_file_name = extract_sql_from_archive(sql_file_path)
+
+ with open(decompressed_file_name) as f:
+ header = " ".join(f.readline() for _ in range(5))
+
+ #Check for full backup file
+ if "Partial Backup" not in header:
+ click.secho(
+ "Full backup file detected.Use `bench restore` to restore a Frappe Site.",
+ fg="red"
+ )
+ _backup.decryption_rollback()
+ sys.exit(1)
+
+
+ except UnicodeDecodeError:
+ _backup.decryption_rollback()
+ if encryption_key:
+ click.secho(
+ "Encrypted backup file detected. Decrypting using provided key.",
+ fg="yellow"
+ )
+ key = encryption_key
+
+ else:
+ click.secho(
+ "Encrypted backup file detected. Decrypting using site config.",
+ fg="yellow"
+ )
+ key = frappe.get_site_config().encryption_key
+
+ _backup.backup_decryption(key)
+
+ # Rollback on unsuccessful decryrption
+ if not os.path.exists(sql_file_path):
+ click.secho(
+ "Decryption failed. Please provide a valid key and try again.",
+ fg="red"
+ )
+ _backup.decryption_rollback()
+ sys.exit(1)
+
+ decompressed_file_name = extract_sql_from_archive(sql_file_path)
+
+ with open(decompressed_file_name) as f:
+ header = " ".join(f.readline() for _ in range(5))
+
+ #Check for Full backup file.
+ if "Partial Backup" not in header:
+ click.secho(
+ "Full Backup file detected.Use `bench restore` to restore a Frappe Site.",
+ fg="red"
+ )
+ _backup.decryption_rollback()
+ sys.exit(1)
+
+
partial_restore(sql_file_path, verbose)
+
+ # Removing temporarily created file
+ _backup.decryption_rollback()
+ if os.path.exists(sql_file_path.rstrip(".gz")):
+ os.remove(sql_file_path.rstrip(".gz"))
+
frappe.destroy()
@@ -295,11 +447,10 @@ def disable_user(context, email):
@pass_context
def migrate(context, skip_failing=False, skip_search_index=False):
"Run patches, sync schema and rebuild files/translations"
- import re
from frappe.migrate import migrate
for site in context.sites:
- print('Migrating', site)
+ click.secho(f"Migrating {site}", fg="green")
frappe.init(site=site)
frappe.connect()
try:
@@ -309,6 +460,7 @@ def migrate(context, skip_failing=False, skip_search_index=False):
skip_search_index=skip_search_index
)
finally:
+ print()
frappe.destroy()
if not context.sites:
raise SiteNotSpecifiedError
@@ -418,6 +570,7 @@ def backup(context, with_files=False, backup_path=None, backup_path_db=None, bac
backup_path_private_files=None, backup_path_conf=None, ignore_backup_conf=False, verbose=False,
compress=False, include="", exclude=""):
"Backup"
+
from frappe.utils.backups import scheduled_backup
verbose = verbose or context.verbose
exit_code = 0
@@ -441,14 +594,25 @@ def backup(context, with_files=False, backup_path=None, backup_path_db=None, bac
force=True
)
except Exception:
- click.secho("Backup failed for Site {0}. Database or site_config.json may be corrupted".format(site), fg="red")
+ click.secho(
+ "Backup failed for Site {0}. Database or site_config.json may be corrupted".format(site),
+ fg="red"
+ )
if verbose:
print(frappe.get_traceback())
exit_code = 1
continue
+ if frappe.get_system_settings("encrypt_backup") and frappe.get_site_config().encryption_key:
+ click.secho(
+ "Backup encryption is turned on. Please note the backup encryption key.",
+ fg="yellow"
+ )
odb.print_summary()
- click.secho("Backup for Site {0} has been successfully completed{1}".format(site, " with files" if with_files else ""), fg="green")
+ click.secho(
+ "Backup for Site {0} has been successfully completed{1}".format(site, " with files" if with_files else ""),
+ fg="green"
+ )
frappe.destroy()
if not context.sites:
@@ -456,6 +620,7 @@ def backup(context, with_files=False, backup_path=None, backup_path_db=None, bac
sys.exit(exit_code)
+
@click.command('remove-from-installed-apps')
@click.argument('app')
@pass_context
@@ -531,11 +696,9 @@ def _drop_site(site, root_login='root', root_password=None, archived_sites_path=
drop_user_and_database(frappe.conf.db_name, root_login, root_password)
- if not archived_sites_path:
- archived_sites_path = os.path.join(frappe.get_app_path('frappe'), '..', '..', '..', 'archived_sites')
+ archived_sites_path = archived_sites_path or os.path.join(frappe.get_app_path('frappe'), '..', '..', '..', 'archived', 'sites')
- if not os.path.exists(archived_sites_path):
- os.mkdir(archived_sites_path)
+ os.makedirs(archived_sites_path, exist_ok=True)
move(archived_sites_path, site)
@@ -659,22 +822,41 @@ def publish_realtime(context, event, message, room, user, doctype, docname, afte
@click.command('browse')
@click.argument('site', required=False)
+@click.option('--user', required=False, help='Login as user')
@pass_context
-def browse(context, site):
+def browse(context, site, user=None):
'''Opens the site on web browser'''
- import webbrowser
- site = context.sites[0] if context.sites else site
+ from frappe.auth import CookieManager, LoginManager
+
+ site = get_site(context, raise_err=False) or site
if not site:
- click.echo('''Please provide site name\n\nUsage:\n\tbench browse [site-name]\nor\n\tbench --site [site-name] browse''')
- return
+ raise SiteNotSpecifiedError
- site = site.lower()
+ if site not in frappe.utils.get_sites():
+ click.echo(f"\nSite named {click.style(site, bold=True)} doesn't exist\n", err=True)
+ sys.exit(1)
- if site in frappe.utils.get_sites():
- webbrowser.open(frappe.utils.get_site_url(site), new=2)
- else:
- click.echo("\nSite named \033[1m{}\033[0m doesn't exist\n".format(site))
+ frappe.init(site=site)
+ frappe.connect()
+
+ sid = ''
+ if user:
+ if frappe.conf.developer_mode or user == "Administrator":
+ frappe.utils.set_request(path="/")
+ frappe.local.cookie_manager = CookieManager()
+ frappe.local.login_manager = LoginManager()
+ frappe.local.login_manager.login_as(user)
+ sid = f'/app?sid={frappe.session.sid}'
+ else:
+ click.echo("Please enable developer mode to login as a user")
+
+ url = f'{frappe.utils.get_site_url(site)}{sid}'
+
+ if user == "Administrator":
+ click.echo(f'Login URL: {url}')
+
+ click.launch(url)
@click.command('start-recording')
diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py
index 416f014164..41b607b192 100644
--- a/frappe/commands/utils.py
+++ b/frappe/commands/utils.py
@@ -723,7 +723,7 @@ def run_ui_tests(context, app, headless=False, parallel=True, with_coverage=Fals
frappe.commands.popen("yarn add cypress@^6 cypress-file-upload@^5 @testing-library/cypress@^8 @cypress/code-coverage@^3 --no-lockfile")
# run for headless mode
- run_or_open = 'run --browser firefox --record' if headless else 'open'
+ run_or_open = 'run --browser chrome --record' if headless else 'open'
formatted_command = f'{site_env} {password_env} {coverage_env} {cypress_path} {run_or_open}'
if parallel:
@@ -791,10 +791,11 @@ def request(context, args=None, path=None):
@click.command('make-app')
@click.argument('destination')
@click.argument('app_name')
-def make_app(destination, app_name):
+@click.option('--no-git', is_flag=True, default=False, help='Do not initialize git repository for the app')
+def make_app(destination, app_name, no_git=False):
"Creates a boilerplate app"
from frappe.utils.boilerplate import make_boilerplate
- make_boilerplate(destination, app_name)
+ make_boilerplate(destination, app_name, no_git=no_git)
@click.command('set-config')
diff --git a/frappe/core/doctype/access_log/access_log.py b/frappe/core/doctype/access_log/access_log.py
index 48c12fd93f..db2e64e868 100644
--- a/frappe/core/doctype/access_log/access_log.py
+++ b/frappe/core/doctype/access_log/access_log.py
@@ -11,11 +11,26 @@ class AccessLog(Document):
@frappe.whitelist()
+def make_access_log(
+ doctype=None,
+ document=None,
+ method=None,
+ file_type=None,
+ report_name=None,
+ filters=None,
+ page=None,
+ columns=None,
+):
+ _make_access_log(
+ doctype, document, method, file_type, report_name, filters, page, columns,
+ )
+
+
@frappe.write_only()
@retry(
stop=stop_after_attempt(3), retry=retry_if_exception_type(frappe.DuplicateEntryError)
)
-def make_access_log(
+def _make_access_log(
doctype=None,
document=None,
method=None,
@@ -42,6 +57,7 @@ def make_access_log(
}).db_insert()
# `frappe.db.commit` added because insert doesnt `commit` when called in GET requests like `printview`
- # dont commit in test mode
+ # dont commit in test mode. It must be tempting to put this block along with the in_request in the
+ # whitelisted method...yeah, don't do it. That part would be executed possibly on a read only DB conn
if not frappe.flags.in_test or in_request:
frappe.db.commit()
diff --git a/frappe/core/doctype/comment/test_comment.py b/frappe/core/doctype/comment/test_comment.py
index 99bd19c106..cd9af498aa 100644
--- a/frappe/core/doctype/comment/test_comment.py
+++ b/frappe/core/doctype/comment/test_comment.py
@@ -5,6 +5,15 @@ import frappe, json
import unittest
class TestComment(unittest.TestCase):
+ def tearDown(self):
+ frappe.form_dict.comment = None
+ frappe.form_dict.comment_email = None
+ frappe.form_dict.comment_by = None
+ frappe.form_dict.reference_doctype = None
+ frappe.form_dict.reference_name = None
+ frappe.form_dict.route = None
+ frappe.local.request_ip = None
+
def test_comment_creation(self):
test_doc = frappe.get_doc(dict(doctype = 'ToDo', description = 'test'))
test_doc.insert()
@@ -33,8 +42,16 @@ class TestComment(unittest.TestCase):
frappe.db.delete("Comment", {"reference_doctype": "Blog Post"})
from frappe.templates.includes.comments.comments import add_comment
- add_comment('Good comment with 10 chars', 'test@test.com', 'Good Tester',
- 'Blog Post', test_blog.name, test_blog.route)
+
+ frappe.form_dict.comment = 'Good comment with 10 chars'
+ frappe.form_dict.comment_email = 'test@test.com'
+ frappe.form_dict.comment_by = 'Good Tester'
+ frappe.form_dict.reference_doctype = 'Blog Post'
+ frappe.form_dict.reference_name = test_blog.name
+ frappe.form_dict.route = test_blog.route
+ frappe.local.request_ip = '127.0.0.1'
+
+ add_comment()
self.assertEqual(frappe.get_all('Comment', fields = ['*'], filters = dict(
reference_doctype = test_blog.doctype,
@@ -43,8 +60,10 @@ class TestComment(unittest.TestCase):
frappe.db.delete("Comment", {"reference_doctype": "Blog Post"})
- add_comment('pleez vizits my site http://mysite.com', 'test@test.com', 'bad commentor',
- 'Blog Post', test_blog.name, test_blog.route)
+ frappe.form_dict.comment = 'pleez vizits my site http://mysite.com'
+ frappe.form_dict.comment_by = 'bad commentor'
+
+ add_comment()
self.assertEqual(len(frappe.get_all('Comment', fields = ['*'], filters = dict(
reference_doctype = test_blog.doctype,
diff --git a/frappe/core/doctype/communication/communication.json b/frappe/core/doctype/communication/communication.json
index 849df66a5f..175c64b9eb 100644
--- a/frappe/core/doctype/communication/communication.json
+++ b/frappe/core/doctype/communication/communication.json
@@ -51,6 +51,7 @@
"email_inbox",
"message_id",
"uid",
+ "imap_folder",
"email_status",
"has_attachment",
"feedback_section",
@@ -382,12 +383,19 @@
"label": "Timeline Links",
"options": "Communication Link",
"permlevel": 2
+ },
+ {
+ "fieldname": "imap_folder",
+ "fieldtype": "Data",
+ "hidden": 1,
+ "label": "IMAP Folder",
+ "read_only": 1
}
],
"icon": "fa fa-comment",
"idx": 1,
"links": [],
- "modified": "2021-03-25 09:44:28.963538",
+ "modified": "2021-11-30 09:03:25.728637",
"modified_by": "Administrator",
"module": "Core",
"name": "Communication",
diff --git a/frappe/core/doctype/communication/communication.py b/frappe/core/doctype/communication/communication.py
index 3a78a6a599..96c8f271d9 100644
--- a/frappe/core/doctype/communication/communication.py
+++ b/frappe/core/doctype/communication/communication.py
@@ -488,10 +488,12 @@ def update_parent_document_on_communication(doc):
def update_first_response_time(parent, communication):
if parent.meta.has_field("first_response_time") and not parent.get("first_response_time"):
if is_system_user(communication.sender):
- first_responded_on = communication.creation
- if parent.meta.has_field("first_responded_on") and communication.sent_or_received == "Sent":
- parent.db_set("first_responded_on", first_responded_on)
- parent.db_set("first_response_time", round(time_diff_in_seconds(first_responded_on, parent.creation), 2))
+ if communication.sent_or_received == "Sent":
+ first_responded_on = communication.creation
+ if parent.meta.has_field("first_responded_on"):
+ parent.db_set("first_responded_on", first_responded_on)
+ first_response_time = round(time_diff_in_seconds(first_responded_on, parent.creation), 2)
+ parent.db_set("first_response_time", first_response_time)
def set_avg_response_time(parent, communication):
if parent.meta.has_field("avg_response_time") and communication.sent_or_received == "Sent":
diff --git a/frappe/core/doctype/communication/email.py b/frappe/core/doctype/communication/email.py
index 4d22075b78..54ddbce2c4 100755
--- a/frappe/core/doctype/communication/email.py
+++ b/frappe/core/doctype/communication/email.py
@@ -146,25 +146,43 @@ def add_attachments(name, attachments):
})
_file.save(ignore_permissions=True)
-@frappe.whitelist(allow_guest=True)
-def mark_email_as_seen(name=None):
+@frappe.whitelist(allow_guest=True, methods=("GET",))
+def mark_email_as_seen(name: str = None):
try:
- if name and frappe.db.exists("Communication", name) and not frappe.db.get_value("Communication", name, "read_by_recipient"):
- frappe.db.set_value("Communication", name, "read_by_recipient", 1)
- frappe.db.set_value("Communication", name, "delivery_status", "Read")
- frappe.db.set_value("Communication", name, "read_by_recipient_on", get_datetime())
- frappe.db.commit()
+ update_communication_as_read(name)
+ frappe.db.commit() # nosemgrep: this will be called in a GET request
+
except Exception:
frappe.log_error(frappe.get_traceback())
- finally:
- # Return image as response under all circumstances
- from PIL import Image
- import io
- im = Image.new('RGBA', (1, 1))
- im.putdata([(255,255,255,0)])
- buffered_obj = io.BytesIO()
- im.save(buffered_obj, format="PNG")
- frappe.response["type"] = 'binary'
- frappe.response["filename"] = "imaginary_pixel.png"
- frappe.response["filecontent"] = buffered_obj.getvalue()
+ finally:
+ frappe.response.update({
+ "type": "binary",
+ "filename": "imaginary_pixel.png",
+ "filecontent": (
+ b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00"
+ b"\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\r"
+ b"IDATx\x9cc\xf8\xff\xff?\x03\x00\x08\xfc\x02\xfe\xa7\x9a\xa0"
+ b"\xa0\x00\x00\x00\x00IEND\xaeB`\x82"
+ )
+ })
+
+def update_communication_as_read(name):
+ if not name or not isinstance(name, str):
+ return
+
+ communication = frappe.db.get_value(
+ "Communication",
+ name,
+ "read_by_recipient",
+ as_dict=True
+ )
+
+ if not communication or communication.read_by_recipient:
+ return
+
+ frappe.db.set_value("Communication", name, {
+ "read_by_recipient": 1,
+ "delivery_status": "Read",
+ "read_by_recipient_on": get_datetime()
+ })
diff --git a/frappe/core/doctype/communication/test_communication.py b/frappe/core/doctype/communication/test_communication.py
index b0c8e1fcee..f26e70771b 100644
--- a/frappe/core/doctype/communication/test_communication.py
+++ b/frappe/core/doctype/communication/test_communication.py
@@ -291,6 +291,7 @@ def create_email_account():
"unreplied_for_mins": 20,
"send_notification_to": "test_comm@example.com",
"pop3_server": "pop.test.example.com",
+ "imap_folder": [{"folder_name": "INBOX", "append_to": "ToDo"}],
"no_remaining":"0",
"enable_automatic_linking": 1
}).insert(ignore_permissions=True)
diff --git a/frappe/core/doctype/data_import/exporter.py b/frappe/core/doctype/data_import/exporter.py
index 684328a4c7..c09bd58c25 100644
--- a/frappe/core/doctype/data_import/exporter.py
+++ b/frappe/core/doctype/data_import/exporter.py
@@ -5,6 +5,7 @@
import typing
import frappe
+from frappe import _
from frappe.model import (
display_fieldtypes,
no_value_fields,
@@ -191,7 +192,7 @@ class Exporter:
[format_column_name(df) for df in self.fields if df.parent == child_table_doctype]
)
)
- data = frappe.db.get_list(
+ data = frappe.db.get_all(
child_table_doctype,
filters={
"parent": ("in", parent_names),
@@ -215,9 +216,9 @@ class Exporter:
for df in self.fields:
is_parent = not df.is_child_table_field
if is_parent:
- label = df.label
+ label = _(df.label)
else:
- label = "{0} ({1})".format(df.label, df.child_table_df.label)
+ label = "{0} ({1})".format(_(df.label), _(df.child_table_df.label))
if label in header:
# this label is already in the header,
@@ -227,6 +228,7 @@ class Exporter:
label = "{0}".format(df.fieldname)
else:
label = "{0}.{1}".format(df.child_table_df.fieldname, df.fieldname)
+
header.append(label)
self.csv_array.append(header)
@@ -253,10 +255,10 @@ class Exporter:
self.build_xlsx_response()
def build_csv_response(self):
- build_csv_response(self.get_csv_array_for_export(), self.doctype)
+ build_csv_response(self.get_csv_array_for_export(), _(self.doctype))
def build_xlsx_response(self):
- build_xlsx_response(self.get_csv_array_for_export(), self.doctype)
+ build_xlsx_response(self.get_csv_array_for_export(), _(self.doctype))
def group_children_data_by_parent(self, children_data: typing.Dict[str, list]):
return groupby_metric(children_data, key='parent')
diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py
index cd20a5c0f3..b9b2050763 100644
--- a/frappe/core/doctype/data_import/importer.py
+++ b/frappe/core/doctype/data_import/importer.py
@@ -199,7 +199,7 @@ class Importer:
new_doc = frappe.new_doc(self.doctype)
new_doc.update(doc)
- if (meta.autoname or "").lower() != "prompt":
+ if not doc.name and (meta.autoname or "").lower() != "prompt":
# name can only be set directly if autoname is prompt
new_doc.set("name", None)
@@ -262,7 +262,7 @@ class Importer:
rows = [header_row]
rows += [row.data for row in self.import_file.data if row.row_number in row_indexes]
- build_csv_response(rows, self.doctype)
+ build_csv_response(rows, _(self.doctype))
def print_import_log(self, import_log):
failed_records = [log for log in import_log if not log.success]
@@ -1009,18 +1009,14 @@ def build_fields_dict_for_column_matching(parent_doctype):
out = {}
# doctypes and fieldname if it is a child doctype
- doctypes = [[parent_doctype, None]] + [
- [df.options, df] for df in parent_meta.get_table_fields()
+ doctypes = [(parent_doctype, None)] + [
+ (df.options, df) for df in parent_meta.get_table_fields()
]
for doctype, table_df in doctypes:
+ translated_table_label = _(table_df.label) if table_df else None
+
# name field
- name_by_label = (
- "ID" if doctype == parent_doctype else "ID ({0})".format(table_df.label)
- )
- name_by_fieldname = (
- "name" if doctype == parent_doctype else "{0}.name".format(table_df.fieldname)
- )
name_df = frappe._dict(
{
"fieldtype": "Data",
@@ -1031,63 +1027,90 @@ def build_fields_dict_for_column_matching(parent_doctype):
}
)
- if doctype != parent_doctype:
+ if doctype == parent_doctype:
+ name_headers = (
+ "name", # fieldname
+ "ID", # label
+ _("ID"), # translated label
+ )
+ else:
+ name_headers = (
+ "{0}.name".format(table_df.fieldname), # fieldname
+ "ID ({0})".format(table_df.label), # label
+ "{0} ({1})".format(_("ID"), translated_table_label), # translated label
+ )
+
name_df.is_child_table_field = True
name_df.child_table_df = table_df
- out[name_by_label] = name_df
- out[name_by_fieldname] = name_df
+ for header in name_headers:
+ out[header] = name_df
- # other fields
fields = get_standard_fields(doctype) + frappe.get_meta(doctype).fields
for df in fields:
- label = (df.label or "").strip()
fieldtype = df.fieldtype or "Data"
+ if fieldtype in no_value_fields:
+ continue
+
+ label = (df.label or "").strip()
+ translated_label = _(label)
parent = df.parent or parent_doctype
- if fieldtype not in no_value_fields:
- if parent_doctype == doctype:
- # for parent doctypes keys will be
- # Label
- # label
- # Label (label)
- if not out.get(label):
- # if Label is already set, don't set it again
- # in case of duplicate column headers
- out[label] = df
- out[df.fieldname] = df
- label_with_fieldname = "{0} ({1})".format(label, df.fieldname)
- out[label_with_fieldname] = df
+
+ if parent_doctype == doctype:
+ # for parent doctypes keys will be
+ # Label, fieldname, Label (fieldname)
+
+ for header in (label, translated_label):
+ # if Label is already set, don't set it again
+ # in case of duplicate column headers
+ if header not in out:
+ out[header] = df
+
+ for header in (
+ df.fieldname,
+ f"{label} ({df.fieldname})",
+ f"{translated_label} ({df.fieldname})"
+ ):
+ out[header] = df
+
+ else:
+ # for child doctypes keys will be
+ # Label (Table Field Label)
+ # table_field.fieldname
+
+ # create a new df object to avoid mutation problems
+ if isinstance(df, dict):
+ new_df = frappe._dict(df.copy())
else:
- # in case there are multiple table fields with the same doctype
- # for child doctypes keys will be
- # Label (Table Field Label)
- # table_field.fieldname
- table_fields = parent_meta.get(
- "fields", {"fieldtype": ["in", table_fieldtypes], "options": parent}
- )
- for table_field in table_fields:
- by_label = "{0} ({1})".format(label, table_field.label)
- by_fieldname = "{0}.{1}".format(table_field.fieldname, df.fieldname)
+ new_df = df.as_dict()
- # create a new df object to avoid mutation problems
- if isinstance(df, dict):
- new_df = frappe._dict(df.copy())
- else:
- new_df = df.as_dict()
+ new_df.is_child_table_field = True
+ new_df.child_table_df = table_df
- new_df.is_child_table_field = True
- new_df.child_table_df = table_field
- out[by_label] = new_df
- out[by_fieldname] = new_df
+ for header in (
+ # fieldname
+ "{0}.{1}".format(table_df.fieldname, df.fieldname),
+ # label
+ "{0} ({1})".format(label, table_df.label),
+ # translated label
+ "{0} ({1})".format(translated_label, translated_table_label),
+ ):
+ out[header] = new_df
# if autoname is based on field
# add an entry for "ID (Autoname Field)"
autoname_field = get_autoname_field(parent_doctype)
if autoname_field:
- out["ID ({})".format(autoname_field.label)] = autoname_field
- # ID field should also map to the autoname field
- out["ID"] = autoname_field
- out["name"] = autoname_field
+ for header in (
+ "ID ({})".format(autoname_field.label), # label
+ "{0} ({1})".format(_("ID"), _(autoname_field.label)), # translated label
+
+ # ID field should also map to the autoname field
+ "ID",
+ _("ID"),
+ "name",
+ ):
+ out[header] = autoname_field
return out
diff --git a/frappe/core/doctype/docfield/docfield.json b/frappe/core/doctype/docfield/docfield.json
index 56c3ff6037..7bbf9422ba 100644
--- a/frappe/core/doctype/docfield/docfield.json
+++ b/frappe/core/doctype/docfield/docfield.json
@@ -20,6 +20,7 @@
"search_index",
"column_break_18",
"options",
+ "show_dashboard",
"defaults_section",
"default",
"column_break_6",
@@ -97,7 +98,7 @@
"label": "Type",
"oldfieldname": "fieldtype",
"oldfieldtype": "Select",
- "options": "Attach\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\nRead Only\nRating\nSection Break\nSelect\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nSignature\nTab Break",
+ "options": "Attach\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\nRead Only\nRating\nSection Break\nSelect\nSignature\nSmall Text\nTab Break\nTable\nTable MultiSelect\nText\nText Editor\nTime",
"reqd": 1,
"search_index": 1
},
@@ -526,13 +527,20 @@
{
"fieldname": "column_break_35",
"fieldtype": "Column Break"
+ },
+ {
+ "default": "0",
+ "depends_on": "eval:doc.fieldtype===\"Tab Break\"",
+ "fieldname": "show_dashboard",
+ "fieldtype": "Check",
+ "label": "Show Dashboard"
}
],
"idx": 1,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2021-12-26 23:39:38.341443",
+ "modified": "2022-01-03 11:56:19.812863",
"modified_by": "Administrator",
"module": "Core",
"name": "DocField",
@@ -540,5 +548,6 @@
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
- "sort_order": "ASC"
+ "sort_order": "ASC",
+ "states": []
}
diff --git a/frappe/core/doctype/doctype/doctype.js b/frappe/core/doctype/doctype/doctype.js
index 262a6efd90..b907ebc0bc 100644
--- a/frappe/core/doctype/doctype/doctype.js
+++ b/frappe/core/doctype/doctype/doctype.js
@@ -1,16 +1,6 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// MIT License. See license.txt
-// -------------
-// Menu Display
-// -------------
-
-// $(cur_frm.wrapper).on("grid-row-render", function(e, grid_row) {
-// if(grid_row.doc && grid_row.doc.fieldtype=="Section Break") {
-// $(grid_row.row).css({"font-weight": "bold"});
-// }
-// })
-
frappe.ui.form.on('DocType', {
refresh: function(frm) {
frm.set_query('role', 'permissions', function(doc) {
@@ -129,7 +119,7 @@ frappe.ui.form.on('DocType', {
}
frm.set_df_property('fields', 'reqd', frm.doc.autoname !== 'Prompt');
- }
+ },
});
frappe.ui.form.on("DocField", {
@@ -153,11 +143,10 @@ frappe.ui.form.on("DocField", {
curr_value.doctype = doctype;
curr_value.fieldname = fieldname;
}
- let curr_df_link_doctype = row.fieldtype == "Link" ? row.options : null;
let doctypes = frm.doc.fields
.filter(df => df.fieldtype == "Link")
- .filter(df => df.options && df.options != curr_df_link_doctype)
+ .filter(df => df.options && df.fieldname != row.fieldname)
.map(df => ({
label: `${df.options} (${df.fieldname})`,
value: df.fieldname
@@ -217,5 +206,11 @@ frappe.ui.form.on("DocField", {
$doctype_select.val(curr_value.doctype);
update_fieldname_options();
}
+ },
+
+ fieldtype: function(frm) {
+ frm.trigger("max_attachments");
}
});
+
+extend_cscript(cur_frm.cscript, new frappe.model.DocTypeController({frm: cur_frm}));
diff --git a/frappe/core/doctype/doctype/doctype.json b/frappe/core/doctype/doctype/doctype.json
index e18edc1512..03e3b65ea1 100644
--- a/frappe/core/doctype/doctype/doctype.json
+++ b/frappe/core/doctype/doctype/doctype.json
@@ -1,686 +1,700 @@
{
- "actions": [],
- "allow_rename": 1,
- "autoname": "Prompt",
- "creation": "2013-02-18 13:36:19",
- "description": "DocType is a Table / Form in the application.",
- "doctype": "DocType",
- "document_type": "Document",
- "engine": "InnoDB",
- "field_order": [
- "sb0",
- "module",
- "is_submittable",
- "istable",
- "issingle",
- "is_tree",
- "editable_grid",
- "quick_entry",
- "cb01",
- "track_changes",
- "track_seen",
- "track_views",
- "custom",
- "beta",
- "is_virtual",
- "fields_section_break",
- "fields",
- "sb1",
- "naming_rule",
- "autoname",
- "name_case",
- "allow_rename",
- "column_break_15",
- "description",
- "documentation",
- "form_settings_section",
- "image_field",
- "timeline_field",
- "nsm_parent_field",
- "max_attachments",
- "column_break_23",
- "hide_toolbar",
- "allow_copy",
- "allow_import",
- "allow_events_in_timeline",
- "allow_auto_repeat",
- "view_settings",
- "title_field",
- "search_fields",
- "default_print_format",
- "sort_field",
- "sort_order",
- "column_break_29",
- "document_type",
- "icon",
- "color",
- "show_preview_popup",
- "show_name_in_global_search",
- "email_settings_sb",
- "default_email_template",
- "column_break_51",
- "email_append_to",
- "sender_field",
- "subject_field",
- "sb2",
- "permissions",
- "restrict_to_domain",
- "read_only",
- "in_create",
- "actions_section",
- "actions",
- "links_section",
- "links",
- "web_view",
- "has_web_view",
- "allow_guest_to_view",
- "index_web_pages_for_search",
- "route",
- "is_published_field",
- "website_search_field",
- "advanced",
- "engine",
- "migration_hash"
- ],
- "fields": [
- {
- "fieldname": "sb0",
- "fieldtype": "Section Break",
- "oldfieldtype": "Section Break"
- },
- {
- "fieldname": "module",
- "fieldtype": "Link",
- "in_list_view": 1,
- "in_standard_filter": 1,
- "label": "Module",
- "oldfieldname": "module",
- "oldfieldtype": "Link",
- "options": "Module Def",
- "reqd": 1,
- "search_index": 1
- },
- {
- "default": "0",
- "depends_on": "eval:!doc.istable",
- "description": "Once submitted, submittable documents cannot be changed. They can only be Cancelled and Amended.",
- "fieldname": "is_submittable",
- "fieldtype": "Check",
- "label": "Is Submittable"
- },
- {
- "default": "0",
- "description": "Child Tables are shown as a Grid in other DocTypes",
- "fieldname": "istable",
- "fieldtype": "Check",
- "in_standard_filter": 1,
- "label": "Is Child Table",
- "oldfieldname": "istable",
- "oldfieldtype": "Check"
- },
- {
- "default": "0",
- "depends_on": "eval:!doc.istable",
- "description": "Single Types have only one record no tables associated. Values are stored in tabSingles",
- "fieldname": "issingle",
- "fieldtype": "Check",
- "in_standard_filter": 1,
- "label": "Is Single",
- "oldfieldname": "issingle",
- "oldfieldtype": "Check",
- "set_only_once": 1
- },
- {
- "default": "1",
- "depends_on": "istable",
- "fieldname": "editable_grid",
- "fieldtype": "Check",
- "label": "Editable Grid"
- },
- {
- "default": "0",
- "depends_on": "eval:!doc.istable && !doc.issingle",
- "description": "Open a dialog with mandatory fields to create a new record quickly",
- "fieldname": "quick_entry",
- "fieldtype": "Check",
- "label": "Quick Entry"
- },
- {
- "fieldname": "cb01",
- "fieldtype": "Column Break"
- },
- {
- "default": "0",
- "depends_on": "eval:!doc.istable",
- "description": "If enabled, changes to the document are tracked and shown in timeline",
- "fieldname": "track_changes",
- "fieldtype": "Check",
- "label": "Track Changes"
- },
- {
- "default": "0",
- "depends_on": "eval:!doc.istable",
- "description": "If enabled, the document is marked as seen, the first time a user opens it",
- "fieldname": "track_seen",
- "fieldtype": "Check",
- "label": "Track Seen"
- },
- {
- "default": "0",
- "depends_on": "eval:!doc.istable",
- "description": "If enabled, document views are tracked, this can happen multiple times",
- "fieldname": "track_views",
- "fieldtype": "Check",
- "label": "Track Views"
- },
- {
- "default": "0",
- "fieldname": "custom",
- "fieldtype": "Check",
- "label": "Custom?"
- },
- {
- "default": "0",
- "fieldname": "beta",
- "fieldtype": "Check",
- "label": "Beta"
- },
- {
- "fieldname": "fields_section_break",
- "fieldtype": "Section Break",
- "label": "Fields",
- "oldfieldtype": "Section Break"
- },
- {
- "fieldname": "fields",
- "fieldtype": "Table",
- "label": "Fields",
- "oldfieldname": "fields",
- "oldfieldtype": "Table",
- "options": "DocField"
- },
- {
- "fieldname": "sb1",
- "fieldtype": "Section Break",
- "label": "Naming"
- },
- {
- "description": "Naming Options:\n
field:[fieldname] - By Field
naming_series: - By Naming Series (field called naming_series must be present
Prompt - Prompt user for a name
[series] - Series by prefix (separated by a dot); for example PRE.#####
\n
format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####} - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.
naming_series: - By Naming Series (field called naming_series must be present
Prompt - Prompt user for a name
[series] - Series by prefix (separated by a dot); for example PRE.#####
\n
format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####} - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.