diff --git a/.github/helper/documentation.py b/.github/helper/documentation.py
index 08d1d1aa9c..f8ee3fa10b 100644
--- a/.github/helper/documentation.py
+++ b/.github/helper/documentation.py
@@ -32,9 +32,9 @@ if __name__ == "__main__":
if response.ok:
payload = response.json()
- title = payload.get("title", "").lower()
- head_sha = payload.get("head", {}).get("sha")
- body = payload.get("body", "").lower()
+ title = (payload.get("title") or "").lower()
+ head_sha = (payload.get("head") or {}).get("sha")
+ body = (payload.get("body") or "").lower()
if title.startswith("feat") and head_sha and "no-docs" not in body:
if docs_link_exists(body):
diff --git a/.github/workflows/patch-mariadb-tests.yml b/.github/workflows/patch-mariadb-tests.yml
index e8627a01fb..82be4d06b5 100644
--- a/.github/workflows/patch-mariadb-tests.yml
+++ b/.github/workflows/patch-mariadb-tests.yml
@@ -1,6 +1,11 @@
name: Patch
-on: [pull_request, workflow_dispatch]
+on:
+ pull_request:
+ paths-ignore:
+ - '**.js'
+ - '**.md'
+ workflow_dispatch:
jobs:
test:
diff --git a/.github/workflows/server-mariadb-tests.yml b/.github/workflows/server-mariadb-tests.yml
index 2476102e3d..8d5bd690a1 100644
--- a/.github/workflows/server-mariadb-tests.yml
+++ b/.github/workflows/server-mariadb-tests.yml
@@ -2,9 +2,15 @@ name: Server
on:
pull_request:
+ paths-ignore:
+ - '**.js'
+ - '**.md'
workflow_dispatch:
push:
branches: [ develop ]
+ paths-ignore:
+ - '**.js'
+ - '**.md'
jobs:
test:
diff --git a/.github/workflows/server-postgres-tests.yml b/.github/workflows/server-postgres-tests.yml
index 4325eebaad..8c97c7f84b 100644
--- a/.github/workflows/server-postgres-tests.yml
+++ b/.github/workflows/server-postgres-tests.yml
@@ -2,6 +2,9 @@ name: Server
on:
pull_request:
+ paths-ignore:
+ - '**.js'
+ - '**.md'
workflow_dispatch:
jobs:
diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml
index f342c0709e..d76e5e77ea 100644
--- a/.github/workflows/ui-tests.yml
+++ b/.github/workflows/ui-tests.yml
@@ -2,6 +2,8 @@ name: UI
on:
pull_request:
+ paths-ignore:
+ - '**.md'
workflow_dispatch:
push:
branches: [ develop ]
diff --git a/.mergify.yml b/.mergify.yml
index c759c1e3ec..1a81a28594 100644
--- a/.mergify.yml
+++ b/.mergify.yml
@@ -1,4 +1,16 @@
pull_request_rules:
+ - name: Auto-close PRs on stable branch
+ conditions:
+ - or:
+ - base=version-13
+ - base=version-12
+ actions:
+ close:
+ comment:
+ message: |
+ @{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on an appropriate hotfix branch.
+ https://github.com/frappe/erpnext/wiki/Pull-Request-Checklist#which-branch
+
- name: Automatic merge on CI success and review
conditions:
- status-success=Sider
diff --git a/cypress/integration/awesome_bar.js b/cypress/integration/awesome_bar.js
index 3e12101532..fb09b384a8 100644
--- a/cypress/integration/awesome_bar.js
+++ b/cypress/integration/awesome_bar.js
@@ -10,9 +10,9 @@ context('Awesome Bar', () => {
});
it('navigates to doctype list', () => {
- cy.get('#navbar-search').type('todo', { delay: 200 });
- cy.get('#navbar-search + ul').should('be.visible');
- cy.get('#navbar-search').type('{downarrow}{enter}', { delay: 100 });
+ cy.findByPlaceholderText('Search or type a command (Ctrl + G)').type('todo', { delay: 200 });
+ cy.get('.awesomplete').findByRole('listbox').should('be.visible');
+ cy.findByPlaceholderText('Search or type a command (Ctrl + G)').type('{downarrow}{enter}', { delay: 100 });
cy.get('.title-text').should('contain', 'To Do');
@@ -20,24 +20,24 @@ context('Awesome Bar', () => {
});
it('find text in doctype list', () => {
- cy.get('#navbar-search')
+ cy.findByPlaceholderText('Search or type a command (Ctrl + G)')
.type('test in todo{downarrow}{enter}', { delay: 200 });
cy.get('.title-text').should('contain', 'To Do');
- cy.get('[data-original-title="Name"] > .input-with-feedback')
+ cy.findByPlaceholderText('Name')
.should('have.value', '%test%');
});
it('navigates to new form', () => {
- cy.get('#navbar-search')
+ cy.findByPlaceholderText('Search or type a command (Ctrl + G)')
.type('new blog post{downarrow}{enter}', { delay: 200 });
cy.get('.title-text:visible').should('have.text', 'New Blog Post');
});
it('calculates math expressions', () => {
- cy.get('#navbar-search')
+ cy.findByPlaceholderText('Search or type a command (Ctrl + G)')
.type('55 + 32{downarrow}{enter}', { delay: 200 });
cy.get('.modal-title').should('contain', 'Result');
diff --git a/cypress/integration/control_barcode.js b/cypress/integration/control_barcode.js
index 1df5e64f0e..5f1ab86d41 100644
--- a/cypress/integration/control_barcode.js
+++ b/cypress/integration/control_barcode.js
@@ -20,7 +20,7 @@ context('Control Barcode', () => {
it('should generate barcode on setting a value', () => {
get_dialog_with_barcode().as('dialog');
- cy.get('.frappe-control[data-fieldname=barcode] input')
+ cy.get('.frappe-control[data-fieldname=barcode]').findByRole('textbox')
.focus()
.type('123456789')
.blur();
@@ -37,11 +37,11 @@ context('Control Barcode', () => {
it('should reset when input is cleared', () => {
get_dialog_with_barcode().as('dialog');
- cy.get('.frappe-control[data-fieldname=barcode] input')
+ cy.get('.frappe-control[data-fieldname=barcode]').findByRole('textbox')
.focus()
.type('123456789')
.blur();
- cy.get('.frappe-control[data-fieldname=barcode] input')
+ cy.get('.frappe-control[data-fieldname=barcode]').findByRole('textbox')
.clear()
.blur();
cy.get('.frappe-control[data-fieldname=barcode] svg[data-barcode-value="123456789"]')
diff --git a/cypress/integration/control_icon.js b/cypress/integration/control_icon.js
index f92927f267..5c531a0823 100644
--- a/cypress/integration/control_icon.js
+++ b/cypress/integration/control_icon.js
@@ -17,17 +17,17 @@ context('Control Icon', () => {
it('should set icon', () => {
get_dialog_with_icon().as('dialog');
- cy.get('.frappe-control[data-fieldname=icon] input').first().click();
+ cy.get('.frappe-control[data-fieldname=icon]').findByRole('textbox').click();
cy.get('.icon-picker .icon-wrapper[id=active]').first().click();
- cy.get('.frappe-control[data-fieldname=icon] input').first().should('have.value', 'active');
+ cy.get('.frappe-control[data-fieldname=icon]').findByRole('textbox').should('have.value', 'active');
cy.get('@dialog').then(dialog => {
let value = dialog.get_value('icon');
expect(value).to.equal('active');
});
cy.get('.icon-picker .icon-wrapper[id=resting]').first().click();
- cy.get('.frappe-control[data-fieldname=icon] input').first().should('have.value', 'resting');
+ cy.get('.frappe-control[data-fieldname=icon]').findByRole('textbox').should('have.value', 'resting');
cy.get('@dialog').then(dialog => {
let value = dialog.get_value('icon');
expect(value).to.equal('resting');
@@ -36,14 +36,14 @@ context('Control Icon', () => {
it('search for icon and clear search input', () => {
let search_text = 'ed';
- cy.get('.icon-picker input[type=search]').first().click().type(search_text);
+ cy.get('.icon-picker').findByRole('searchbox').click().type(search_text);
cy.get('.icon-section .icon-wrapper:not(.hidden)').then(i => {
cy.get(`.icon-section .icon-wrapper[id*='${search_text}']`).then(icons => {
expect(i.length).to.equal(icons.length);
});
});
- cy.get('.icon-picker input[type=search]').clear().blur();
+ cy.get('.icon-picker').findByRole('searchbox').clear().blur();
cy.get('.icon-section .icon-wrapper').should('not.have.class', 'hidden');
});
diff --git a/cypress/integration/control_link.js b/cypress/integration/control_link.js
index 8f9257e9c4..7d44a71d06 100644
--- a/cypress/integration/control_link.js
+++ b/cypress/integration/control_link.js
@@ -35,7 +35,7 @@ context('Control Link', () => {
cy.wait('@search_link');
cy.get('@input').type('todo for link', { delay: 200 });
cy.wait('@search_link');
- cy.get('.frappe-control[data-fieldname=link] ul').should('be.visible');
+ cy.get('.frappe-control[data-fieldname=link]').findByRole('listbox').should('be.visible');
cy.get('.frappe-control[data-fieldname=link] input').type('{enter}', { delay: 100 });
cy.get('.frappe-control[data-fieldname=link] input').blur();
cy.get('@dialog').then(dialog => {
@@ -71,7 +71,7 @@ context('Control Link', () => {
cy.get('@input').type(todos[0]).blur();
cy.wait('@validate_link');
cy.get('@input').focus();
- cy.get('.frappe-control[data-fieldname=link] .link-btn')
+ cy.findByTitle('Open Link')
.should('be.visible')
.click();
cy.location('pathname').should('eq', `/app/todo/${todos[0]}`);
diff --git a/cypress/integration/control_select.js b/cypress/integration/control_select.js
index 0bc719b4a7..8e18d21260 100644
--- a/cypress/integration/control_select.js
+++ b/cypress/integration/control_select.js
@@ -24,8 +24,10 @@ context('Control Select', () => {
cy.get('@control').get('.select-icon').should('exist');
cy.get('@control').get('.placeholder').should('have.css', 'display', 'block');
cy.get('@select').select('Option 1');
+ cy.findByDisplayValue('Option 1').should('exist');
cy.get('@control').get('.placeholder').should('have.css', 'display', 'none');
cy.get('@select').invoke('val', '');
+ cy.findByDisplayValue('Option 1').should('not.exist');
cy.get('@control').get('.placeholder').should('have.css', 'display', 'block');
diff --git a/cypress/integration/depends_on.js b/cypress/integration/depends_on.js
index d33babb134..9aa6b5d89d 100644
--- a/cypress/integration/depends_on.js
+++ b/cypress/integration/depends_on.js
@@ -62,11 +62,11 @@ context('Depends On', () => {
it('should set the field as mandatory depending on other fields value', () => {
cy.new_form('Test Depends On');
cy.fill_field('test_field', 'Some Value');
- cy.get('button.primary-action').contains('Save').click();
+ cy.findByRole('button', {name: 'Save'}).click();
cy.get('.msgprint-dialog .modal-title').contains('Missing Fields').should('be.visible');
cy.hide_dialog();
cy.fill_field('test_field', 'Random value');
- cy.get('button.primary-action').contains('Save').click();
+ cy.findByRole('button', {name: 'Save'}).click();
cy.get('.msgprint-dialog .modal-title').contains('Missing Fields').should('not.be.visible');
});
it('should set the field as read only depending on other fields value', () => {
@@ -84,7 +84,7 @@ context('Depends On', () => {
cy.fill_field('dependant_field', 'Some Value');
//cy.fill_field('test_field', 'Some Other Value');
cy.get('.frappe-control[data-fieldname="child_test_depends_on_field"]').as('table');
- cy.get('@table').find('button.grid-add-row').click();
+ cy.get('@table').findByRole('button', {name: 'Add Row'}).click();
cy.get('@table').find('[data-idx="1"]').as('row1');
cy.get('@row1').find('.btn-open-row').click();
cy.get('@row1').find('.form-in-grid').as('row1-form_in_grid');
diff --git a/cypress/integration/file_uploader.js b/cypress/integration/file_uploader.js
index e1e232c058..3d4f92df3c 100644
--- a/cypress/integration/file_uploader.js
+++ b/cypress/integration/file_uploader.js
@@ -25,7 +25,7 @@ context('FileUploader', () => {
cy.get_open_dialog().find('.file-name').should('contain', 'example.json');
cy.intercept('POST', '/api/method/upload_file').as('upload_file');
- cy.get_open_dialog().find('.btn-modal-primary').click();
+ cy.get_open_dialog().findByRole('button', {name: 'Upload'}).click();
cy.wait('@upload_file').its('response.statusCode').should('eq', 200);
cy.get('.modal:visible').should('not.exist');
});
@@ -33,11 +33,11 @@ context('FileUploader', () => {
it('should accept uploaded files', () => {
open_upload_dialog();
- cy.get_open_dialog().find('.btn-file-upload div:contains("Library")').click();
- cy.get('.file-filter').type('example.json');
- cy.get_open_dialog().find('.tree-label:contains("example.json")').first().click();
+ cy.get_open_dialog().findByRole('button', {name: 'Library'}).click();
+ cy.findByPlaceholderText('Search by filename or extension').type('example.json');
+ cy.get_open_dialog().findAllByText('example.json').first().click();
cy.intercept('POST', '/api/method/upload_file').as('upload_file');
- cy.get_open_dialog().find('.btn-primary').click();
+ cy.get_open_dialog().findByRole('button', {name: 'Upload'}).click();
cy.wait('@upload_file').its('response.body.message')
.should('have.property', 'file_name', 'example.json');
cy.get('.modal:visible').should('not.exist');
@@ -46,10 +46,12 @@ context('FileUploader', () => {
it('should accept web links', () => {
open_upload_dialog();
- cy.get_open_dialog().find('.btn-file-upload div:contains("Link")').click();
- cy.get_open_dialog().find('.file-web-link input').type('https://github.com', { delay: 100, force: true });
+ cy.get_open_dialog().findByRole('button', {name: 'Link'}).click();
+ cy.get_open_dialog()
+ .findByPlaceholderText('Attach a web link')
+ .type('https://github.com', { delay: 100, force: true });
cy.intercept('POST', '/api/method/upload_file').as('upload_file');
- cy.get_open_dialog().find('.btn-primary').click();
+ cy.get_open_dialog().findByRole('button', {name: 'Upload'}).click();
cy.wait('@upload_file').its('response.body.message')
.should('have.property', 'file_url', 'https://github.com');
cy.get('.modal:visible').should('not.exist');
@@ -62,15 +64,14 @@ context('FileUploader', () => {
subjectType: 'drag-n-drop',
});
- cy.get_open_dialog().find('.file-name').should('contain', 'sample_image.jpg');
+ cy.get_open_dialog().findAllByText('sample_image.jpg').should('exist');
cy.get_open_dialog().find('.btn-crop').first().click();
- cy.get_open_dialog().find('.image-cropper-actions > .btn-primary').should('contain', 'Crop');
- cy.get_open_dialog().find('.image-cropper-actions > .btn-primary').click();
- cy.get_open_dialog().find('.optimize-checkbox').first().should('contain', 'Optimize');
- cy.get_open_dialog().find('.optimize-checkbox').first().click();
+ cy.get_open_dialog().findByRole('button', {name: 'Crop'}).click();
+ cy.get_open_dialog().findAllByRole('checkbox', {name: 'Optimize'}).should('exist');
+ cy.get_open_dialog().findAllByLabelText('Optimize').first().click();
cy.intercept('POST', '/api/method/upload_file').as('upload_file');
- cy.get_open_dialog().find('.btn-modal-primary').click();
+ cy.get_open_dialog().findByRole('button', {name: 'Upload'}).click();
cy.wait('@upload_file').its('response.statusCode').should('eq', 200);
cy.get('.modal:visible').should('not.exist');
});
diff --git a/cypress/integration/form.js b/cypress/integration/form.js
index 909955c1df..d20750b1d5 100644
--- a/cypress/integration/form.js
+++ b/cypress/integration/form.js
@@ -26,7 +26,7 @@ context('Form', () => {
cy.visit('/app/contact');
cy.add_filter();
cy.get('.filter-field .input-with-feedback.form-control').type('123', { force: true });
- cy.get('.filter-popover .apply-filters').click({ force: true });
+ cy.findByRole('button', {name: 'Apply Filters'}).click({ force: true });
cy.visit('/app/contact/Test Form Contact 3');
cy.get('.prev-doc').should('be.visible').click();
cy.get('.msgprint-dialog .modal-body').contains('No further records').should('be.visible');
diff --git a/cypress/integration/form_tour.js b/cypress/integration/form_tour.js
index d12be63f3b..d2d39679a8 100644
--- a/cypress/integration/form_tour.js
+++ b/cypress/integration/form_tour.js
@@ -9,7 +9,7 @@ context('Form Tour', () => {
const open_test_form_tour = () => {
cy.visit('/app/form-tour/Test Form Tour');
- cy.get('button[data-label="Show%20Tour"]').should('be.visible').and('contain', 'Show Tour').as('show_tour');
+ cy.findByRole('button', {name: 'Show Tour'}).should('be.visible').as('show_tour');
cy.get('@show_tour').click();
cy.wait(500);
cy.url().should('include', '/app/contact');
@@ -23,7 +23,7 @@ context('Form Tour', () => {
cy.get('#driver-popover-item').should('be.visible');
cy.get('.frappe-control[data-fieldname="first_name"]').as('first_name');
cy.get('@first_name').should('have.class', 'driver-highlighted-element');
- cy.get('.driver-next-btn').as('next_btn');
+ cy.get('#driver-popover-item').findByRole('button', {name: 'Next'}).as('next_btn');
// next btn shouldn't move to next step, if first name is not entered
cy.get('@next_btn').click();
@@ -39,7 +39,7 @@ context('Form Tour', () => {
// assert field is highlighted
cy.get('.frappe-control[data-fieldname="last_name"]').as('last_name');
cy.get('@last_name').should('have.class', 'driver-highlighted-element');
-
+
// after filling the field, next step should be highlighted
cy.fill_field('last_name', 'Test Last Name', 'Data');
cy.wait(500);
@@ -49,12 +49,12 @@ context('Form Tour', () => {
// assert field is highlighted
cy.get('.frappe-control[data-fieldname="phone_nos"]').as('phone_nos');
cy.get('@phone_nos').should('have.class', 'driver-highlighted-element');
-
+
// move to next step
cy.wait(500);
cy.get('@next_btn').click();
cy.wait(500);
-
+
// assert add row btn is highlighted
cy.get('@phone_nos').find('.grid-add-row').as('add_row');
cy.get('@add_row').should('have.class', 'driver-highlighted-element');
@@ -78,11 +78,11 @@ context('Form Tour', () => {
// collapse row
cy.get('.grid-row-open .grid-collapse-row').click();
cy.wait(500);
-
+
// assert save btn is highlighted
cy.get('.primary-action').should('have.class', 'driver-highlighted-element');
- cy.get('@next_btn').should('contain', 'Save');
+ cy.wait(500);
+ cy.get('#driver-popover-item').findByRole('button', {name: 'Save'}).should('be.visible');
});
});
-
\ No newline at end of file
diff --git a/cypress/integration/grid_pagination.js b/cypress/integration/grid_pagination.js
index 8f6b79c1f4..c07230d2b8 100644
--- a/cypress/integration/grid_pagination.js
+++ b/cypress/integration/grid_pagination.js
@@ -30,12 +30,12 @@ context('Grid Pagination', () => {
it('adds and deletes rows and changes page', () => {
cy.visit('/app/contact/Test Contact');
cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table');
- cy.get('@table').find('button.grid-add-row').click();
+ 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('.total-page-number').should('contain', '21');
cy.get('@table').find('.grid-body .grid-row .grid-row-check').click({ force: true });
- cy.get('@table').find('button.grid-remove-rows').click();
+ 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('.total-page-number').should('contain', '20');
diff --git a/cypress/integration/list_view_settings.js b/cypress/integration/list_view_settings.js
index 52512b911e..61d4b8aae5 100644
--- a/cypress/integration/list_view_settings.js
+++ b/cypress/integration/list_view_settings.js
@@ -17,9 +17,9 @@ context('List View Settings', () => {
cy.get('.dropdown-menu li').filter(':visible').contains('List Settings').click();
cy.get('.modal-dialog').should('contain', 'DocType Settings');
- cy.get('input[data-fieldname="disable_count"]').check({ force: true });
- cy.get('input[data-fieldname="disable_sidebar_stats"]').check({ force: true });
- cy.get('button').filter(':visible').contains('Save').click();
+ cy.findByLabelText('Disable Count').check({ force: true });
+ cy.findByLabelText('Disable Sidebar Stats').check({ force: true });
+ cy.findByRole('button', {name: 'Save'}).click();
cy.reload({ force: true });
@@ -29,8 +29,8 @@ context('List View Settings', () => {
cy.get('.menu-btn-group button').click({ force: true });
cy.get('.dropdown-menu li').filter(':visible').contains('List Settings').click();
cy.get('.modal-dialog').should('contain', 'DocType Settings');
- cy.get('input[data-fieldname="disable_count"]').uncheck({ force: true });
- cy.get('input[data-fieldname="disable_sidebar_stats"]').uncheck({ force: true });
- cy.get('button').filter(':visible').contains('Save').click();
+ cy.findByLabelText('Disable Count').uncheck({ force: true });
+ cy.findByLabelText('Disable Sidebar Stats').uncheck({ force: true });
+ cy.findByRole('button', {name: 'Save'}).click();
});
});
diff --git a/cypress/integration/login.js b/cypress/integration/login.js
index 6b109dd18d..98739bb4c9 100644
--- a/cypress/integration/login.js
+++ b/cypress/integration/login.js
@@ -11,13 +11,13 @@ context('Login', () => {
it('validates password', () => {
cy.get('#login_email').type('Administrator');
- cy.get('.btn-login:visible').click();
+ cy.findByRole('button', {name: 'Login'}).click();
cy.location('pathname').should('eq', '/login');
});
it('validates email', () => {
cy.get('#login_password').type('qwe');
- cy.get('.btn-login:visible').click();
+ cy.findByRole('button', {name: 'Login'}).click();
cy.location('pathname').should('eq', '/login');
});
@@ -25,8 +25,8 @@ context('Login', () => {
cy.get('#login_email').type('Administrator');
cy.get('#login_password').type('qwer');
- cy.get('.btn-login:visible').click();
- cy.get('.btn-login:visible').contains('Invalid Login. Try again.');
+ cy.findByRole('button', {name: 'Login'}).click();
+ cy.findByRole('button', {name: 'Invalid Login. Try again.'}).should('exist');
cy.location('pathname').should('eq', '/login');
});
@@ -34,7 +34,7 @@ context('Login', () => {
cy.get('#login_email').type('Administrator');
cy.get('#login_password').type(Cypress.config('adminPassword'));
- cy.get('.btn-login:visible').click();
+ cy.findByRole('button', {name: 'Login'}).click();
cy.location('pathname').should('eq', '/app');
cy.window().its('frappe.session.user').should('eq', 'Administrator');
});
@@ -60,7 +60,7 @@ context('Login', () => {
cy.get('#login_email').type('Administrator');
cy.get('#login_password').type(Cypress.config('adminPassword'));
- cy.get('.btn-login:visible').click();
+ cy.findByRole('button', {name: 'Login'}).click();
// verify redirected location and url params after login
cy.url().should('include', '/me?' + payload.toString().replace('+', '%20'));
diff --git a/cypress/integration/recorder.js b/cypress/integration/recorder.js
index 5b7692d8ff..7a62b2e6d9 100644
--- a/cypress/integration/recorder.js
+++ b/cypress/integration/recorder.js
@@ -16,24 +16,24 @@ context('Recorder', () => {
it('Navigate to Recorder', () => {
cy.visit('/app');
cy.awesomebar('recorder');
- cy.get('h3').should('contain', 'Recorder');
+ cy.findByTitle('Recorder').should('exist');
cy.url().should('include', '/recorder/detail');
});
it('Recorder Empty State', () => {
- cy.get('.title-text').should('contain', 'Recorder');
+ cy.findByTitle('Recorder').should('exist');
cy.get('.indicator-pill').should('contain', 'Inactive').should('have.class', 'red');
- cy.get('.primary-action').should('contain', 'Start');
- cy.get('.btn-secondary').should('contain', 'Clear');
+ cy.findByRole('button', {name: 'Start'}).should('exist');
+ cy.findByRole('button', {name: 'Clear'}).should('exist');
cy.get('.msg-box').should('contain', 'Inactive');
- cy.get('.msg-box .btn-primary').should('contain', 'Start Recording');
+ cy.findByRole('button', {name: 'Start Recording'}).should('exist');
});
it('Recorder Start', () => {
- cy.get('.primary-action').should('contain', 'Start').click();
+ cy.findByRole('button', {name: 'Start'}).click();
cy.get('.indicator-pill').should('contain', 'Active').should('have.class', 'green');
cy.get('.msg-box').should('contain', 'No Requests');
@@ -46,12 +46,12 @@ context('Recorder', () => {
cy.get('.list-count').should('contain', '20 of ');
cy.visit('/app/recorder');
- cy.get('.title-text').should('contain', 'Recorder');
+ cy.findByTitle('Recorder').should('exist');
cy.get('.result-list').should('contain', '/api/method/frappe.desk.reportview.get');
});
it('Recorder View Request', () => {
- cy.get('.primary-action').should('contain', 'Start').click();
+ cy.findByRole('button', {name: 'Start'}).click();
cy.visit('/app/List/DocType/List');
cy.intercept('POST', '/api/method/frappe.desk.reportview.get').as('list_refresh');
diff --git a/cypress/integration/report_view.js b/cypress/integration/report_view.js
index ea76246ae2..e762eebea1 100644
--- a/cypress/integration/report_view.js
+++ b/cypress/integration/report_view.js
@@ -23,7 +23,7 @@ context('Report View', () => {
let cell = cy.get('.dt-row-0 > .dt-cell--col-4');
// select the cell
cell.dblclick();
- cell.find('input[data-fieldname="enabled"]').check({ force: true });
+ cell.findByRole('checkbox').check({ force: true });
cy.get('.dt-row-0 > .dt-cell--col-5').click();
cy.wait('@value-update');
cy.get('@doc').then(doc => {
diff --git a/cypress/integration/timeline.js b/cypress/integration/timeline.js
index c7bbe29e5a..7a8f3a159b 100644
--- a/cypress/integration/timeline.js
+++ b/cypress/integration/timeline.js
@@ -10,26 +10,26 @@ context('Timeline', () => {
it('Adding new ToDo, adding new comment, verifying comment addition & deletion and deleting ToDo', () => {
//Adding new ToDo
cy.click_listview_primary_button('Add ToDo');
- cy.get('.modal-footer > .custom-actions > .btn').contains('Edit in full page').click();
- cy.get('.row > .section-body > .form-column > form > .frappe-control > .form-group > .control-input-wrapper > .control-input > .ql-container > .ql-editor').eq(0).type('Test ToDo', {force: true});
+ cy.findByRole('button', {name: 'Edit in full page'}).click();
+ cy.get('[data-fieldname="description"] .ql-editor').eq(0).type('Test ToDo', {force: true});
cy.wait(200);
- cy.get('#page-ToDo > .page-head > .container > .row > .col > .standard-actions > .primary-action').contains('Save').click();
+ cy.findByRole('button', {name: 'Save'}).click();
cy.wait(700);
cy.visit('/app/todo');
- cy.get('.list-row > .level-left > .list-subject > .level-item.ellipsis > .ellipsis').eq(0).click();
+ cy.get('.level-item.ellipsis').eq(0).click();
//To check if the comment box is initially empty and tying some text into it
- cy.get('.comment-input-container > .frappe-control > .ql-container > .ql-editor').should('contain', '').type('Testing Timeline');
+ cy.get('[data-fieldname="comment"] .ql-editor').should('contain', '').type('Testing Timeline');
//Adding new comment
- cy.get('.comment-input-wrapper > .btn').contains('Comment').click();
+ cy.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');
//Editing comment
cy.click_timeline_action_btn(0);
- cy.get('.timeline-content > .timeline-message-box > .comment-edit-box > .frappe-control > .ql-container > .ql-editor').first().type(' 123');
+ cy.get('.timeline-content [data-fieldname="comment"] .ql-editor').first().type(' 123');
cy.click_timeline_action_btn(0);
//To check if the edited comment text is visible in timeline content
@@ -37,20 +37,20 @@ context('Timeline', () => {
//Discarding comment
cy.click_timeline_action_btn(0);
- cy.get('.actions > .btn').eq(1).first().click();
+ cy.findByRole('button', {name: 'Dismiss'}).click();
//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('.actions > .btn > .icon').first().click();
- cy.get('.modal-footer > .standard-actions > .btn-primary').contains('Yes').click();
+ cy.findByRole('button', {name: 'Yes'}).click();
cy.click_modal_primary_button('Yes');
//Deleting the added ToDo
- cy.get('#page-ToDo > .page-head > .container > .row > .col > .standard-actions > .menu-btn-group > .btn').click({force: true});
- cy.get('.menu-btn-group > .dropdown-menu > li > .grey-link').eq(17).click({force: true});
- cy.get('.modal.show > .modal-dialog > .modal-content > .modal-footer > .standard-actions > .btn-primary').contains('Yes').click({force: true});
+ cy.get('.menu-btn-group button').eq(1).click();
+ cy.get('.menu-btn-group [data-label="Delete"]').click();
+ cy.findByRole('button', {name: 'Yes'}).click();
});
it('Timeline should have submit and cancel activity information', () => {
@@ -64,31 +64,31 @@ context('Timeline', () => {
//Adding a new entry for the created custom doctype
cy.fill_field('title', 'Test');
- cy.get('.modal-footer > .standard-actions > .btn-primary').contains('Save').click();
- cy.get('.modal-footer > .standard-actions > .btn-primary').contains('Submit').click();
+ cy.findByRole('button', {name: 'Save'}).click();
+ cy.findByRole('button', {name: 'Submit'}).click();
cy.visit('/app/custom-submittable-doctype');
cy.get('.list-subject > .bold > .ellipsis').eq(0).click();
//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.get('.page-actions > .standard-actions > .btn-secondary').contains('Cancel').click({delay: 900});
- cy.get('.modal-footer > .standard-actions > .btn-primary').contains('Yes').click();
-
+ cy.findByRole('button', {name: 'Cancel'}).click({delay: 900});
+ cy.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.get('.page-actions > .standard-actions > .actions-btn-group > .btn').contains('Actions').click();
+ cy.findByRole('button', {name: 'Actions'}).click();
cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(7).click();
cy.click_modal_primary_button('Yes', {force: true, delay: 700});
//Deleting the custom doctype
cy.visit('/app/doctype');
cy.get('.list-subject > .select-like > .list-row-checkbox').eq(0).click();
- cy.get('.page-actions > .standard-actions > .actions-btn-group > .btn').contains('Actions').click();
- cy.get('.actions-btn-group > .dropdown-menu > li > .grey-link').eq(5).click();
+ cy.findByRole('button', {name: 'Actions'}).click();
+ cy.get('.actions-btn-group [data-label="Delete"]').click();
cy.click_modal_primary_button('Yes');
});
});
\ No newline at end of file
diff --git a/cypress/integration/workspace.js b/cypress/integration/workspace.js
new file mode 100644
index 0000000000..f18e48aadc
--- /dev/null
+++ b/cypress/integration/workspace.js
@@ -0,0 +1,90 @@
+context('Workspace 2.0', () => {
+ before(() => {
+ cy.visit('/login');
+ cy.login();
+ cy.visit('/app/website');
+ });
+
+ it('Navigate to page from sidebar', () => {
+ cy.visit('/app/build');
+ cy.get('.codex-editor__redactor .ce-block');
+ cy.get('.sidebar-item-container[item-name="Settings"]').first().click();
+ cy.location('pathname').should('eq', '/app/settings');
+ });
+
+ it('Create Private Page', () => {
+ cy.get('.codex-editor__redactor .ce-block');
+ cy.get('.custom-actions button[data-label="Create%20Workspace"]').click();
+ cy.fill_field('title', 'Test Private Page', 'Data');
+ cy.fill_field('icon', 'edit', 'Icon');
+ cy.get_open_dialog().find('.modal-header').click();
+ cy.get_open_dialog().find('.btn-primary').click();
+
+ // check if sidebar item is added in pubic section
+ cy.get('.sidebar-item-container[item-name="Test Private Page"]').should('have.attr', 'item-public', '0');
+
+ cy.get('.standard-actions .btn-primary[data-label="Save Customizations"]').click();
+ cy.wait(300);
+ cy.get('.sidebar-item-container[item-name="Test Private Page"]').should('have.attr', 'item-public', '0');
+
+ cy.wait(500);
+ cy.get('.codex-editor__redactor .ce-block');
+ cy.get('.standard-actions .btn-secondary[data-label=Edit]').click();
+ });
+
+ it('Add New Block', () => {
+ cy.get('.codex-editor__redactor .ce-block');
+ cy.get('.custom-actions .inner-group-button[data-label="Add%20Block"]').click();
+ cy.get('.custom-actions .inner-group-button .dropdown-menu .block-menu-item-label').contains('Heading').click();
+ cy.get(".ce-block:last").find('h2').click({force: true}).type('Header');
+ cy.get(".ce-block:last").find('.ce-header').should('exist');
+
+ cy.get('.custom-actions .inner-group-button[data-label="Add%20Block"]').click();
+ cy.get('.custom-actions .inner-group-button .dropdown-menu .block-menu-item-label').contains('Text').click();
+ cy.get(".ce-block:last").find('.ce-paragraph').click({force: true}).type('Paragraph text');
+ cy.get(".ce-block:last").find('.ce-paragraph').should('exist');
+ });
+
+ it('Delete A Block', () => {
+ cy.get(".ce-block:last").find('.delete-paragraph').click();
+ cy.get(".ce-block:last").find('.ce-paragraph').should('not.exist');
+ });
+
+ it('Shrink and Expand A Block', () => {
+ cy.get(".ce-block:last").find('.tune-btn').click();
+ cy.get('.ce-settings--opened .ce-shrink-button').click();
+ cy.get(".ce-block:last").should('have.class', 'col-11');
+ cy.get('.ce-settings--opened .ce-shrink-button').click();
+ cy.get(".ce-block:last").should('have.class', 'col-10');
+ cy.get('.ce-settings--opened .ce-shrink-button').click();
+ cy.get(".ce-block:last").should('have.class', 'col-9');
+ cy.get('.ce-settings--opened .ce-expand-button').click();
+ cy.get(".ce-block:last").should('have.class', 'col-10');
+ cy.get('.ce-settings--opened .ce-expand-button').click();
+ cy.get(".ce-block:last").should('have.class', 'col-11');
+ cy.get('.ce-settings--opened .ce-expand-button').click();
+ cy.get(".ce-block:last").should('have.class', 'col-12');
+ });
+
+ it('Change Header Text Size', () => {
+ cy.get('.ce-settings--opened .cdx-settings-button[data-level="3"]').click();
+ cy.get(".ce-block:last").find('.widget-head h3').should('exist');
+ cy.get('.ce-settings--opened .cdx-settings-button[data-level="4"]').click();
+ cy.get(".ce-block:last").find('.widget-head h4').should('exist');
+
+ cy.get('.standard-actions .btn-primary[data-label="Save Customizations"]').click();
+ });
+
+ it('Delete Private Page', () => {
+ cy.get('.codex-editor__redactor .ce-block');
+ cy.get('.standard-actions .btn-secondary[data-label=Edit]').click();
+
+ cy.get('.sidebar-item-container[item-name="Test Private Page"]').find('.sidebar-item-control .delete-page').click();
+ cy.wait(300);
+ cy.get('.modal-footer > .standard-actions > .btn-modal-primary:visible').first().click();
+ cy.get('.standard-actions .btn-primary[data-label="Save Customizations"]').click();
+ cy.get('.codex-editor__redactor .ce-block');
+ cy.get('.sidebar-item-container[item-name="Test Private Page"]').should('not.exist');
+ });
+
+});
\ No newline at end of file
diff --git a/cypress/support/commands.js b/cypress/support/commands.js
index a81ba60fb0..c941652487 100644
--- a/cypress/support/commands.js
+++ b/cypress/support/commands.js
@@ -1,4 +1,5 @@
import 'cypress-file-upload';
+import '@testing-library/cypress/add-commands';
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
diff --git a/frappe/__init__.py b/frappe/__init__.py
index b4728f9ac3..6d79cbd760 100644
--- a/frappe/__init__.py
+++ b/frappe/__init__.py
@@ -28,7 +28,7 @@ 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
+from frappe.query_builder import get_query_builder, patch_query_execute
# Lazy imports
faker = lazy_import('faker')
@@ -208,6 +208,7 @@ def init(site, sites_path=None, new_site=False):
local.qb = get_query_builder(local.conf.db_type or "mariadb")
setup_module_map()
+ patch_query_execute()
local.initialised = True
diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.js b/frappe/automation/doctype/auto_repeat/auto_repeat.js
index 896a10dfe0..80f2255f47 100644
--- a/frappe/automation/doctype/auto_repeat/auto_repeat.js
+++ b/frappe/automation/doctype/auto_repeat/auto_repeat.js
@@ -30,7 +30,7 @@ frappe.ui.form.on('Auto Repeat', {
refresh: function(frm) {
// auto repeat message
if (frm.is_new()) {
- let customize_form_link = `${__('Customize Form')}`;
+ let customize_form_link = `${__('Customize Form')}`;
frm.dashboard.set_headline(__('To configure Auto Repeat, enable "Allow Auto Repeat" from {0}.', [customize_form_link]));
}
diff --git a/frappe/automation/workspace/tools/tools.json b/frappe/automation/workspace/tools/tools.json
index 4a0835657b..f556be1c07 100644
--- a/frappe/automation/workspace/tools/tools.json
+++ b/frappe/automation/workspace/tools/tools.json
@@ -1,22 +1,27 @@
{
- "category": "Administration",
+ "category": "",
"charts": [],
+ "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Your Shortcuts\", \"level\": 4, \"col\": 12}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"ToDo\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Note\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"File\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Assignment Rule\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Auto Repeat\", \"col\": 4}}, {\"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Tools\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Email\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Automation\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Event Streaming\", \"col\": 4}}]",
"creation": "2020-03-02 14:53:24.980279",
"developer_mode_only": 0,
"disable_user_customization": 0,
"docstatus": 0,
"doctype": "Workspace",
+ "extends": "",
"extends_another_page": 0,
+ "for_user": "",
"hide_custom": 0,
"icon": "tool",
"idx": 0,
- "is_standard": 1,
+ "is_default": 0,
+ "is_standard": 0,
"label": "Tools",
"links": [
{
"hidden": 0,
"is_query_report": 0,
"label": "Tools",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -25,6 +30,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "To Do",
+ "link_count": 0,
"link_to": "ToDo",
"link_type": "DocType",
"onboard": 1,
@@ -35,6 +41,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Calendar",
+ "link_count": 0,
"link_to": "Event",
"link_type": "DocType",
"onboard": 1,
@@ -45,6 +52,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Note",
+ "link_count": 0,
"link_to": "Note",
"link_type": "DocType",
"onboard": 1,
@@ -55,6 +63,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Files",
+ "link_count": 0,
"link_to": "File",
"link_type": "DocType",
"onboard": 0,
@@ -65,6 +74,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Activity",
+ "link_count": 0,
"link_to": "activity",
"link_type": "Page",
"onboard": 0,
@@ -74,6 +84,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Email",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -82,6 +93,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Newsletter",
+ "link_count": 0,
"link_to": "Newsletter",
"link_type": "DocType",
"onboard": 1,
@@ -92,6 +104,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Email Group",
+ "link_count": 0,
"link_to": "Email Group",
"link_type": "DocType",
"onboard": 0,
@@ -101,6 +114,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Automation",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -109,6 +123,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Assignment Rule",
+ "link_count": 0,
"link_to": "Assignment Rule",
"link_type": "DocType",
"onboard": 0,
@@ -119,6 +134,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Milestone",
+ "link_count": 0,
"link_to": "Milestone",
"link_type": "DocType",
"onboard": 0,
@@ -129,6 +145,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Auto Repeat",
+ "link_count": 0,
"link_to": "Auto Repeat",
"link_type": "DocType",
"onboard": 0,
@@ -138,6 +155,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Event Streaming",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -146,6 +164,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Event Producer",
+ "link_count": 0,
"link_to": "Event Producer",
"link_type": "DocType",
"onboard": 0,
@@ -156,6 +175,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Event Consumer",
+ "link_count": 0,
"link_to": "Event Consumer",
"link_type": "DocType",
"onboard": 0,
@@ -166,6 +186,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Event Update Log",
+ "link_count": 0,
"link_to": "Event Update Log",
"link_type": "DocType",
"onboard": 0,
@@ -176,6 +197,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Event Sync Log",
+ "link_count": 0,
"link_to": "Event Sync Log",
"link_type": "DocType",
"onboard": 0,
@@ -186,19 +208,26 @@
"hidden": 0,
"is_query_report": 0,
"label": "Document Type Mapping",
+ "link_count": 0,
"link_to": "Document Type Mapping",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
}
],
- "modified": "2020-12-01 13:38:39.950350",
+ "modified": "2021-08-05 12:16:02.839180",
"modified_by": "Administrator",
"module": "Automation",
"name": "Tools",
+ "onboarding": "",
"owner": "Administrator",
+ "parent_page": "",
"pin_to_bottom": 0,
"pin_to_top": 0,
+ "public": 1,
+ "restrict_to_domain": "",
+ "roles": [],
+ "sequence_id": 26,
"shortcuts": [
{
"label": "ToDo",
@@ -225,5 +254,6 @@
"link_to": "Auto Repeat",
"type": "DocType"
}
- ]
+ ],
+ "title": "Tools"
}
\ No newline at end of file
diff --git a/frappe/boot.py b/frappe/boot.py
index 0589e32ac8..c46709d3d7 100644
--- a/frappe/boot.py
+++ b/frappe/boot.py
@@ -105,8 +105,8 @@ def load_conf_settings(bootinfo):
if key in conf: bootinfo[key] = conf.get(key)
def load_desktop_data(bootinfo):
- from frappe.desk.desktop import get_desk_sidebar_items
- bootinfo.allowed_workspaces = get_desk_sidebar_items()
+ from frappe.desk.desktop import get_wspace_sidebar_items
+ bootinfo.allowed_workspaces = get_wspace_sidebar_items().get('pages')
bootinfo.module_page_map = get_controller("Workspace").get_module_page_map()
bootinfo.dashboards = frappe.get_all("Dashboard")
diff --git a/frappe/cache_manager.py b/frappe/cache_manager.py
index c17ae583ed..2ee3b46b7c 100644
--- a/frappe/cache_manager.py
+++ b/frappe/cache_manager.py
@@ -145,10 +145,9 @@ def build_table_count_cache():
table_rows = frappe.qb.Field("table_rows").as_("count")
information_schema = frappe.qb.Schema("information_schema")
- query = frappe.qb.from_(information_schema.tables).select(table_name, table_rows)
-
- data = frappe.db.sql(query, as_dict=1)
-
+ data = (
+ frappe.qb.from_(information_schema.tables).select(table_name, table_rows)
+ ).run(as_dict=True)
counts = {d.get('name').lstrip('tab'): d.get('count', None) for d in data}
_cache.set_value("information_schema:counts", counts)
diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py
index f2395ae490..be8304e45d 100644
--- a/frappe/commands/utils.py
+++ b/frappe/commands/utils.py
@@ -589,24 +589,26 @@ def run_ui_tests(context, app, headless=False, parallel=True, ci_build_id=None):
admin_password = frappe.get_conf(site).admin_password
# override baseUrl using env variable
- site_env = 'CYPRESS_baseUrl={}'.format(site_url)
- password_env = 'CYPRESS_adminPassword={}'.format(admin_password) if admin_password else ''
+ site_env = f'CYPRESS_baseUrl={site_url}'
+ password_env = f'CYPRESS_adminPassword={admin_password}' if admin_password else ''
os.chdir(app_base_path)
node_bin = subprocess.getoutput("npm bin")
- cypress_path = "{0}/cypress".format(node_bin)
- plugin_path = "{0}/../cypress-file-upload".format(node_bin)
+ cypress_path = f"{node_bin}/cypress"
+ plugin_path = f"{node_bin}/../cypress-file-upload"
+ testing_library_path = f"{node_bin}/../@testing-library"
# check if cypress in path...if not, install it.
if not (
os.path.exists(cypress_path)
and os.path.exists(plugin_path)
+ and os.path.exists(testing_library_path)
and cint(subprocess.getoutput("npm view cypress version")[:1]) >= 6
):
# install cypress
click.secho("Installing Cypress...", fg="yellow")
- frappe.commands.popen("yarn add cypress@^6 cypress-file-upload@^5 --no-lockfile")
+ frappe.commands.popen("yarn add cypress@^6 cypress-file-upload@^5 @testing-library/cypress@^8 --no-lockfile")
# run for headless mode
run_or_open = 'run --browser firefox --record' if headless else 'open'
@@ -617,7 +619,7 @@ def run_ui_tests(context, app, headless=False, parallel=True, ci_build_id=None):
formatted_command += ' --parallel'
if ci_build_id:
- formatted_command += ' --ci-build-id {}'.format(ci_build_id)
+ formatted_command += f' --ci-build-id {ci_build_id}'
click.secho("Running Cypress...", fg="yellow")
frappe.commands.popen(formatted_command, cwd=app_base_path, raise_err=True)
diff --git a/frappe/config/__init__.py b/frappe/config/__init__.py
index e7f0f1a763..aa441b7d71 100644
--- a/frappe/config/__init__.py
+++ b/frappe/config/__init__.py
@@ -43,9 +43,13 @@ def get_all_empty_tables_by_module():
table_name = frappe.qb.Field("table_name")
information_schema = frappe.qb.Schema("information_schema")
- query = frappe.qb.from_(information_schema.tables).select(table_name).where(table_rows == 0)
+ empty_tables = (
+ frappe.qb.from_(information_schema.tables)
+ .select(table_name)
+ .where(table_rows == 0)
+ ).run()
- empty_tables = {r[0] for r in frappe.db.sql(query)}
+ empty_tables = {r[0] for r in empty_tables}
results = frappe.get_all("DocType", fields=["name", "module"])
empty_tables_by_module = {}
diff --git a/frappe/core/doctype/feedback/feedback.json b/frappe/core/doctype/feedback/feedback.json
index cf8a180e27..b77e7a6677 100644
--- a/frappe/core/doctype/feedback/feedback.json
+++ b/frappe/core/doctype/feedback/feedback.json
@@ -8,8 +8,8 @@
"reference_doctype",
"reference_name",
"column_break_3",
- "email",
"rating",
+ "ip_address",
"section_break_6",
"feedback"
],
@@ -18,12 +18,6 @@
"fieldname": "column_break_3",
"fieldtype": "Column Break"
},
- {
- "fieldname": "email",
- "fieldtype": "Data",
- "label": "Email",
- "reqd": 1
- },
{
"fieldname": "rating",
"fieldtype": "Float",
@@ -56,11 +50,18 @@
"label": "Reference Name",
"options": "reference_doctype",
"reqd": 1
+ },
+ {
+ "fieldname": "ip_address",
+ "fieldtype": "Data",
+ "hidden": 1,
+ "label": "IP Address",
+ "read_only": 1
}
],
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2021-06-14 15:11:26.005805",
+ "modified": "2021-06-23 12:45:42.045696",
"modified_by": "Administrator",
"module": "Core",
"name": "Feedback",
diff --git a/frappe/core/doctype/feedback/test_feedback.py b/frappe/core/doctype/feedback/test_feedback.py
index 702f9d8ac1..2a96d86874 100644
--- a/frappe/core/doctype/feedback/test_feedback.py
+++ b/frappe/core/doctype/feedback/test_feedback.py
@@ -12,12 +12,12 @@ class TestFeedback(unittest.TestCase):
frappe.db.sql("delete from `tabFeedback` where reference_doctype = 'Blog Post'")
from frappe.templates.includes.feedback.feedback import add_feedback, update_feedback
- feedback = add_feedback('Blog Post', test_blog.name, 5, 'New feedback','test@test.com')
+ feedback = add_feedback('Blog Post', test_blog.name, 5, 'New feedback')
self.assertEqual(feedback.feedback, 'New feedback')
self.assertEqual(feedback.rating, 5)
- updated_feedback = update_feedback('Blog Post', test_blog.name, 6, 'Updated feedback', 'test@test.com')
+ updated_feedback = update_feedback('Blog Post', test_blog.name, 6, 'Updated feedback')
self.assertEqual(updated_feedback.feedback, 'Updated feedback')
self.assertEqual(updated_feedback.rating, 6)
diff --git a/frappe/core/doctype/page/page.py b/frappe/core/doctype/page/page.py
index 0ba0e309dd..5aa18cd9e3 100644
--- a/frappe/core/doctype/page/page.py
+++ b/frappe/core/doctype/page/page.py
@@ -109,6 +109,7 @@ class Page(Document):
if os.path.exists(fpath):
with open(fpath, 'r') as f:
self.script = render_include(f.read())
+ self.script += f"\n\n//# sourceURL={page_name}.js"
# css
fpath = os.path.join(path, page_name + '.css')
diff --git a/frappe/core/workspace/build/build.json b/frappe/core/workspace/build/build.json
index aefda698b1..464052ba39 100644
--- a/frappe/core/workspace/build/build.json
+++ b/frappe/core/workspace/build/build.json
@@ -1,24 +1,28 @@
{
"cards_label": "Elements",
- "category": "Modules",
+ "category": "",
"charts": [],
+ "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Your Shortcuts\", \"level\": 4, \"col\": 12}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"DocType\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Workspace\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Report\", \"col\": 4}}, {\"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"type\": \"header\", \"data\": {\"text\": \"Elements\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Modules\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Models\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Views\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Scripting\", \"col\": 4}}]",
"creation": "2021-01-02 10:51:16.579957",
"developer_mode_only": 0,
"disable_user_customization": 0,
"docstatus": 0,
"doctype": "Workspace",
+ "extends": "",
"extends_another_page": 0,
+ "for_user": "",
"hide_custom": 0,
"icon": "tool",
"idx": 0,
"is_default": 0,
- "is_standard": 1,
+ "is_standard": 0,
"label": "Build",
"links": [
{
"hidden": 0,
"is_query_report": 0,
"label": "Modules",
+ "link_count": 0,
"link_type": "DocType",
"onboard": 0,
"only_for": "",
@@ -28,6 +32,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Module Def",
+ "link_count": 0,
"link_to": "Module Def",
"link_type": "DocType",
"onboard": 0,
@@ -38,6 +43,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Workspace",
+ "link_count": 0,
"link_to": "Workspace",
"link_type": "DocType",
"onboard": 0,
@@ -48,6 +54,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Module Onboarding",
+ "link_count": 0,
"link_to": "Module Onboarding",
"link_type": "DocType",
"onboard": 0,
@@ -58,6 +65,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Block Module",
+ "link_count": 0,
"link_to": "Block Module",
"link_type": "DocType",
"onboard": 0,
@@ -68,6 +76,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Models",
+ "link_count": 0,
"link_type": "DocType",
"onboard": 0,
"only_for": "",
@@ -77,6 +86,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "DocType",
+ "link_count": 0,
"link_to": "DocType",
"link_type": "DocType",
"onboard": 0,
@@ -87,6 +97,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Workflow",
+ "link_count": 0,
"link_to": "Workflow",
"link_type": "DocType",
"onboard": 0,
@@ -97,6 +108,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Views",
+ "link_count": 0,
"link_type": "DocType",
"onboard": 0,
"only_for": "",
@@ -106,6 +118,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Report",
+ "link_count": 0,
"link_to": "Report",
"link_type": "DocType",
"onboard": 0,
@@ -116,6 +129,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Print Format",
+ "link_count": 0,
"link_to": "Print Format",
"link_type": "DocType",
"onboard": 0,
@@ -126,6 +140,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Workspace",
+ "link_count": 0,
"link_to": "Workspace",
"link_type": "DocType",
"onboard": 0,
@@ -136,6 +151,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Dashboard",
+ "link_count": 0,
"link_to": "Dashboard",
"link_type": "DocType",
"onboard": 0,
@@ -146,6 +162,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Scripting",
+ "link_count": 0,
"link_type": "DocType",
"onboard": 0,
"only_for": "",
@@ -155,6 +172,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Server Script",
+ "link_count": 0,
"link_to": "Server Script",
"link_type": "DocType",
"onboard": 0,
@@ -165,6 +183,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Client Script",
+ "link_count": 0,
"link_to": "Client Script",
"link_type": "DocType",
"onboard": 0,
@@ -175,6 +194,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Scheduled Job Type",
+ "link_count": 0,
"link_to": "Scheduled Job Type",
"link_type": "DocType",
"onboard": 0,
@@ -182,13 +202,19 @@
"type": "Link"
}
],
- "modified": "2021-02-04 13:48:48.493146",
+ "modified": "2021-08-05 12:15:55.793022",
"modified_by": "Administrator",
"module": "Core",
"name": "Build",
+ "onboarding": "",
"owner": "Administrator",
+ "parent_page": "",
"pin_to_bottom": 0,
"pin_to_top": 0,
+ "public": 1,
+ "restrict_to_domain": "",
+ "roles": [],
+ "sequence_id": 5,
"shortcuts": [
{
"doc_view": "",
@@ -208,5 +234,6 @@
"link_to": "Report",
"type": "DocType"
}
- ]
+ ],
+ "title": "Build"
}
\ No newline at end of file
diff --git a/frappe/core/workspace/settings/settings.json b/frappe/core/workspace/settings/settings.json
index fb26b73cfc..93a6c81c90 100644
--- a/frappe/core/workspace/settings/settings.json
+++ b/frappe/core/workspace/settings/settings.json
@@ -1,22 +1,27 @@
{
- "category": "Modules",
+ "category": "",
"charts": [],
+ "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Settings\", \"level\": 4, \"col\": 12}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"System Settings\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Print Settings\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Website Settings\", \"col\": 4}}, {\"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Data\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Email / Notifications\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Website\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Core\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Printing\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Workflow\", \"col\": 4}}]",
"creation": "2020-03-02 15:09:40.527211",
"developer_mode_only": 0,
- "disable_user_customization": 1,
+ "disable_user_customization": 0,
"docstatus": 0,
"doctype": "Workspace",
+ "extends": "",
"extends_another_page": 0,
+ "for_user": "",
"hide_custom": 0,
"icon": "setting",
"idx": 0,
- "is_standard": 1,
+ "is_default": 0,
+ "is_standard": 0,
"label": "Settings",
"links": [
{
"hidden": 0,
"is_query_report": 0,
"label": "Data",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -25,6 +30,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Import Data",
+ "link_count": 0,
"link_to": "Data Import",
"link_type": "DocType",
"onboard": 0,
@@ -35,6 +41,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Export Data",
+ "link_count": 0,
"link_to": "Data Export",
"link_type": "DocType",
"onboard": 0,
@@ -45,6 +52,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Bulk Update",
+ "link_count": 0,
"link_to": "Bulk Update",
"link_type": "DocType",
"onboard": 0,
@@ -55,6 +63,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Download Backups",
+ "link_count": 0,
"link_to": "backups",
"link_type": "Page",
"onboard": 0,
@@ -65,6 +74,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Deleted Documents",
+ "link_count": 0,
"link_to": "Deleted Document",
"link_type": "DocType",
"onboard": 0,
@@ -74,6 +84,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Email / Notifications",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -82,6 +93,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Email Account",
+ "link_count": 0,
"link_to": "Email Account",
"link_type": "DocType",
"onboard": 0,
@@ -92,6 +104,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Email Domain",
+ "link_count": 0,
"link_to": "Email Domain",
"link_type": "DocType",
"onboard": 0,
@@ -102,6 +115,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Notification",
+ "link_count": 0,
"link_to": "Notification",
"link_type": "DocType",
"onboard": 0,
@@ -112,6 +126,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Email Template",
+ "link_count": 0,
"link_to": "Email Template",
"link_type": "DocType",
"onboard": 0,
@@ -122,6 +137,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Auto Email Report",
+ "link_count": 0,
"link_to": "Auto Email Report",
"link_type": "DocType",
"onboard": 0,
@@ -132,6 +148,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Newsletter",
+ "link_count": 0,
"link_to": "Newsletter",
"link_type": "DocType",
"onboard": 0,
@@ -142,6 +159,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Notification Settings",
+ "link_count": 0,
"link_to": "Notification Settings",
"link_type": "DocType",
"onboard": 0,
@@ -151,6 +169,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Website",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -159,6 +178,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Website Settings",
+ "link_count": 0,
"link_to": "Website Settings",
"link_type": "DocType",
"onboard": 1,
@@ -169,6 +189,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Website Theme",
+ "link_count": 0,
"link_to": "Website Theme",
"link_type": "DocType",
"onboard": 1,
@@ -179,6 +200,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Website Script",
+ "link_count": 0,
"link_to": "Website Script",
"link_type": "DocType",
"onboard": 0,
@@ -189,6 +211,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "About Us Settings",
+ "link_count": 0,
"link_to": "About Us Settings",
"link_type": "DocType",
"onboard": 0,
@@ -199,6 +222,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Contact Us Settings",
+ "link_count": 0,
"link_to": "Contact Us Settings",
"link_type": "DocType",
"onboard": 0,
@@ -208,6 +232,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Core",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -216,6 +241,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "System Settings",
+ "link_count": 0,
"link_to": "System Settings",
"link_type": "DocType",
"onboard": 0,
@@ -226,6 +252,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Error Log",
+ "link_count": 0,
"link_to": "Error Log",
"link_type": "DocType",
"onboard": 0,
@@ -236,6 +263,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Error Snapshot",
+ "link_count": 0,
"link_to": "Error Snapshot",
"link_type": "DocType",
"onboard": 0,
@@ -246,6 +274,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Domain Settings",
+ "link_count": 0,
"link_to": "Domain Settings",
"link_type": "DocType",
"onboard": 0,
@@ -255,6 +284,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Printing",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -263,6 +293,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Print Format Builder",
+ "link_count": 0,
"link_to": "print-format-builder",
"link_type": "Page",
"onboard": 0,
@@ -273,6 +304,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Print Settings",
+ "link_count": 0,
"link_to": "Print Settings",
"link_type": "DocType",
"onboard": 0,
@@ -283,6 +315,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Print Format",
+ "link_count": 0,
"link_to": "Print Format",
"link_type": "DocType",
"onboard": 0,
@@ -293,6 +326,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Print Style",
+ "link_count": 0,
"link_to": "Print Style",
"link_type": "DocType",
"onboard": 0,
@@ -302,6 +336,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Workflow",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -310,6 +345,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Workflow",
+ "link_count": 0,
"link_to": "Workflow",
"link_type": "DocType",
"onboard": 0,
@@ -320,6 +356,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Workflow State",
+ "link_count": 0,
"link_to": "Workflow State",
"link_type": "DocType",
"onboard": 0,
@@ -330,19 +367,26 @@
"hidden": 0,
"is_query_report": 0,
"label": "Workflow Action",
+ "link_count": 0,
"link_to": "Workflow Action",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
}
],
- "modified": "2020-12-01 13:38:40.235323",
+ "modified": "2021-08-05 12:16:03.456173",
"modified_by": "Administrator",
"module": "Core",
"name": "Settings",
+ "onboarding": "",
"owner": "Administrator",
- "pin_to_bottom": 1,
+ "parent_page": "",
+ "pin_to_bottom": 0,
"pin_to_top": 0,
+ "public": 1,
+ "restrict_to_domain": "",
+ "roles": [],
+ "sequence_id": 29,
"shortcuts": [
{
"icon": "setting",
@@ -363,5 +407,6 @@
"type": "DocType"
}
],
- "shortcuts_label": "Settings"
+ "shortcuts_label": "Settings",
+ "title": "Settings"
}
\ No newline at end of file
diff --git a/frappe/core/workspace/users/users.json b/frappe/core/workspace/users/users.json
index ba82461b57..09a835ea2c 100644
--- a/frappe/core/workspace/users/users.json
+++ b/frappe/core/workspace/users/users.json
@@ -1,23 +1,27 @@
{
- "category": "Administration",
+ "category": "",
"charts": [],
+ "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Your Shortcuts\", \"level\": 4, \"col\": 12}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"User\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Role\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Permission Manager\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"User Profile\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"User Type\", \"col\": 4}}, {\"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Users\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Logs\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Permissions\", \"col\": 4}}]",
"creation": "2020-03-02 15:12:16.754449",
"developer_mode_only": 0,
"disable_user_customization": 0,
"docstatus": 0,
"doctype": "Workspace",
+ "extends": "",
"extends_another_page": 0,
+ "for_user": "",
"hide_custom": 0,
"icon": "users",
"idx": 0,
"is_default": 0,
- "is_standard": 1,
+ "is_standard": 0,
"label": "Users",
"links": [
{
"hidden": 0,
"is_query_report": 0,
"label": "Users",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -26,6 +30,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "User",
+ "link_count": 0,
"link_to": "User",
"link_type": "DocType",
"onboard": 0,
@@ -36,6 +41,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Role",
+ "link_count": 0,
"link_to": "Role",
"link_type": "DocType",
"onboard": 0,
@@ -46,6 +52,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Role Profile",
+ "link_count": 0,
"link_to": "Role Profile",
"link_type": "DocType",
"onboard": 0,
@@ -55,6 +62,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Logs",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -63,6 +71,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Activity Log",
+ "link_count": 0,
"link_to": "Activity Log",
"link_type": "DocType",
"onboard": 0,
@@ -73,6 +82,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Access Log",
+ "link_count": 0,
"link_to": "Access Log",
"link_type": "DocType",
"onboard": 0,
@@ -82,6 +92,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Permissions",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -90,6 +101,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Role Permissions Manager",
+ "link_count": 0,
"link_to": "permission-manager",
"link_type": "Page",
"onboard": 0,
@@ -100,6 +112,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "User Permissions",
+ "link_count": 0,
"link_to": "User Permission",
"link_type": "DocType",
"onboard": 0,
@@ -110,6 +123,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Role Permission for Page and Report",
+ "link_count": 0,
"link_to": "Role Permission for Page and Report",
"link_type": "DocType",
"onboard": 0,
@@ -120,6 +134,7 @@
"hidden": 0,
"is_query_report": 1,
"label": "Permitted Documents For User",
+ "link_count": 0,
"link_to": "Permitted Documents For User",
"link_type": "Report",
"onboard": 0,
@@ -130,19 +145,26 @@
"hidden": 0,
"is_query_report": 0,
"label": "Document Share Report",
+ "link_count": 0,
"link_to": "Document Share Report",
"link_type": "Report",
"onboard": 0,
"type": "Link"
}
],
- "modified": "2021-03-25 23:02:34.582569",
+ "modified": "2021-08-05 12:16:03.010204",
"modified_by": "Administrator",
"module": "Core",
"name": "Users",
+ "onboarding": "",
"owner": "Administrator",
+ "parent_page": "",
"pin_to_bottom": 0,
"pin_to_top": 0,
+ "public": 1,
+ "restrict_to_domain": "",
+ "roles": [],
+ "sequence_id": 27,
"shortcuts": [
{
"label": "User",
@@ -170,5 +192,6 @@
"link_to": "User Type",
"type": "DocType"
}
- ]
+ ],
+ "title": "Users"
}
\ No newline at end of file
diff --git a/frappe/custom/workspace/customization/customization.json b/frappe/custom/workspace/customization/customization.json
index cdc3b73366..136b1a57eb 100644
--- a/frappe/custom/workspace/customization/customization.json
+++ b/frappe/custom/workspace/customization/customization.json
@@ -1,23 +1,27 @@
{
- "category": "Administration",
+ "category": "",
"charts": [],
+ "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Your Shortcuts\", \"level\": 4, \"col\": 12}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Customize Form\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Custom Role\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Client Script\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Server Script\", \"col\": 4}}, {\"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Dashboards\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Form Customization\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Other\", \"col\": 4}}]",
"creation": "2020-03-02 15:15:03.839594",
"developer_mode_only": 0,
"disable_user_customization": 0,
"docstatus": 0,
"doctype": "Workspace",
+ "extends": "",
"extends_another_page": 0,
+ "for_user": "",
"hide_custom": 0,
"icon": "customization",
"idx": 0,
"is_default": 0,
- "is_standard": 1,
+ "is_standard": 0,
"label": "Customization",
"links": [
{
"hidden": 0,
"is_query_report": 0,
"label": "Dashboards",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -26,6 +30,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Dashboard",
+ "link_count": 0,
"link_to": "Dashboard",
"link_type": "DocType",
"onboard": 0,
@@ -36,6 +41,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Dashboard Chart",
+ "link_count": 0,
"link_to": "Dashboard Chart",
"link_type": "DocType",
"onboard": 0,
@@ -46,6 +52,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Dashboard Chart Source",
+ "link_count": 0,
"link_to": "Dashboard Chart Source",
"link_type": "DocType",
"onboard": 0,
@@ -55,6 +62,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Form Customization",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -63,6 +71,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Customize Form",
+ "link_count": 0,
"link_to": "Customize Form",
"link_type": "DocType",
"onboard": 0,
@@ -73,6 +82,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Custom Field",
+ "link_count": 0,
"link_to": "Custom Field",
"link_type": "DocType",
"onboard": 0,
@@ -83,6 +93,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Client Script",
+ "link_count": 0,
"link_to": "Client Script",
"link_type": "DocType",
"onboard": 0,
@@ -93,6 +104,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "DocType",
+ "link_count": 0,
"link_to": "DocType",
"link_type": "DocType",
"onboard": 0,
@@ -102,6 +114,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Other",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -110,19 +123,26 @@
"hidden": 0,
"is_query_report": 0,
"label": "Custom Translations",
+ "link_count": 0,
"link_to": "Translation",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
}
],
- "modified": "2021-02-04 13:50:35.750463",
+ "modified": "2021-08-05 12:15:57.486112",
"modified_by": "Administrator",
"module": "Custom",
"name": "Customization",
+ "onboarding": "",
"owner": "Administrator",
+ "parent_page": "",
"pin_to_bottom": 0,
"pin_to_top": 0,
+ "public": 1,
+ "restrict_to_domain": "",
+ "roles": [],
+ "sequence_id": 8,
"shortcuts": [
{
"label": "Customize Form",
@@ -145,5 +165,6 @@
"link_to": "Server Script",
"type": "DocType"
}
- ]
+ ],
+ "title": "Customization"
}
\ No newline at end of file
diff --git a/frappe/desk/desktop.py b/frappe/desk/desktop.py
index ca53e6cba4..e9036b98b0 100644
--- a/frappe/desk/desktop.py
+++ b/frappe/desk/desktop.py
@@ -6,6 +6,7 @@ import frappe
from json import loads, dumps
from frappe import _, DoesNotExistError, ValidationError, _dict
from frappe.boot import get_allowed_pages, get_allowed_reports
+from frappe.core.doctype.custom_role.custom_role import get_custom_allowed_roles
from functools import wraps
from frappe.cache_manager import (
build_domain_restriced_doctype_cache,
@@ -27,18 +28,21 @@ def handle_not_exist(fn):
class Workspace:
- def __init__(self, page_name, minimal=False):
- self.page_name = page_name
+ def __init__(self, page, minimal=False):
+ self.page_name = page.get('name')
+ self.page_title = page.get('title')
+ self.public_page = page.get('public')
self.extended_links = []
self.extended_charts = []
self.extended_shortcuts = []
+ self.workspace_manager = "Workspace Manager" in frappe.get_roles()
self.user = frappe.get_user()
self.allowed_modules = self.get_cached('user_allowed_modules', self.get_allowed_modules)
- self.doc = self.get_page_for_user()
+ self.doc = frappe.get_cached_doc("Workspace", self.page_name)
- if self.doc.module and self.doc.module not in self.allowed_modules:
+ if self.doc and self.doc.module and self.doc.module not in self.allowed_modules and not self.workspace_manager:
raise frappe.PermissionError
self.can_read = self.get_cached('user_perm_can_read', self.get_can_read_items)
@@ -47,16 +51,17 @@ class Workspace:
self.allowed_reports = get_allowed_reports(cache=True)
if not minimal:
- self.onboarding_doc = self.get_onboarding_doc()
- self.onboarding = None
+ if self.doc.content:
+ self.onboarding_list = [x['data']['onboarding_name'] for x in loads(self.doc.content) if x['type'] == 'onboarding']
+ self.onboardings = []
self.table_counts = get_table_with_counts()
self.restricted_doctypes = frappe.cache().get_value("domain_restricted_doctypes") or build_domain_restriced_doctype_cache()
self.restricted_pages = frappe.cache().get_value("domain_restricted_pages") or build_domain_restriced_page_cache()
def is_page_allowed(self):
- cards = self.doc.get_link_groups() + get_custom_reports_and_doctypes(self.doc.module) + self.extended_links
- shortcuts = self.doc.shortcuts + self.extended_shortcuts
+ cards = self.doc.get_link_groups() + get_custom_reports_and_doctypes(self.doc.module)
+ shortcuts = self.doc.shortcuts
for section in cards:
links = loads(section.get('links')) if isinstance(section.get('links'), str) else section.get('links')
@@ -74,8 +79,28 @@ class Workspace:
if self.is_item_allowed(item.link_to, item.type) and _in_active_domains(item):
return True
+ if not shortcuts and not self.doc.links:
+ return True
+
return False
+ def is_permitted(self):
+ """Returns true if Has Role is not set or the user is allowed."""
+ from frappe.utils import has_common
+
+ allowed = [d.role for d in frappe.get_all("Has Role", fields=["role"], filters={"parent": self.doc.name})]
+
+ custom_roles = get_custom_allowed_roles('page', self.doc.name)
+ allowed.extend(custom_roles)
+
+ if not allowed:
+ return True
+
+ roles = frappe.get_roles()
+
+ if has_common(roles, allowed):
+ return True
+
def get_cached(self, cache_key, fallback_fn):
_cache = frappe.cache()
@@ -101,39 +126,18 @@ class Workspace:
return self.user.allow_modules
- def get_page_for_user(self):
- filters = {
- 'extends': self.page_name,
- 'for_user': frappe.session.user
- }
- user_pages = frappe.get_all("Workspace", filters=filters, limit=1)
- if user_pages:
- return frappe.get_cached_doc("Workspace", user_pages[0])
-
- filters = {
- 'extends_another_page': 1,
- 'extends': self.page_name,
- 'is_default': 1
- }
- default_page = frappe.get_all("Workspace", filters=filters, limit=1)
- if default_page:
- return frappe.get_cached_doc("Workspace", default_page[0])
-
- self.get_pages_to_extend()
- return frappe.get_cached_doc("Workspace", self.page_name)
-
- def get_onboarding_doc(self):
+ def get_onboarding_doc(self, onboarding):
# Check if onboarding is enabled
if not frappe.get_system_settings("enable_onboarding"):
return None
- if not self.doc.onboarding:
+ if not self.onboarding_list:
return None
- if frappe.db.get_value("Module Onboarding", self.doc.onboarding, "is_complete"):
+ if frappe.db.get_value("Module Onboarding", onboarding, "is_complete"):
return None
- doc = frappe.get_doc("Module Onboarding", self.doc.onboarding)
+ doc = frappe.get_doc("Module Onboarding", onboarding)
# Check if user is allowed
allowed_roles = set(doc.get_allowed_roles())
@@ -197,14 +201,9 @@ class Workspace:
'items': self.get_shortcuts()
}
- if self.onboarding_doc:
- self.onboarding = {
- 'label': _(self.onboarding_doc.title),
- 'subtitle': _(self.onboarding_doc.subtitle),
- 'success': _(self.onboarding_doc.success_message),
- 'docs_url': self.onboarding_doc.documentation_url,
- 'items': self.get_onboarding_steps()
- }
+ self.onboardings = {
+ 'items': self.get_onboardings()
+ }
def _doctype_contains_a_record(self, name):
exists = self.table_counts.get(name, False)
@@ -333,9 +332,26 @@ class Workspace:
return items
@handle_not_exist
- def get_onboarding_steps(self):
+ def get_onboardings(self):
+ if self.onboarding_list:
+ for onboarding in self.onboarding_list:
+ onboarding_doc = self.get_onboarding_doc(onboarding)
+ if onboarding_doc:
+ item = {
+ 'label': _(onboarding),
+ 'title': _(onboarding_doc.title),
+ 'subtitle': _(onboarding_doc.subtitle),
+ 'success': _(onboarding_doc.success_message),
+ 'docs_url': onboarding_doc.documentation_url,
+ 'items': self.get_onboarding_steps(onboarding_doc)
+ }
+ self.onboardings.append(item)
+ return self.onboardings
+
+ @handle_not_exist
+ def get_onboarding_steps(self, onboarding_doc):
steps = []
- for doc in self.onboarding_doc.get_steps():
+ for doc in onboarding_doc.get_steps():
step = doc.as_dict().copy()
step.label = _(doc.title)
if step.action == "Create Entry":
@@ -352,19 +368,19 @@ def get_desktop_page(page):
on desk.
Args:
- page (string): page name
+ page (json): page data
Returns:
dict: dictionary of cards, charts and shortcuts to be displayed on website
"""
try:
- wspace = Workspace(page)
+ wspace = Workspace(loads(page))
wspace.build_workspace()
return {
'charts': wspace.charts,
'shortcuts': wspace.shortcuts,
'cards': wspace.cards,
- 'onboarding': wspace.onboarding,
+ 'onboardings': wspace.onboardings,
'allow_customization': not wspace.doc.disable_user_customization
}
except DoesNotExistError:
@@ -372,39 +388,45 @@ def get_desktop_page(page):
return {}
@frappe.whitelist()
-def get_desk_sidebar_items():
+def get_wspace_sidebar_items():
"""Get list of sidebar items for desk"""
+ has_access = "Workspace Manager" in frappe.get_roles()
# don't get domain restricted pages
blocked_modules = frappe.get_doc('User', frappe.session.user).get_blocked_modules()
+ blocked_modules.append('Dummy Module')
filters = {
'restrict_to_domain': ['in', frappe.get_active_domains()],
- 'extends_another_page': 0,
- 'for_user': '',
'module': ['not in', blocked_modules]
}
- if not frappe.local.conf.developer_mode:
- filters['developer_mode_only'] = '0'
+ if has_access:
+ filters = []
- # pages sorted based on pinned to top and then by name
- order_by = "pin_to_top desc, pin_to_bottom asc, name asc"
- all_pages = frappe.get_all("Workspace", fields=["name", "category", "icon", "module"],
- filters=filters, order_by=order_by, ignore_permissions=True)
+ # pages sorted based on sequence id
+ order_by = "sequence_id asc"
+ fields = ["name", "title", "for_user", "parent_page", "content", "public", "module", "icon"]
+ all_pages = frappe.get_all("Workspace", fields=fields, filters=filters, order_by=order_by, ignore_permissions=True)
pages = []
+ private_pages = []
# Filter Page based on Permission
for page in all_pages:
try:
- wspace = Workspace(page.get('name'), True)
- if wspace.is_page_allowed():
- pages.append(page)
+ wspace = Workspace(page)
+ if wspace.is_permitted() and wspace.is_page_allowed() or has_access:
+ if page.public:
+ pages.append(page)
+ elif page.for_user == frappe.session.user:
+ private_pages.append(page)
page['label'] = _(page.get('name'))
except frappe.PermissionError:
pass
+ if private_pages:
+ pages.extend(private_pages)
- return pages
+ return {'pages': pages, 'has_access': has_access}
def get_table_with_counts():
counts = frappe.cache().get_value("information_schema:counts")
@@ -471,7 +493,7 @@ def get_custom_workspace_for_user(page):
"""
filters = {
'extends': page,
- 'for_user': frappe.session.user
+ 'for_user': frappe.session.user,
}
pages = frappe.get_list("Workspace", filters=filters)
if pages:
@@ -481,7 +503,6 @@ def get_custom_workspace_for_user(page):
doc.for_user = frappe.session.user
return doc
-
@frappe.whitelist()
def save_customization(page, config):
"""Save customizations as a separate doctype in Workspace per user
@@ -540,6 +561,80 @@ def save_customization(page, config):
return True
+def save_new_widget(doc, page, blocks, new_widgets):
+
+ widgets = _dict(loads(new_widgets))
+
+ if widgets.chart:
+ doc.charts.extend(new_widget(widgets.chart, "Workspace Chart", "charts"))
+ if widgets.shortcut:
+ doc.shortcuts.extend(new_widget(widgets.shortcut, "Workspace Shortcut", "shortcuts"))
+ if widgets.card:
+ doc.build_links_table_from_card(widgets.card)
+
+ # remove duplicate and unwanted widgets
+ if widgets:
+ clean_up(doc, blocks)
+
+ try:
+ doc.save(ignore_permissions=True)
+ except (ValidationError, TypeError) as e:
+ # Create a json string to log
+ json_config = dumps(widgets, sort_keys=True, indent=4)
+
+ # Error log body
+ log = \
+ """
+ page: {0}
+ config: {1}
+ exception: {2}
+ """.format(page, json_config, e)
+ frappe.log_error(log, _("Could not save customization"))
+ return False
+
+ return True
+def clean_up(original_page, blocks):
+ page_widgets = {}
+
+ for wid in ['shortcut', 'card', 'chart']:
+ # get list of widget's name from blocks
+ page_widgets[wid] = [x['data'][wid + '_name'] for x in loads(blocks) if x['type'] == wid]
+
+ # shortcut & chart cleanup
+ for wid in ['shortcut', 'chart']:
+ updated_widgets = []
+ original_page.get(wid+'s').reverse()
+
+ for w in original_page.get(wid+'s'):
+ if w.label in page_widgets[wid] and w.label not in [x.label for x in updated_widgets]:
+ updated_widgets.append(w)
+ original_page.set(wid+'s', updated_widgets)
+
+ # card cleanup
+ for i, v in enumerate(original_page.links):
+ if v.type == 'Card Break' and v.label not in page_widgets['card']:
+ del original_page.links[i : i+v.link_count+1]
+
+def new_widget(config, doctype, parentfield):
+ if not config:
+ return []
+ prepare_widget_list = []
+ for idx, widget in enumerate(config):
+ # Some cleanup
+ widget.pop("name", None)
+
+ # New Doc
+ doc = frappe.new_doc(doctype)
+ doc.update(widget)
+
+ # Manually Set IDX
+ doc.idx = idx + 1
+
+ # Set Parent Field
+ doc.parentfield = parentfield
+
+ prepare_widget_list.append(doc)
+ return prepare_widget_list
def prepare_widget(config, doctype, parentfield):
"""Create widget child table entries with parent details
diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.js b/frappe/desk/doctype/dashboard_chart/dashboard_chart.js
index 3b4d5e7be5..635d32d969 100644
--- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.js
+++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.js
@@ -223,7 +223,7 @@ frappe.ui.form.on('Dashboard Chart', {
if (['Date', 'Datetime'].includes(df.fieldtype)) {
date_fields.push({label: df.label, value: df.fieldname});
}
- if (['Int', 'Float', 'Currency', 'Percent'].includes(df.fieldtype)) {
+ if (['Int', 'Float', 'Currency', 'Percent', 'Duration'].includes(df.fieldtype)) {
value_fields.push({label: df.label, value: df.fieldname});
aggregate_function_fields.push({label: df.label, value: df.fieldname});
}
diff --git a/frappe/desk/doctype/onboarding_step/onboarding_step.py b/frappe/desk/doctype/onboarding_step/onboarding_step.py
index 10bd8926ce..2336ff52f8 100644
--- a/frappe/desk/doctype/onboarding_step/onboarding_step.py
+++ b/frappe/desk/doctype/onboarding_step/onboarding_step.py
@@ -2,10 +2,26 @@
# Copyright (c) 2020, Frappe Technologies and contributors
# For license information, please see license.txt
-# import frappe
+import frappe
+from frappe import _
+import json
from frappe.model.document import Document
class OnboardingStep(Document):
def before_export(self, doc):
doc.is_complete = 0
doc.is_skipped = 0
+
+
+@frappe.whitelist()
+def get_onboarding_steps(ob_steps):
+ steps = []
+ for s in json.loads(ob_steps):
+ doc = frappe.get_doc('Onboarding Step', s.get('step'))
+ step = doc.as_dict().copy()
+ step.label = _(doc.title)
+ if step.action == "Create Entry":
+ step.is_submittable = frappe.db.get_value("DocType", step.reference_document, 'is_submittable', cache=True)
+ steps.append(step)
+
+ return steps
diff --git a/frappe/desk/doctype/workspace/workspace.json b/frappe/desk/doctype/workspace/workspace.json
index 386267b699..e2ae38faf1 100644
--- a/frappe/desk/doctype/workspace/workspace.json
+++ b/frappe/desk/doctype/workspace/workspace.json
@@ -8,8 +8,11 @@
"engine": "InnoDB",
"field_order": [
"label",
+ "title",
+ "sequence_id",
"for_user",
"extends",
+ "parent_page",
"module",
"category",
"icon",
@@ -24,6 +27,9 @@
"pin_to_top",
"pin_to_bottom",
"hide_custom",
+ "public",
+ "content_section",
+ "content",
"section_break_2",
"charts_label",
"charts",
@@ -32,7 +38,8 @@
"shortcuts",
"section_break_18",
"cards_label",
- "links"
+ "links",
+ "roles"
],
"fields": [
{
@@ -199,7 +206,7 @@
},
{
"fieldname": "icon",
- "fieldtype": "Data",
+ "fieldtype": "Icon",
"label": "Icon"
},
{
@@ -209,16 +216,53 @@
"options": "Workspace Link"
},
{
- "default": "0",
- "depends_on": "extends_another_page",
- "description": "Sets the current page as default for all users",
- "fieldname": "is_default",
- "fieldtype": "Check",
- "label": "Is Default"
- }
+ "default": "0",
+ "depends_on": "extends_another_page",
+ "description": "Sets the current page as default for all users",
+ "fieldname": "is_default",
+ "fieldtype": "Check",
+ "label": "Is Default"
+ },
+ {
+ "default": "0",
+ "fieldname": "public",
+ "fieldtype": "Check",
+ "label": "Public"
+ },
+ {
+ "fieldname": "title",
+ "fieldtype": "Data",
+ "label": "Title"
+ },
+ {
+ "fieldname": "parent_page",
+ "fieldtype": "Data",
+ "label": "Parent Page"
+ },
+ {
+ "fieldname": "content_section",
+ "fieldtype": "Section Break",
+ "label": "Content"
+ },
+ {
+ "fieldname": "content",
+ "fieldtype": "Long Text",
+ "label": "Content"
+ },
+ {
+ "fieldname": "sequence_id",
+ "fieldtype": "Int",
+ "label": "Sequence Id"
+ },
+ {
+ "fieldname": "roles",
+ "fieldtype": "Table",
+ "label": "Roles",
+ "options": "Has Role"
+ }
],
"links": [],
- "modified": "2021-01-21 12:09:36.156614",
+ "modified": "2021-08-05 11:49:09.028243",
"modified_by": "Administrator",
"module": "Desk",
"name": "Workspace",
@@ -232,7 +276,7 @@
"print": 1,
"read": 1,
"report": 1,
- "role": "System Manager",
+ "role": "Workspace Manager",
"share": 1,
"write": 1
},
@@ -248,4 +292,4 @@
],
"sort_field": "modified",
"sort_order": "DESC"
-}
+}
\ No newline at end of file
diff --git a/frappe/desk/doctype/workspace/workspace.py b/frappe/desk/doctype/workspace/workspace.py
index 41b0227f2a..0821ae03c4 100644
--- a/frappe/desk/doctype/workspace/workspace.py
+++ b/frappe/desk/doctype/workspace/workspace.py
@@ -6,6 +6,7 @@ import frappe
from frappe import _
from frappe.modules.export_file import export_to_files
from frappe.model.document import Document
+from frappe.desk.desktop import save_new_widget
from frappe.desk.utils import validate_route_conflict
from json import loads
@@ -27,7 +28,7 @@ class Workspace(Document):
if disable_saving_as_standard():
return
- if frappe.conf.developer_mode and self.is_standard:
+ if frappe.conf.developer_mode and self.module and self.public:
export_to_files(record_list=[['Workspace', self.name]], record_module=self.module)
@staticmethod
@@ -98,6 +99,37 @@ class Workspace(Document):
"is_query_report": link.get('is_query_report')
})
+ def build_links_table_from_card(self, config):
+
+ for idx, card in enumerate(config):
+ links = loads(card.get('links'))
+
+ # remove duplicate before adding
+ for idx, link in enumerate(self.links):
+ if link.label == card.get('label') and link.type == 'Card Break':
+ del self.links[idx : idx + link.link_count + 1]
+
+ self.append('links', {
+ "label": card.get('label'),
+ "type": "Card Break",
+ "icon": card.get('icon'),
+ "hidden": card.get('hidden') or False,
+ "link_count": card.get('link_count'),
+ "idx": 1 if not self.links else self.links[-1].idx + 1
+ })
+
+ for link in links:
+ self.append('links', {
+ "label": link.get('label'),
+ "type": "Link",
+ "link_type": link.get('link_type'),
+ "link_to": link.get('link_to'),
+ "onboard": link.get('onboard'),
+ "only_for": link.get('only_for'),
+ "dependencies": link.get('dependencies'),
+ "is_query_report": link.get('is_query_report'),
+ "idx": self.links[-1].idx + 1
+ })
def disable_saving_as_standard():
return frappe.flags.in_install or \
@@ -123,3 +155,84 @@ def get_link_type(key):
def get_report_type(report):
report_type = frappe.get_value("Report", report, "report_type")
return report_type in ["Query Report", "Script Report", "Custom Report"]
+
+
+@frappe.whitelist()
+def save_page(title, icon, parent, public, sb_public_items, sb_private_items, deleted_pages, new_widgets, blocks, save):
+ save = frappe.parse_json(save)
+ public = frappe.parse_json(public)
+ if save:
+ doc = frappe.new_doc('Workspace')
+ doc.title = title
+ doc.icon = icon
+ doc.content = blocks
+ doc.parent_page = parent
+
+ if public:
+ doc.label = title
+ doc.public = 1
+ else:
+ doc.label = title + "-" + frappe.session.user
+ doc.for_user = frappe.session.user
+ doc.save(ignore_permissions=True)
+ else:
+ if public:
+ filters = {
+ 'public': public,
+ 'label': title
+ }
+ else:
+ filters = {
+ 'for_user': frappe.session.user,
+ 'label': title + "-" + frappe.session.user
+ }
+ pages = frappe.get_list("Workspace", filters=filters)
+ if pages:
+ doc = frappe.get_doc("Workspace", pages[0])
+
+ doc.content = blocks
+ doc.save(ignore_permissions=True)
+
+ if loads(new_widgets):
+ save_new_widget(doc, title, blocks, new_widgets)
+
+ if loads(sb_public_items) or loads(sb_private_items):
+ sort_pages(loads(sb_public_items), loads(sb_private_items))
+
+ if loads(deleted_pages):
+ return delete_pages(loads(deleted_pages))
+
+ return {"name": title, "public": public}
+
+def delete_pages(deleted_pages):
+ for page in deleted_pages:
+ if page.get("public") and "Workspace Manager" not in frappe.get_roles():
+ return {"name": page.get("title"), "public": 1}
+
+ if frappe.db.exists("Workspace", page.get("name")):
+ frappe.get_doc("Workspace", page.get("name")).delete(ignore_permissions=True)
+
+ return {"name": "Home", "public": 1}
+
+def sort_pages(sb_public_items, sb_private_items):
+ wspace_public_pages = get_page_list(['name', 'title'], {'public': 1})
+ wspace_private_pages = get_page_list(['name', 'title'], {'for_user': frappe.session.user})
+
+ if sb_private_items:
+ sort_page(wspace_private_pages, sb_private_items)
+
+ if sb_public_items and "Workspace Manager" in frappe.get_roles():
+ sort_page(wspace_public_pages, sb_public_items)
+
+def sort_page(wspace_pages, pages):
+ for seq, d in enumerate(pages):
+ for page in wspace_pages:
+ if page.title == d.get('title'):
+ doc = frappe.get_doc('Workspace', page.name)
+ doc.sequence_id = seq + 1
+ doc.parent_page = d.get('parent_page') or ""
+ doc.save(ignore_permissions=True)
+ break
+
+def get_page_list(fields, filters):
+ return frappe.get_list("Workspace", fields=fields, filters=filters, order_by='sequence_id asc')
diff --git a/frappe/desk/doctype/workspace_link/workspace_link.json b/frappe/desk/doctype/workspace_link/workspace_link.json
index 53dadad83d..a7b217be9e 100644
--- a/frappe/desk/doctype/workspace_link/workspace_link.json
+++ b/frappe/desk/doctype/workspace_link/workspace_link.json
@@ -8,15 +8,16 @@
"type",
"label",
"icon",
- "only_for",
"hidden",
"link_details_section",
"link_type",
"link_to",
"column_break_7",
"dependencies",
+ "only_for",
"onboard",
- "is_query_report"
+ "is_query_report",
+ "link_count"
],
"fields": [
{
@@ -99,12 +100,19 @@
"fieldname": "is_query_report",
"fieldtype": "Check",
"label": "Is Query Report"
+ },
+ {
+ "depends_on": "eval:doc.type == \"Card Break\"",
+ "fieldname": "link_count",
+ "fieldtype": "Int",
+ "hidden": 1,
+ "label": "Link Count"
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2021-05-13 13:10:18.128512",
+ "modified": "2021-06-01 11:23:28.990593",
"modified_by": "Administrator",
"module": "Desk",
"name": "Workspace Link",
diff --git a/frappe/desk/query_report.py b/frappe/desk/query_report.py
index 3c0ebf11c1..b42c9c89a0 100644
--- a/frappe/desk/query_report.py
+++ b/frappe/desk/query_report.py
@@ -177,11 +177,13 @@ def get_script(report_name):
if os.path.exists(script_path):
with open(script_path, "r") as f:
script = f.read()
+ script += f"\n\n//# sourceURL={scrub(report.name)}.js"
html_format = get_html_format(print_path)
if not script and report.javascript:
script = report.javascript
+ script += f"\n\n//# sourceURL={scrub(report.name)}__custom"
if not script:
script = "frappe.query_reports['%s']={}" % report_name
diff --git a/frappe/email/doctype/newsletter/exceptions.py b/frappe/email/doctype/newsletter/exceptions.py
new file mode 100644
index 0000000000..a6c688dbe8
--- /dev/null
+++ b/frappe/email/doctype/newsletter/exceptions.py
@@ -0,0 +1,13 @@
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# MIT License. See LICENSE
+
+from frappe.exceptions import ValidationError
+
+class NewsletterAlreadySentError(ValidationError):
+ pass
+
+class NoRecipientFoundError(ValidationError):
+ pass
+
+class NewsletterNotSavedError(ValidationError):
+ pass
diff --git a/frappe/email/doctype/newsletter/newsletter.py b/frappe/email/doctype/newsletter/newsletter.py
index 97d77549b7..667d0fb34c 100755
--- a/frappe/email/doctype/newsletter/newsletter.py
+++ b/frappe/email/doctype/newsletter/newsletter.py
@@ -1,241 +1,322 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# License: GNU General Public License v3. See license.txt
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# MIT License. See LICENSE
+
+from typing import Dict, List
import frappe
import frappe.utils
-from frappe import throw, _
+
+from frappe import _
from frappe.website.website_generator import WebsiteGenerator
from frappe.utils.verified_command import get_signed_params, verify_request
from frappe.email.doctype.email_group.email_group import add_subscribers
-from frappe.utils import parse_addr, now_datetime, markdown, validate_email_address
+
+from .exceptions import NewsletterAlreadySentError, NoRecipientFoundError, NewsletterNotSavedError
+
class Newsletter(WebsiteGenerator):
def onload(self):
- if self.email_sent:
- self.get("__onload").status_count = dict(frappe.db.sql("""select status, count(name)
- from `tabEmail Queue` where reference_doctype=%s and reference_name=%s
- group by status""", (self.doctype, self.name))) or None
+ self.setup_newsletter_status()
def validate(self):
- self.route = "newsletters/" + self.name
- if self.send_from:
- validate_email_address(self.send_from, True)
+ self.route = f"newsletters/{self.name}"
+ self.validate_sender_address()
+ self.validate_recipient_address()
+
+ @property
+ def newsletter_recipients(self) -> List[str]:
+ if getattr(self, "_recipients", None) is None:
+ self._recipients = self.get_recipients()
+ return self._recipients
@frappe.whitelist()
- def test_send(self, doctype="Lead"):
- self.recipients = frappe.utils.split_emails(self.test_email_id)
- self.queue_all(test_email=True)
+ def test_send(self):
+ test_emails = frappe.utils.split_emails(self.test_email_id)
+ self.queue_all(test_emails=test_emails)
frappe.msgprint(_("Test email sent to {0}").format(self.test_email_id))
@frappe.whitelist()
def send_emails(self):
"""send emails to leads and customers"""
+ self.queue_all()
+ frappe.msgprint(_("Email queued to {0} recipients").format(len(self.newsletter_recipients)))
+
+ def setup_newsletter_status(self):
+ """Setup analytical status for current Newsletter. Can be accessible from desk.
+ """
if self.email_sent:
- throw(_("Newsletter has already been sent"))
-
- self.recipients = self.get_recipients()
-
- if self.recipients:
- self.queue_all()
- frappe.msgprint(_("Email queued to {0} recipients").format(len(self.recipients)))
-
- else:
- frappe.msgprint(_("Newsletter should have atleast one recipient"))
-
- def queue_all(self, test_email=False):
- if not self.get("recipients"):
- # in case it is called via worker
- self.recipients = self.get_recipients()
-
- self.validate_send()
-
- sender = self.send_from or frappe.utils.get_formatted_email(self.owner)
-
- if not frappe.flags.in_test:
- frappe.db.auto_commit_on_many_writes = True
-
- attachments = []
- if self.send_attachments:
- files = frappe.get_all("File", fields=["name"], filters={"attached_to_doctype": "Newsletter",
- "attached_to_name": self.name}, order_by="creation desc")
-
- for file in files:
- try:
- # these attachments will be attached on-demand
- # and won't be stored in the message
- attachments.append({"fid": file.name})
- except IOError:
- frappe.throw(_("Unable to find attachment {0}").format(file.name))
-
- args = {
- "message": self.get_message(),
- "name": self.name
- }
- frappe.sendmail(recipients=self.recipients, sender=sender,
- subject=self.subject, message=self.get_message(), template="newsletter",
- reference_doctype=self.doctype, reference_name=self.name,
- add_unsubscribe_link=self.send_unsubscribe_link, attachments=attachments,
- unsubscribe_method="/unsubscribe",
- unsubscribe_params={"name": self.name},
- send_priority=0, queue_separately=True, args=args)
-
- if not frappe.flags.in_test:
- frappe.db.auto_commit_on_many_writes = False
-
- if not test_email:
- self.db_set("email_sent", 1)
- self.db_set("schedule_send", now_datetime())
- self.db_set("scheduled_to_send", len(self.recipients))
-
- def get_message(self):
- if self.content_type == "HTML":
- return frappe.render_template(self.message_html, {"doc": self.as_dict()})
- return {
- 'Rich Text': self.message,
- 'Markdown': markdown(self.message_md)
- }[self.content_type or 'Rich Text']
-
- def get_recipients(self):
- """Get recipients from Email Group"""
- recipients_list = []
- for email_group in get_email_groups(self.name):
- for d in frappe.db.get_all("Email Group Member", ["email"],
- {"unsubscribed": 0, "email_group": email_group.email_group}):
- recipients_list.append(d.email)
- return list(set(recipients_list))
+ status_count = frappe.get_all("Email Queue",
+ filters={"reference_doctype": self.doctype, "reference_name": self.name},
+ fields=["status", "count(name)"],
+ group_by="status",
+ order_by="status",
+ as_list=True,
+ )
+ self.get("__onload").status_count = dict(status_count)
def validate_send(self):
- if self.get("__islocal"):
- throw(_("Please save the Newsletter before sending"))
+ """Validate if Newsletter can be sent.
+ """
+ self.validate_newsletter_status()
+ self.validate_newsletter_recipients()
- if not self.recipients:
- frappe.throw(_("Newsletter should have at least one recipient"))
+ def validate_newsletter_status(self):
+ if self.email_sent:
+ frappe.throw(_("Newsletter has already been sent"), exc=NewsletterAlreadySentError)
+
+ if self.get("__islocal"):
+ frappe.throw(_("Please save the Newsletter before sending"), exc=NewsletterNotSavedError)
+
+ def validate_newsletter_recipients(self):
+ if not self.newsletter_recipients:
+ frappe.throw(_("Newsletter should have atleast one recipient"), exc=NoRecipientFoundError)
+ self.validate_recipient_address()
+
+ def validate_sender_address(self):
+ """Validate self.send_from is a valid email address or not.
+ """
+ if self.send_from:
+ frappe.utils.validate_email_address(self.send_from, throw=True)
+
+ def validate_recipient_address(self):
+ """Validate if self.newsletter_recipients are all valid email addresses or not.
+ """
+ for recipient in self.newsletter_recipients:
+ frappe.utils.validate_email_address(recipient, throw=True)
+
+ def get_linked_email_queue(self) -> List[str]:
+ """Get list of email queue linked to this newsletter.
+ """
+ return frappe.get_all("Email Queue",
+ filters={
+ "reference_doctype": self.doctype,
+ "reference_name": self.name,
+ },
+ pluck="name",
+ )
+
+ def get_success_recipients(self) -> List[str]:
+ """Recipients who have already recieved the newsletter.
+
+ Couldn't think of a better name ;)
+ """
+ return frappe.get_all("Email Queue Recipient",
+ filters={
+ "status": ("in", ["Not Sent", "Sending", "Sent"]),
+ "parentfield": ("in", self.get_linked_email_queue()),
+ },
+ pluck="recipient",
+ )
+
+ def get_pending_recipients(self) -> List[str]:
+ """Get list of pending recipients of the newsletter. These
+ recipients may not have receive the newsletter in the previous iteration.
+ """
+ return [
+ x for x in self.newsletter_recipients if x not in self.get_success_recipients()
+ ]
+
+ def queue_all(self, test_emails: List[str] = None):
+ """Queue Newsletter to all the recipients generated from the `Email Group`
+ table
+
+ Args:
+ test_email (List[str], optional): Send test Newsletter to the passed set of emails.
+ Defaults to None.
+ """
+ if test_emails:
+ for test_email in test_emails:
+ frappe.utils.validate_email_address(test_email, throw=True)
+ else:
+ self.validate()
+ self.validate_send()
+
+ newsletter_recipients = test_emails or self.get_pending_recipients()
+ self.send_newsletter(emails=newsletter_recipients)
+
+ if not test_emails:
+ self.email_sent = True
+ self.schedule_send = frappe.utils.now_datetime()
+ self.scheduled_to_send = len(newsletter_recipients)
+ self.save()
+
+ def get_newsletter_attachments(self) -> List[Dict[str, str]]:
+ """Get list of attachments on current Newsletter
+ """
+ attachments = []
+
+ if self.send_attachments:
+ files = frappe.get_all(
+ "File",
+ filters={"attached_to_doctype": "Newsletter", "attached_to_name": self.name},
+ order_by="creation desc",
+ pluck="name",
+ )
+ attachments.extend({"fid": file} for file in files)
+
+ return attachments
+
+ def send_newsletter(self, emails: List[str]):
+ """Trigger email generation for `emails` and add it in Email Queue.
+ """
+ # TODO: get rid of this maybe?
+ message = self.get_message()
+ attachments = self.get_newsletter_attachments()
+ sender = self.send_from or frappe.utils.get_formatted_email(self.owner)
+ args = {"message": message, "name": self.name}
+
+ frappe.db.auto_commit_on_many_writes = not frappe.flags.in_test
+
+ frappe.sendmail(
+ subject=self.subject,
+ sender=sender,
+ recipients=emails,
+ message=message,
+ attachments=attachments,
+ template="newsletter",
+ add_unsubscribe_link=self.send_unsubscribe_link,
+ unsubscribe_method="/unsubscribe",
+ unsubscribe_params={"name": self.name},
+ reference_doctype=self.doctype,
+ reference_name=self.name,
+ queue_separately=True,
+ send_priority=0,
+ args=args,
+ )
+
+ frappe.db.auto_commit_on_many_writes = not frappe.flags.in_test
+
+ def get_message(self) -> str:
+ if self.content_type == "HTML":
+ return frappe.render_template(self.message_html, {"doc": self.as_dict()})
+ if self.content_type == "Markdown":
+ return frappe.utils.markdown(self.message_md)
+ # fallback to Rich Text
+ return self.message
+
+ def get_recipients(self) -> List[str]:
+ """Get recipients from Email Group"""
+ emails = frappe.get_all(
+ "Email Group Member",
+ filters={"unsubscribed": 0, "email_group": ("in", self.get_email_groups())},
+ pluck="email",
+ )
+ return list(set(emails))
+
+ def get_email_groups(self) -> List[str]:
+ # wondering why the 'or'? i can't figure out why both aren't equivalent - @gavin
+ return [
+ x.email_group for x in self.email_group
+ ] or frappe.get_all(
+ "Newsletter Email Group",
+ filters={"parent": self.name, "parenttype": "Newsletter"},
+ pluck="email_group",
+ )
+
+ def get_attachments(self) -> List[Dict[str, str]]:
+ return frappe.get_all(
+ "File",
+ fields=["name", "file_name", "file_url", "is_private"],
+ filters={
+ "attached_to_name": self.name,
+ "attached_to_doctype": "Newsletter",
+ "is_private": 0,
+ },
+ )
def get_context(self, context):
newsletters = get_newsletter_list("Newsletter", None, None, 0)
if newsletters:
newsletter_list = [d.name for d in newsletters]
if self.name not in newsletter_list:
- frappe.redirect_to_message(_('Permission Error'),
- _("You are not permitted to view the newsletter."))
+ frappe.redirect_to_message(
+ _("Permission Error"), _("You are not permitted to view the newsletter.")
+ )
frappe.local.flags.redirect_location = frappe.local.response.location
raise frappe.Redirect
else:
- context.attachments = get_attachments(self.name)
+ context.attachments = self.get_attachments()
context.no_cache = 1
context.show_sidebar = True
-def get_attachments(name):
- return frappe.get_all("File",
- fields=["name", "file_name", "file_url", "is_private"],
- filters = {"attached_to_name": name, "attached_to_doctype": "Newsletter", "is_private":0})
-
-
-def get_email_groups(name):
- return frappe.db.get_all("Newsletter Email Group", ["email_group"],{"parent":name, "parenttype":"Newsletter"})
-
-
@frappe.whitelist(allow_guest=True)
def confirmed_unsubscribe(email, group):
""" unsubscribe the email(user) from the mailing list(email_group) """
- frappe.flags.ignore_permissions=True
+ frappe.flags.ignore_permissions = True
doc = frappe.get_doc("Email Group Member", {"email": email, "email_group": group})
if not doc.unsubscribed:
doc.unsubscribed = 1
- doc.save(ignore_permissions = True)
-
-def create_lead(email_id):
- """create a lead if it does not exist"""
- from frappe.model.naming import get_default_naming_series
- full_name, email_id = parse_addr(email_id)
- if frappe.db.get_value("Lead", {"email_id": email_id}):
- return
-
- lead = frappe.get_doc({
- "doctype": "Lead",
- "email_id": email_id,
- "lead_name": full_name or email_id,
- "status": "Lead",
- "naming_series": get_default_naming_series("Lead"),
- "company": frappe.db.get_default("Company"),
- "source": "Email"
- })
- lead.insert()
+ doc.save(ignore_permissions=True)
@frappe.whitelist(allow_guest=True)
-def subscribe(email, email_group=_('Website')):
- url = frappe.utils.get_url("/api/method/frappe.email.doctype.newsletter.newsletter.confirm_subscription") +\
- "?" + get_signed_params({"email": email, "email_group": email_group})
+def subscribe(email, email_group=_("Website")):
+ """API endpoint to subscribe an email to a particular email group. Triggers a confirmation email.
+ """
- email_template = frappe.db.get_value('Email Group', email_group, ['confirmation_email_template'])
+ # build subscription confirmation URL
+ api_endpoint = frappe.utils.get_url(
+ "/api/method/frappe.email.doctype.newsletter.newsletter.confirm_subscription"
+ )
+ signed_params = get_signed_params({"email": email, "email_group": email_group})
+ confirm_subscription_url = f"{api_endpoint}?{signed_params}"
- content=''
- if email_template:
- args = dict(
- email=email,
- confirmation_url=url,
- email_group=email_group
- )
+ # fetch custom template if available
+ email_confirmation_template = frappe.db.get_value(
+ "Email Group", email_group, "confirmation_email_template"
+ )
- email_template = frappe.get_doc("Email Template", email_template)
+ # build email and send
+ if email_confirmation_template:
+ args = {"email": email, "confirmation_url": confirm_subscription_url, "email_group": email_group}
+ email_template = frappe.get_doc("Email Template", email_confirmation_template)
+ email_subject = email_template.subject
content = frappe.render_template(email_template.response, args)
-
- if not content:
- messages = (
+ else:
+ email_subject = _("Confirm Your Email")
+ translatable_content = (
_("Thank you for your interest in subscribing to our updates"),
_("Please verify your Email Address"),
- url,
- _("Click here to verify")
+ confirm_subscription_url,
+ _("Click here to verify"),
)
-
content = """
-
{0}. {1}.
- {3}
- """.format(*messages)
+ {0}. {1}.
+ {3}
+ """.format(*translatable_content)
+
+ frappe.sendmail(
+ email,
+ subject=email_subject,
+ content=content,
+ now=True,
+ )
- frappe.sendmail(email, subject=getattr('email_template', 'subject', '') or _("Confirm Your Email"), content=content, now=True)
@frappe.whitelist(allow_guest=True)
-def confirm_subscription(email, email_group=_('Website')):
+def confirm_subscription(email, email_group=_("Website")):
+ """API endpoint to confirm email subscription.
+ This endpoint is called when user clicks on the link sent to their mail.
+ """
if not verify_request():
return
if not frappe.db.exists("Email Group", email_group):
- frappe.get_doc({
- "doctype": "Email Group",
- "title": email_group
- }).insert(ignore_permissions=True)
+ frappe.get_doc({"doctype": "Email Group", "title": email_group}).insert(
+ ignore_permissions=True
+ )
frappe.flags.ignore_permissions = True
add_subscribers(email_group, email)
frappe.db.commit()
- frappe.respond_as_web_page(_("Confirmed"),
+ frappe.respond_as_web_page(
+ _("Confirmed"),
_("{0} has been successfully added to the Email Group.").format(email),
- indicator_color='green')
-
-
-def send_newsletter(newsletter):
- try:
- doc = frappe.get_doc("Newsletter", newsletter)
- doc.queue_all()
-
- except:
- frappe.db.rollback()
-
- # wasn't able to send emails :(
- doc.db_set("email_sent", 0)
- frappe.db.commit()
-
- frappe.log_error(title='Send Newsletter')
-
- raise
-
- else:
- frappe.db.commit()
+ indicator_color="green",
+ )
def get_list_context(context=None):
@@ -268,12 +349,35 @@ def get_newsletter_list(doctype, txt, filters, limit_start, limit_page_length=20
'''.format(','.join(['%s'] * len(email_group_list)),
limit_page_length, limit_start), email_group_list, as_dict=1)
+
def send_scheduled_email():
"""Send scheduled newsletter to the recipients."""
- scheduled_newsletter = frappe.get_all('Newsletter', filters = {
- 'schedule_send': ('<=', now_datetime()),
- 'email_sent': 0,
- 'schedule_sending': 1
- }, fields = ['name'], ignore_ifnull=True)
+ scheduled_newsletter = frappe.get_all(
+ "Newsletter",
+ filters={
+ "schedule_send": ("<=", frappe.utils.now_datetime()),
+ "email_sent": False,
+ "schedule_sending": True,
+ },
+ ignore_ifnull=True,
+ pluck="name",
+ )
+
for newsletter in scheduled_newsletter:
- send_newsletter(newsletter.name)
+ try:
+ frappe.get_doc("Newsletter", newsletter).queue_all()
+
+ except Exception:
+ frappe.db.rollback()
+
+ # wasn't able to send emails :(
+ frappe.db.set_value("Newsletter", newsletter, "email_sent", 0)
+ message = (
+ f"Newsletter {newsletter} failed to send"
+ "\n\n"
+ f"Traceback: {frappe.get_traceback()}"
+ )
+ frappe.log_error(title="Send Newsletter", message=message)
+
+ if not frappe.flags.in_test:
+ frappe.db.commit()
diff --git a/frappe/email/doctype/newsletter/test_newsletter.py b/frappe/email/doctype/newsletter/test_newsletter.py
index 3abd339ed9..abbcc6440c 100644
--- a/frappe/email/doctype/newsletter/test_newsletter.py
+++ b/frappe/email/doctype/newsletter/test_newsletter.py
@@ -1,17 +1,26 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# License: GNU General Public License v3. See license.txt
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# MIT License. See LICENSE
+
import unittest
from random import choice
+from typing import Union
+from unittest.mock import MagicMock, PropertyMock, patch
import frappe
-from frappe.email.doctype.newsletter.newsletter import (
- confirmed_unsubscribe,
- send_scheduled_email,
+from frappe.desk.form.load import run_onload
+from frappe.email.doctype.newsletter.exceptions import (
+ NewsletterAlreadySentError, NoRecipientFoundError
+)
+from frappe.email.doctype.newsletter.newsletter import (
+ Newsletter,
+ confirmed_unsubscribe,
+ get_newsletter_list,
+ send_scheduled_email
)
-from frappe.email.doctype.newsletter.newsletter import get_newsletter_list
from frappe.email.queue import flush
from frappe.utils import add_days, getdate
+
test_dependencies = ["Email Group"]
emails = [
"test_subscriber1@example.com",
@@ -19,23 +28,107 @@ emails = [
"test_subscriber3@example.com",
"test1@example.com",
]
+newsletters = []
-class TestNewsletter(unittest.TestCase):
+def get_dotted_path(obj: type) -> str:
+ klass = obj.__class__
+ module = klass.__module__
+ if module == 'builtins':
+ return klass.__qualname__ # avoid outputs like 'builtins.str'
+ return f"{module}.{klass.__qualname__}"
+
+
+class TestNewsletterMixin:
def setUp(self):
frappe.set_user("Administrator")
- frappe.db.sql("delete from `tabEmail Group Member`")
+ self.setup_email_group()
+ def tearDown(self):
+ frappe.set_user("Administrator")
+ for newsletter in newsletters:
+ frappe.db.delete("Email Queue", {
+ "reference_doctype": "Newsletter",
+ "reference_name": newsletter,
+ })
+ frappe.delete_doc("Newsletter", newsletter)
+ frappe.db.delete("Newsletter Email Group", newsletter)
+ newsletters.remove(newsletter)
+
+ def setup_email_group(self):
if not frappe.db.exists("Email Group", "_Test Email Group"):
- frappe.get_doc({"doctype": "Email Group", "title": "_Test Email Group"}).insert()
-
- for email in emails:
frappe.get_doc({
- "doctype": "Email Group Member",
- "email": email,
- "email_group": "_Test Email Group"
+ "doctype": "Email Group",
+ "title": "_Test Email Group"
}).insert()
+ for email in emails:
+ doctype = "Email Group Member"
+ email_filters = {
+ "email": email,
+ "email_group": "_Test Email Group"
+ }
+ try:
+ frappe.get_doc({
+ "doctype": doctype,
+ **email_filters,
+ }).insert()
+ except Exception:
+ frappe.db.update(doctype, email_filters, "unsubscribed", 0)
+
+ def send_newsletter(self, published=0, schedule_send=None) -> Union[str, None]:
+ frappe.db.delete("Email Queue")
+ frappe.db.delete("Email Queue Recipient")
+ frappe.db.delete("Newsletter")
+
+ newsletter_options = {
+ "published": published,
+ "schedule_sending": bool(schedule_send),
+ "schedule_send": schedule_send
+ }
+ newsletter = self.get_newsletter(**newsletter_options)
+
+ if schedule_send:
+ send_scheduled_email()
+ else:
+ newsletter.send_emails()
+ return newsletter.name
+
+ @staticmethod
+ def get_newsletter(**kwargs) -> "Newsletter":
+ """Generate and return Newsletter object
+ """
+ doctype = "Newsletter"
+ newsletter_content = {
+ "subject": "_Test Newsletter",
+ "send_from": "Test Sender ",
+ "content_type": "Rich Text",
+ "message": "Testing my news.",
+ }
+ similar_newsletters = frappe.db.get_all(doctype, newsletter_content, pluck="name")
+
+ for similar_newsletter in similar_newsletters:
+ frappe.delete_doc(doctype, similar_newsletter)
+
+ newsletter = frappe.get_doc({"doctype": doctype, **newsletter_content, **kwargs})
+ newsletter.append("email_group", {"email_group": "_Test Email Group"})
+ newsletter.save(ignore_permissions=True)
+ newsletter.reload()
+ newsletters.append(newsletter.name)
+
+ attached_files = frappe.get_all("File", {
+ "attached_to_doctype": newsletter.doctype,
+ "attached_to_name": newsletter.name,
+ },
+ pluck="name",
+ )
+ for file in attached_files:
+ frappe.delete_doc("File", file)
+
+ return newsletter
+
+
+class TestNewsletter(TestNewsletterMixin, unittest.TestCase):
def test_send(self):
self.send_newsletter()
@@ -64,40 +157,15 @@ class TestNewsletter(unittest.TestCase):
if email != to_unsubscribe:
self.assertTrue(email in recipients)
- @staticmethod
- def send_newsletter(published=0, schedule_send=None):
- frappe.db.sql("delete from `tabEmail Queue`")
- frappe.db.sql("delete from `tabEmail Queue Recipient`")
- frappe.db.sql("delete from `tabNewsletter`")
- newsletter = frappe.get_doc({
- "doctype": "Newsletter",
- "subject": "_Test Newsletter",
- "send_from": "Test Sender ",
- "content_type": "Rich Text",
- "message": "Testing my news.",
- "published": published,
- "schedule_sending": bool(schedule_send),
- "schedule_send": schedule_send
- }).insert(ignore_permissions=True)
-
- newsletter.append("email_group", {"email_group": "_Test Email Group"})
- newsletter.save()
- if schedule_send:
- send_scheduled_email()
- return
-
- newsletter.send_emails()
- return newsletter.name
-
def test_portal(self):
- self.send_newsletter(1)
+ self.send_newsletter(published=1)
frappe.set_user("test1@example.com")
- newsletters = get_newsletter_list("Newsletter", None, None, 0)
- self.assertEqual(len(newsletters), 1)
+ newsletter_list = get_newsletter_list("Newsletter", None, None, 0)
+ self.assertEqual(len(newsletter_list), 1)
def test_newsletter_context(self):
context = frappe._dict()
- newsletter_name = self.send_newsletter(1)
+ newsletter_name = self.send_newsletter(published=1)
frappe.set_user("test2@example.com")
doc = frappe.get_doc("Newsletter", newsletter_name)
doc.get_context(context)
@@ -112,3 +180,68 @@ class TestNewsletter(unittest.TestCase):
recipients = [e.recipients[0].recipient for e in email_queue_list]
for email in emails:
self.assertTrue(email in recipients)
+
+ def test_newsletter_test_send(self):
+ """Test "Test Send" functionality of Newsletter
+ """
+ newsletter = self.get_newsletter()
+ newsletter.test_email_id = choice(emails)
+ newsletter.test_send()
+
+ self.assertFalse(newsletter.email_sent)
+ newsletter.save = MagicMock()
+ self.assertFalse(newsletter.save.called)
+
+ def test_newsletter_status(self):
+ """Test for Newsletter's stats on onload event
+ """
+ newsletter = self.get_newsletter()
+ newsletter.email_sent = True
+ # had to use run_onload as calling .onload directly bought weird errors
+ # like TestNewsletter has no attribute "_TestNewsletter__onload"
+ run_onload(newsletter)
+ self.assertIsInstance(newsletter.get("__onload").status_count, dict)
+
+ def test_already_sent_newsletter(self):
+ newsletter = self.get_newsletter()
+ newsletter.send_emails()
+
+ with self.assertRaises(NewsletterAlreadySentError):
+ newsletter.send_emails()
+
+ def test_newsletter_with_no_recipient(self):
+ newsletter = self.get_newsletter()
+ property_path = f"{get_dotted_path(newsletter)}.newsletter_recipients"
+
+ with patch(property_path, new_callable=PropertyMock) as mock_newsletter_recipients:
+ mock_newsletter_recipients.return_value = []
+ with self.assertRaises(NoRecipientFoundError):
+ newsletter.send_emails()
+
+ def test_send_newsletter_with_attachments(self):
+ newsletter = self.get_newsletter()
+ newsletter.reload()
+ file_attachment = frappe.get_doc({
+ "doctype": "File",
+ "file_name": "test1.txt",
+ "attached_to_doctype": newsletter.doctype,
+ "attached_to_name": newsletter.name,
+ "content": frappe.mock("paragraph")
+ })
+ file_attachment.save()
+ newsletter.send_attachments = True
+ newsletter_attachments = newsletter.get_newsletter_attachments()
+ self.assertEqual(len(newsletter_attachments), 1)
+ self.assertEqual(newsletter_attachments[0]["fid"], file_attachment.name)
+
+ def test_send_scheduled_email_error_handling(self):
+ newsletter = self.get_newsletter(schedule_send=add_days(getdate(), -1))
+ job_path = "frappe.email.doctype.newsletter.newsletter.Newsletter.queue_all"
+ m = MagicMock(side_effect=frappe.OutgoingEmailError)
+
+ with self.assertRaises(frappe.OutgoingEmailError):
+ with patch(job_path, new_callable=m):
+ send_scheduled_email()
+
+ newsletter.reload()
+ self.assertEqual(newsletter.email_sent, 0)
diff --git a/frappe/integrations/doctype/ldap_settings/ldap_settings.json b/frappe/integrations/doctype/ldap_settings/ldap_settings.json
index 5d30a873fb..d915ae2ad6 100644
--- a/frappe/integrations/doctype/ldap_settings/ldap_settings.json
+++ b/frappe/integrations/doctype/ldap_settings/ldap_settings.json
@@ -1,4 +1,5 @@
{
+ "actions": [],
"creation": "2016-09-22 04:16:48.829658",
"doctype": "DocType",
"document_type": "System",
@@ -6,18 +7,24 @@
"engine": "InnoDB",
"field_order": [
"enabled",
- "ldap_server_url",
+ "ldap_server_settings_section",
+ "ldap_directory_server",
"column_break_4",
+ "ldap_server_url",
+ "ldap_auth_section",
"base_dn",
+ "column_break_8",
"password",
- "section_break_5",
- "organizational_unit",
- "default_role",
+ "ldap_search_and_paths_section",
+ "ldap_search_path_user",
"ldap_search_string",
+ "column_break_12",
+ "ldap_search_path_group",
+ "ldap_user_creation_and_mapping_section",
"ldap_email_field",
"ldap_username_field",
- "column_break_11",
"ldap_first_name_field",
+ "column_break_19",
"ldap_middle_name_field",
"ldap_last_name_field",
"ldap_phone_field",
@@ -25,13 +32,18 @@
"ldap_security",
"ssl_tls_mode",
"require_trusted_certificate",
- "column_break_17",
+ "column_break_27",
"local_private_key_file",
"local_server_certificate_file",
"local_ca_certs_file",
+ "ldap_custom_settings_section",
+ "ldap_group_objectclass",
+ "column_break_33",
+ "ldap_group_member_attribute",
"ldap_group_mappings_section",
- "ldap_group_field",
- "ldap_groups"
+ "default_role",
+ "ldap_groups",
+ "ldap_group_field"
],
"fields": [
{
@@ -65,18 +77,6 @@
"label": "Password for Base DN",
"reqd": 1
},
- {
- "fieldname": "section_break_5",
- "fieldtype": "Section Break",
- "label": "LDAP User Creation and Mapping"
- },
- {
- "fieldname": "organizational_unit",
- "fieldtype": "Data",
- "in_list_view": 1,
- "label": "Organizational Unit for Users",
- "reqd": 1
- },
{
"fieldname": "default_role",
"fieldtype": "Link",
@@ -85,6 +85,7 @@
"reqd": 1
},
{
+ "description": "Must be enclosed in '()' and include '{0}', which is a placeholder for the user/login name. i.e. (&(objectclass=user)(uid={0}))",
"fieldname": "ldap_search_string",
"fieldtype": "Data",
"label": "LDAP Search String",
@@ -102,10 +103,6 @@
"label": "LDAP Username Field",
"reqd": 1
},
- {
- "fieldname": "column_break_11",
- "fieldtype": "Column Break"
- },
{
"fieldname": "ldap_first_name_field",
"fieldtype": "Data",
@@ -152,10 +149,6 @@
"options": "No\nYes",
"reqd": 1
},
- {
- "fieldname": "column_break_17",
- "fieldtype": "Column Break"
- },
{
"fieldname": "local_private_key_file",
"fieldtype": "Data",
@@ -177,6 +170,7 @@
"label": "LDAP Group Mappings"
},
{
+ "description": "NOTE: This box is due for depreciation. Please re-setup LDAP to work with the newer settings",
"fieldname": "ldap_group_field",
"fieldtype": "Data",
"label": "LDAP Group Field"
@@ -186,11 +180,93 @@
"fieldtype": "Table",
"label": "LDAP Group Mappings",
"options": "LDAP Group Mapping"
+ },
+ {
+ "fieldname": "ldap_server_settings_section",
+ "fieldtype": "Section Break",
+ "label": "LDAP Server Settings"
+ },
+ {
+ "fieldname": "ldap_auth_section",
+ "fieldtype": "Section Break",
+ "label": "LDAP Auth"
+ },
+ {
+ "fieldname": "column_break_8",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "ldap_search_and_paths_section",
+ "fieldtype": "Section Break",
+ "label": "LDAP Search and Paths"
+ },
+ {
+ "fieldname": "column_break_12",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "ldap_user_creation_and_mapping_section",
+ "fieldtype": "Section Break",
+ "label": "LDAP User Creation and Mapping"
+ },
+ {
+ "fieldname": "column_break_19",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_27",
+ "fieldtype": "Column Break"
+ },
+ {
+ "description": "These settings are required if 'Custom' LDAP Directory is used",
+ "fieldname": "ldap_custom_settings_section",
+ "fieldtype": "Section Break",
+ "label": "LDAP Custom Settings"
+ },
+ {
+ "fieldname": "column_break_33",
+ "fieldtype": "Column Break"
+ },
+ {
+ "description": "string value, i.e. member",
+ "fieldname": "ldap_group_member_attribute",
+ "fieldtype": "Data",
+ "label": "LDAP Group Member attribute"
+ },
+ {
+ "description": "Please select the LDAP Directory being used",
+ "fieldname": "ldap_directory_server",
+ "fieldtype": "Select",
+ "label": "Directory Server",
+ "options": "\nActive Directory\nOpenLDAP\nCustom",
+ "reqd": 1
+ },
+ {
+ "description": "string value, i.e. group",
+ "fieldname": "ldap_group_objectclass",
+ "fieldtype": "Data",
+ "label": "Group Object Class"
+ },
+ {
+ "description": "Requires any valid fdn path. i.e. ou=users,dc=example,dc=com",
+ "fieldname": "ldap_search_path_user",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "LDAP search path for Users",
+ "reqd": 1
+ },
+ {
+ "description": "Requires any valid fdn path. i.e. ou=groups,dc=example,dc=com",
+ "fieldname": "ldap_search_path_group",
+ "fieldtype": "Data",
+ "label": "LDAP search path for Groups",
+ "reqd": 1
}
],
"in_create": 1,
"issingle": 1,
- "modified": "2019-07-15 06:48:16.562109",
+ "links": [],
+ "modified": "2021-07-27 11:51:43.328271",
"modified_by": "Administrator",
"module": "Integrations",
"name": "LDAP Settings",
diff --git a/frappe/integrations/doctype/ldap_settings/ldap_settings.py b/frappe/integrations/doctype/ldap_settings/ldap_settings.py
index acc8b96679..7c89c31844 100644
--- a/frappe/integrations/doctype/ldap_settings/ldap_settings.py
+++ b/frappe/integrations/doctype/ldap_settings/ldap_settings.py
@@ -13,10 +13,44 @@ class LDAPSettings(Document):
return
if not self.flags.ignore_mandatory:
- if self.ldap_search_string and self.ldap_search_string.endswith("={0}"):
- self.connect_to_ldap(base_dn=self.base_dn, password=self.get_password(raise_exception=False))
+
+ if self.ldap_search_string.count('(') == self.ldap_search_string.count(')') and \
+ self.ldap_search_string.startswith('(') and \
+ self.ldap_search_string.endswith(')') and \
+ self.ldap_search_string and \
+ "{0}" in self.ldap_search_string:
+
+ conn = self.connect_to_ldap(base_dn=self.base_dn, password=self.get_password(raise_exception=False))
+
+ try:
+ if conn.result['type'] == 'bindResponse' and self.base_dn:
+ import ldap3
+
+ conn.search(
+ search_base=self.ldap_search_path_user,
+ search_filter="(objectClass=*)",
+ attributes=self.get_ldap_attributes())
+
+ conn.search(
+ search_base=self.ldap_search_path_group,
+ search_filter="(objectClass=*)",
+ attributes=['cn'])
+
+ except ldap3.core.exceptions.LDAPAttributeError as ex:
+ frappe.throw(_("LDAP settings incorrect. validation response was: {0}").format(ex),
+ title=_("Misconfigured"))
+
+ except ldap3.core.exceptions.LDAPNoSuchObjectResult:
+ frappe.throw(_("Ensure the user and group search paths are correct."),
+ title=_("Misconfigured"))
+
+ if self.ldap_directory_server.lower() == 'custom':
+ if not self.ldap_group_member_attribute or not self.ldap_group_mappings_section:
+ frappe.throw(_("Custom LDAP Directoy Selected, please ensure 'LDAP Group Member attribute' and 'LDAP Group Mappings' are entered"),
+ title=_("Misconfigured"))
+
else:
- frappe.throw(_("LDAP Search String needs to end with a placeholder, eg sAMAccountName={0}"))
+ frappe.throw(_("LDAP Search String must be enclosed in '()' and needs to contian the user placeholder {0}, eg sAMAccountName={0}"))
def connect_to_ldap(self, base_dn, password, read_only=True):
try:
@@ -118,8 +152,8 @@ class LDAPSettings(Document):
user.insert(ignore_permissions=True)
# always add default role.
user.add_roles(self.default_role)
- if self.ldap_group_field:
- self.sync_roles(user, groups)
+ self.sync_roles(user, groups)
+
return user
def get_ldap_attributes(self):
@@ -142,6 +176,66 @@ class LDAPSettings(Document):
return ldap_attributes
+
+ def fetch_ldap_groups(self, user, conn):
+ import ldap3
+
+ if type(user) is not ldap3.abstract.entry.Entry:
+ raise TypeError("Invalid type, attribute {0} must be of type '{1}'".format('user', 'ldap3.abstract.entry.Entry'))
+
+ if type(conn) is not ldap3.core.connection.Connection:
+ raise TypeError("Invalid type, attribute {0} must be of type '{1}'".format('conn', 'ldap3.Connection'))
+
+ fetch_ldap_groups = None
+
+ ldap_object_class = None
+ ldap_group_members_attribute = None
+
+
+ if self.ldap_directory_server.lower() == 'active directory':
+
+ ldap_object_class = 'Group'
+ ldap_group_members_attribute = 'member'
+ user_search_str = user.entry_dn
+
+
+ elif self.ldap_directory_server.lower() == 'openldap':
+
+ ldap_object_class = 'posixgroup'
+ ldap_group_members_attribute = 'memberuid'
+ user_search_str = getattr(user, self.ldap_username_field).value
+
+ elif self.ldap_directory_server.lower() == 'custom':
+
+ ldap_object_class = self.ldap_group_objectclass
+ ldap_group_members_attribute = self.ldap_group_member_attribute
+ user_search_str = getattr(user, self.ldap_username_field).value
+
+ else:
+ # NOTE: depreciate this else path
+ # this path will be hit for everyone with preconfigured ldap settings. this must be taken into account so as not to break ldap for those users.
+
+ if self.ldap_group_field:
+
+ fetch_ldap_groups = getattr(user, self.ldap_group_field).values
+
+ if ldap_object_class is not None:
+ conn.search(
+ search_base=self.ldap_search_path_group,
+ search_filter="(&(objectClass={0})({1}={2}))".format(ldap_object_class,ldap_group_members_attribute, user_search_str),
+ attributes=['cn']) # Build search query
+
+ if len(conn.entries) >= 1:
+
+ fetch_ldap_groups = []
+ for group in conn.entries:
+ fetch_ldap_groups.append(group['cn'].value)
+
+ return fetch_ldap_groups
+
+
+
+
def authenticate(self, username, password):
if not self.enabled:
@@ -152,23 +246,33 @@ class LDAPSettings(Document):
conn = self.connect_to_ldap(self.base_dn, self.get_password(raise_exception=False))
- conn.search(
- search_base=self.organizational_unit,
- search_filter="({0})".format(user_filter),
- attributes=ldap_attributes)
+ try:
+ import ldap3
- if len(conn.entries) == 1 and conn.entries[0]:
- user = conn.entries[0]
- # only try and connect as the user, once we have their fqdn entry.
- self.connect_to_ldap(base_dn=user.entry_dn, password=password)
+ conn.search(
+ search_base=self.ldap_search_path_user,
+ search_filter="{0}".format(user_filter),
+ attributes=ldap_attributes)
- groups = None
- if self.ldap_group_field:
- groups = getattr(user, self.ldap_group_field).values
- return self.create_or_update_user(self.convert_ldap_entry_to_dict(user), groups=groups)
- else:
+ if len(conn.entries) == 1 and conn.entries[0]:
+ user = conn.entries[0]
+
+ groups = self.fetch_ldap_groups(user, conn)
+
+ # only try and connect as the user, once we have their fqdn entry.
+ if user.entry_dn and password and conn.rebind(user=user.entry_dn, password=password):
+
+ return self.create_or_update_user(self.convert_ldap_entry_to_dict(user), groups=groups)
+
+ raise ldap3.core.exceptions.LDAPInvalidCredentialsResult # even though nothing foundor failed authentication raise invalid credentials
+
+ except ldap3.core.exceptions.LDAPInvalidFilterError:
+ frappe.throw(_("Please use a valid LDAP search filter"), title=_("Misconfigured"))
+
+ except ldap3.core.exceptions.LDAPInvalidCredentialsResult:
frappe.throw(_("Invalid username or password"))
+
def reset_password(self, user, password, logout_sessions=False):
from ldap3 import HASHED_SALTED_SHA, MODIFY_REPLACE
from ldap3.utils.hashed import hashed
@@ -179,7 +283,7 @@ class LDAPSettings(Document):
read_only=False)
if conn.search(
- search_base=self.organizational_unit,
+ search_base=self.ldap_search_path_user,
search_filter=search_filter,
attributes=self.get_ldap_attributes()
):
diff --git a/frappe/integrations/doctype/ldap_settings/test_data_ldif_activedirectory.json b/frappe/integrations/doctype/ldap_settings/test_data_ldif_activedirectory.json
new file mode 100644
index 0000000000..9777452af8
--- /dev/null
+++ b/frappe/integrations/doctype/ldap_settings/test_data_ldif_activedirectory.json
@@ -0,0 +1,338 @@
+{
+ "entries": [
+ {
+ "attributes": {
+ "cn": "base_dn_user",
+ "memberOf": [
+ "cn=Domain Users,ou=Groups,dc=unit,dc=testing",
+ "cn=Enterprise Administrators,ou=Groups,dc=unit,dc=testing"
+ ],
+ "objectClass": [
+ "user",
+ "top",
+ "person",
+ "organizationalPerson"
+ ],
+ "samaccountname": "cn=base_dn_user,dc=unit,dc=testing",
+ "sn": "user_sn",
+ "userPassword": [
+ "my_password"
+ ]
+ },
+ "dn": "cn=base_dn_user,dc=unit,dc=testing",
+ "raw": {
+ "cn": [
+ "base_dn_user"
+ ],
+ "memberOf": [
+ "cn=Domain Users,ou=Groups,dc=unit,dc=testing",
+ "cn=Enterprise Administrators,ou=Groups,dc=unit,dc=testing"
+ ],
+ "objectClass": [
+ "user",
+ "top",
+ "person",
+ "organizationalPerson"
+ ],
+ "samaccountname": [
+ "cn=base_dn_user,dc=unit,dc=testing"
+ ],
+ "sn": [
+ "user_sn"
+ ],
+ "userPassword": [
+ "my_password"
+ ]
+ }
+ },
+ {
+ "attributes": {
+ "cn": "Posix User1",
+ "description": [
+ "ACCESS:test1,ACCESS:test2"
+ ],
+ "givenname": "Posix",
+ "mail": "posix.user1@unit.testing",
+ "memberOf": [
+ "cn=Domain Users,ou=Groups,dc=unit,dc=testing",
+ "cn=Domain Administrators,ou=Groups,dc=unit,dc=testing"
+ ],
+ "mobile": "0421 123 456",
+ "objectClass": [
+ "user",
+ "top",
+ "person",
+ "organizationalPerson"
+ ],
+ "samaccountname": "posix.user",
+ "sn": "User1",
+ "telephonenumber": "08 8912 3456",
+ "userpassword": [
+ "posix_user_password"
+ ]
+ },
+ "dn": "cn=Posix User1,ou=Users,dc=unit,dc=testing",
+ "raw": {
+ "cn": [
+ "Posix User1"
+ ],
+ "description": [
+ "ACCESS:test1,ACCESS:test2"
+ ],
+ "givenname": [
+ "Posix"
+ ],
+ "mail": [
+ "posix.user1@unit.testing"
+ ],
+ "memberOf": [
+ "cn=Domain Users,ou=Groups,dc=unit,dc=testing",
+ "cn=Domain Administrators,ou=Groups,dc=unit,dc=testing"
+ ],
+ "mobile": [
+ "0421 123 456"
+ ],
+ "objectClass": [
+ "user",
+ "top",
+ "person",
+ "organizationalPerson"
+ ],
+ "samaccountname": [
+ "posix.user"
+ ],
+ "sn": [
+ "User1"
+ ],
+ "telephonenumber": [
+ "08 8912 3456"
+ ],
+ "userpassword": [
+ "posix_user_password"
+ ]
+ }
+ },
+ {
+ "attributes": {
+ "cn": "Posix User2",
+ "description": [
+ "ACCESS:test1,ACCESS:test3"
+ ],
+ "givenname": "Posix",
+ "homedirectory": "/home/users/posix.user2",
+ "mail": "posix.user2@unit.testing",
+ "memberOf": [
+ "cn=Domain Users,ou=Groups,dc=unit,dc=testing",
+ "cn=Enterprise Administrators,ou=Groups,dc=unit,dc=testing"
+ ],
+ "mobile": "0421 456 789",
+ "objectClass": [
+ "user",
+ "top",
+ "person",
+ "organizationalPerson"
+ ],
+ "samaccountname": "posix.user2",
+ "sn": "User2",
+ "telephonenumber": "08 8978 1234",
+ "userpassword": [
+ "posix_user2_password"
+ ]
+ },
+ "dn": "cn=Posix User2,ou=Users,dc=unit,dc=testing",
+ "raw": {
+ "cn": [
+ "Posix User2"
+ ],
+ "description": [
+ "ACCESS:test1,ACCESS:test3"
+ ],
+ "givenname": [
+ "Posix"
+ ],
+ "homedirectory": [
+ "/home/users/posix.user2"
+ ],
+ "mail": [
+ "posix.user2@unit.testing"
+ ],
+ "memberOf": [
+ "cn=Domain Users,ou=Groups,dc=unit,dc=testing",
+ "cn=Enterprise Administrators,ou=Groups,dc=unit,dc=testing"
+ ],
+ "mobile": [
+ "0421 456 789"
+ ],
+ "objectClass": [
+ "user",
+ "top",
+ "person",
+ "organizationalPerson"
+ ],
+ "samaccountname": [
+ "posix.user2"
+ ],
+ "sn": [
+ "User2"
+ ],
+ "telephonenumber": [
+ "08 8978 1234"
+ ],
+ "userpassword": [
+ "posix_user2_password"
+ ]
+ }
+ },
+ {
+ "attributes": {
+ "objectClass": [
+ "top",
+ "organizationalUnit"
+ ],
+ "ou": [
+ "Users"
+ ]
+ },
+ "dn": "ou=Users,dc=unit,dc=testing",
+ "raw": {
+ "objectClass": [
+ "top",
+ "organizationalUnit"
+ ],
+ "ou": [
+ "Users"
+ ]
+ }
+ },
+ {
+ "attributes": {
+ "Member": [
+ "cn=Posix User2,ou=Users,dc=unit,dc=testing"
+ ],
+ "cn": "Enterprise Administrators",
+ "description": [
+ "group contains only posix.user2"
+ ],
+ "groupType": 2147483652,
+ "objectClass": [
+ "top",
+ "group"
+ ]
+ },
+ "dn": "cn=Enterprise Administrators,ou=Groups,dc=unit,dc=testing",
+ "raw": {
+ "Member": [
+ "cn=Posix User2,ou=Users,dc=unit,dc=testing"
+ ],
+ "cn": [
+ "Enterprise Administrators"
+ ],
+ "description": [
+ "group contains only posix.user2"
+ ],
+ "groupType": [
+ "2147483652"
+ ],
+ "objectClass": [
+ "top",
+ "group"
+ ]
+ }
+ },
+ {
+ "attributes": {
+ "Member": [
+ "cn=Posix User1,ou=Users,dc=unit,dc=testing",
+ "cn=Posix User2,ou=Users,dc=unit,dc=testing"
+ ],
+ "cn": "Domain Users",
+ "description": [
+ "group2 Users contains only posix.user and posix.user2"
+ ],
+ "groupType": 2147483652,
+ "objectClass": [
+ "top",
+ "group"
+ ]
+ },
+ "dn": "cn=Domain Users,ou=Groups,dc=unit,dc=testing",
+ "raw": {
+ "Member": [
+ "cn=Posix User1,ou=Users,dc=unit,dc=testing",
+ "cn=Posix User2,ou=Users,dc=unit,dc=testing"
+ ],
+ "cn": [
+ "Domain Users"
+ ],
+ "description": [
+ "group2 Users contains only posix.user and posix.user2"
+ ],
+ "groupType": [
+ "2147483652"
+ ],
+ "objectClass": [
+ "top",
+ "group"
+ ]
+ }
+ },
+ {
+ "attributes": {
+ "Member": [
+ "cn=Posix User1,ou=Users,dc=unit,dc=testing",
+ "cn=base_dn_user,dc=unit,dc=testing"
+ ],
+ "cn": "Domain Administrators",
+ "description": [
+ "group1 Administrators contains only posix.user only"
+ ],
+ "groupType": 2147483652,
+ "objectClass": [
+ "top",
+ "group"
+ ]
+ },
+ "dn": "cn=Domain Administrators,ou=Groups,dc=unit,dc=testing",
+ "raw": {
+ "Member": [
+ "cn=Posix User1,ou=Users,dc=unit,dc=testing",
+ "cn=base_dn_user,dc=unit,dc=testing"
+ ],
+ "cn": [
+ "Domain Administrators"
+ ],
+ "description": [
+ "group1 Administrators contains only posix.user only"
+ ],
+ "groupType": [
+ "2147483652"
+ ],
+ "objectClass": [
+ "top",
+ "group"
+ ]
+ }
+ },
+ {
+ "attributes": {
+ "objectClass": [
+ "top",
+ "organizationalUnit"
+ ],
+ "ou": [
+ "Groups"
+ ]
+ },
+ "dn": "ou=Groups,dc=unit,dc=testing",
+ "raw": {
+ "objectClass": [
+ "top",
+ "organizationalUnit"
+ ],
+ "ou": [
+ "Groups"
+ ]
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/frappe/integrations/doctype/ldap_settings/test_data_ldif_openldap.json b/frappe/integrations/doctype/ldap_settings/test_data_ldif_openldap.json
new file mode 100644
index 0000000000..86a76c1abc
--- /dev/null
+++ b/frappe/integrations/doctype/ldap_settings/test_data_ldif_openldap.json
@@ -0,0 +1,400 @@
+{
+ "entries": [
+ {
+ "attributes": {
+ "cn": [
+ "base_dn_user"
+ ],
+ "objectClass": [
+ "simpleSecurityObject",
+ "organizationalRole",
+ "top"
+ ],
+ "sn": [
+ "user_sn"
+ ],
+ "userPassword": [
+ "my_password"
+ ]
+ },
+ "dn": "cn=base_dn_user,dc=unit,dc=testing",
+ "raw": {
+ "cn": [
+ "base_dn_user"
+ ],
+ "objectClass": [
+ "simpleSecurityObject",
+ "organizationalRole",
+ "top"
+ ],
+ "sn": [
+ "user_sn"
+ ],
+ "userPassword": [
+ "my_password"
+ ]
+ }
+ },
+ {
+ "attributes": {
+ "cn": [
+ "Posix User2"
+ ],
+ "description": [
+ "ACCESS:test1,ACCESS:test3"
+ ],
+ "gidnumber": 501,
+ "givenname": [
+ "Posix2"
+ ],
+ "homedirectory": "/home/users/posix.user2",
+ "mail": [
+ "posix.user2@unit.testing"
+ ],
+ "mobile": [
+ "0421 456 789"
+ ],
+ "objectClass": [
+ "posixAccount",
+ "top",
+ "inetOrgPerson",
+ "person",
+ "organizationalPerson"
+ ],
+ "sn": [
+ "User2"
+ ],
+ "telephonenumber": [
+ "08 8978 1234"
+ ],
+ "uid": [
+ "posix.user2"
+ ],
+ "uidnumber": 1000,
+ "userpassword": [
+ "posix_user2_password"
+ ]
+ },
+ "dn": "cn=Posix User2,ou=users,dc=unit,dc=testing",
+ "raw": {
+ "cn": [
+ "Posix User2"
+ ],
+ "description": [
+ "ACCESS:test1,ACCESS:test3"
+ ],
+ "gidnumber": [
+ "501"
+ ],
+ "givenname": [
+ "Posix2"
+ ],
+ "homedirectory": [
+ "/home/users/posix.user2"
+ ],
+ "mail": [
+ "posix.user2@unit.testing"
+ ],
+ "mobile": [
+ "0421 456 789"
+ ],
+ "objectClass": [
+ "posixAccount",
+ "top",
+ "inetOrgPerson",
+ "person",
+ "organizationalPerson"
+ ],
+ "sn": [
+ "User2"
+ ],
+ "telephonenumber": [
+ "08 8978 1234"
+ ],
+ "uid": [
+ "posix.user2"
+ ],
+ "uidnumber": [
+ "1000"
+ ],
+ "userpassword": [
+ "posix_user2_password"
+ ]
+ }
+ },
+ {
+ "attributes": {
+ "cn": [
+ "Posix User1"
+ ],
+ "description": [
+ "ACCESS:test1,ACCESS:test2"
+ ],
+ "gidnumber": 501,
+ "givenname": [
+ "Posix"
+ ],
+ "homedirectory": "/home/users/posix.user",
+ "mail": [
+ "posix.user1@unit.testing"
+ ],
+ "mobile": [
+ "0421 123 456"
+ ],
+ "objectClass": [
+ "posixAccount",
+ "top",
+ "inetOrgPerson",
+ "person",
+ "organizationalPerson"
+ ],
+ "sn": [
+ "User1"
+ ],
+ "telephonenumber": [
+ "08 8912 3456"
+ ],
+ "uid": [
+ "posix.user"
+ ],
+ "uidnumber": 1000,
+ "userpassword": [
+ "posix_user_password"
+ ]
+ },
+ "dn": "cn=Posix User1,ou=users,dc=unit,dc=testing",
+ "raw": {
+ "cn": [
+ "Posix User1"
+ ],
+ "description": [
+ "ACCESS:test1,ACCESS:test2"
+ ],
+ "gidnumber": [
+ "501"
+ ],
+ "givenname": [
+ "Posix"
+ ],
+ "homedirectory": [
+ "/home/users/posix.user"
+ ],
+ "mail": [
+ "posix.user1@unit.testing"
+ ],
+ "mobile": [
+ "0421 123 456"
+ ],
+ "objectClass": [
+ "posixAccount",
+ "top",
+ "inetOrgPerson",
+ "person",
+ "organizationalPerson"
+ ],
+ "sn": [
+ "User1"
+ ],
+ "telephonenumber": [
+ "08 8912 3456"
+ ],
+ "uid": [
+ "posix.user"
+ ],
+ "uidnumber": [
+ "1000"
+ ],
+ "userpassword": [
+ "posix_user_password"
+ ]
+ }
+ },
+ {
+ "attributes": {
+ "objectClass": [
+ "top",
+ "organizationalUnit"
+ ],
+ "ou": [
+ "Users",
+ "users"
+ ]
+ },
+ "dn": "ou=users,dc=unit,dc=testing",
+ "raw": {
+ "objectClass": [
+ "top",
+ "organizationalUnit"
+ ],
+ "ou": [
+ "Users",
+ "users"
+ ]
+ }
+ },
+ {
+ "attributes": {
+ "dc": "testing",
+ "o": [
+ "Testing"
+ ],
+ "objectClass": [
+ "top",
+ "organization",
+ "dcObject"
+ ]
+ },
+ "dn": "dc=unit,dc=testing",
+ "raw": {
+ "dc": [
+ "testing",
+ "unit"
+ ],
+ "o": [
+ "Testing"
+ ],
+ "objectClass": [
+ "top",
+ "organization",
+ "dcObject"
+ ]
+ }
+ },
+ {
+ "attributes": {
+ "cn": [
+ "Users"
+ ],
+ "description": [
+ "group2 Users contains only posix.user and posix.user2"
+ ],
+ "gidnumber": 501,
+ "memberuid": [
+ "posix.user2",
+ "posix.user"
+ ],
+ "objectClass": [
+ "top",
+ "posixGroup"
+ ]
+ },
+ "dn": "cn=Users,ou=groups,dc=unit,dc=testing",
+ "raw": {
+ "cn": [
+ "Users"
+ ],
+ "description": [
+ "group2 Users contains only posix.user and posix.user2"
+ ],
+ "gidnumber": [
+ "501"
+ ],
+ "memberuid": [
+ "posix.user2",
+ "posix.user"
+ ],
+ "objectClass": [
+ "top",
+ "posixGroup"
+ ]
+ }
+ },
+ {
+ "attributes": {
+ "cn": [
+ "Administrators"
+ ],
+ "description": [
+ "group1 Administrators contains only posix.user only"
+ ],
+ "gidnumber": 500,
+ "memberuid": [
+ "posix.user"
+ ],
+ "objectClass": [
+ "top",
+ "posixGroup"
+ ]
+ },
+ "dn": "cn=Administrators,ou=groups,dc=unit,dc=testing",
+ "raw": {
+ "cn": [
+ "Administrators"
+ ],
+ "description": [
+ "group1 Administrators contains only posix.user only"
+ ],
+ "gidnumber": [
+ "500"
+ ],
+ "memberuid": [
+ "posix.user"
+ ],
+ "objectClass": [
+ "top",
+ "posixGroup"
+ ]
+ }
+ },
+ {
+ "attributes": {
+ "cn": [
+ "Group3"
+ ],
+ "description": [
+ "group3 Group3 contains only posix.user2 only"
+ ],
+ "gidnumber": 502,
+ "memberuid": [
+ "posix.user2"
+ ],
+ "objectClass": [
+ "top",
+ "posixGroup"
+ ]
+ },
+ "dn": "cn=Group3,ou=groups,dc=unit,dc=testing",
+ "raw": {
+ "cn": [
+ "Group3"
+ ],
+ "description": [
+ "group3 Group3 contains only posix.user2 only"
+ ],
+ "gidnumber": [
+ "502"
+ ],
+ "memberuid": [
+ "posix.user2"
+ ],
+ "objectClass": [
+ "top",
+ "posixGroup"
+ ]
+ }
+ },
+ {
+ "attributes": {
+ "objectClass": [
+ "top",
+ "organizationalUnit"
+ ],
+ "ou": [
+ "Users",
+ "groups"
+ ]
+ },
+ "dn": "ou=groups,dc=unit,dc=testing",
+ "raw": {
+ "objectClass": [
+ "top",
+ "organizationalUnit"
+ ],
+ "ou": [
+ "Users",
+ "groups"
+ ]
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py b/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py
index 113692b6c4..1b2a9b155f 100644
--- a/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py
+++ b/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py
@@ -1,8 +1,684 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and Contributors
# See license.txt
-# import frappe
+import frappe
import unittest
+import functools
+import ldap3
+import ssl
+import os
+
+from unittest import mock
+from frappe.integrations.doctype.ldap_settings.ldap_settings import LDAPSettings
+from ldap3 import Server, Connection, MOCK_SYNC, OFFLINE_SLAPD_2_4, OFFLINE_AD_2012_R2
+
+
+class LDAP_TestCase():
+ TEST_LDAP_SERVER = None # must match the 'LDAP Settings' field option
+ TEST_LDAP_SEARCH_STRING = None
+ LDAP_USERNAME_FIELD = None
+ DOCUMENT_GROUP_MAPPINGS = []
+ LDAP_SCHEMA = None
+ LDAP_LDIF_JSON = None
+ TEST_VALUES_LDAP_COMPLEX_SEARCH_STRING = None
+
+ def mock_ldap_connection(f):
+
+ @functools.wraps(f)
+ def wrapped(self, *args, **kwargs):
+
+ with mock.patch('frappe.integrations.doctype.ldap_settings.ldap_settings.LDAPSettings.connect_to_ldap') as mock_connection:
+ mock_connection.return_value = self.connection
+
+ self.test_class = LDAPSettings(self.doc)
+
+ # Create a clean doc
+ localdoc = self.doc.copy()
+ frappe.get_doc(localdoc).save()
+
+ rv = f(self, *args, **kwargs)
+
+
+ # Clean-up
+ self.test_class = None
+
+ return rv
+
+ return wrapped
+
+ def clean_test_users():
+ try: # clean up test user 1
+ frappe.get_doc("User", 'posix.user1@unit.testing').delete()
+ except Exception:
+ pass
+
+ try: # clean up test user 2
+ frappe.get_doc("User", 'posix.user2@unit.testing').delete()
+ except Exception:
+ pass
+
+
+ @classmethod
+ def setUpClass(self, ldapServer='OpenLDAP'):
+
+ self.clean_test_users()
+ # Save user data for restoration in tearDownClass()
+ self.user_ldap_settings = frappe.get_doc('LDAP Settings')
+
+ # Create test user1
+ self.user1doc = {
+ 'username': 'posix.user',
+ 'email': 'posix.user1@unit.testing',
+ 'first_name': 'posix'
+ }
+ self.user1doc.update({
+ "doctype": "User",
+ "send_welcome_email": 0,
+ "language": "",
+ "user_type": "System User",
+ })
+
+ user = frappe.get_doc(self.user1doc)
+ user.insert(ignore_permissions=True)
+
+ # Create test user1
+ self.user2doc = {
+ 'username': 'posix.user2',
+ 'email': 'posix.user2@unit.testing',
+ 'first_name': 'posix'
+ }
+ self.user2doc.update({
+ "doctype": "User",
+ "send_welcome_email": 0,
+ "language": "",
+ "user_type": "System User",
+ })
+
+ user = frappe.get_doc(self.user2doc)
+ user.insert(ignore_permissions=True)
+
+
+ # Setup Mock OpenLDAP Directory
+ self.ldap_dc_path = 'dc=unit,dc=testing'
+ self.ldap_user_path = 'ou=users,' + self.ldap_dc_path
+ self.ldap_group_path = 'ou=groups,' + self.ldap_dc_path
+ self.base_dn = 'cn=base_dn_user,' + self.ldap_dc_path
+ self.base_password = 'my_password'
+ self.ldap_server = 'ldap://my_fake_server:389'
+
+
+ self.doc = {
+ "doctype": "LDAP Settings",
+ "enabled": True,
+ "ldap_directory_server": self.TEST_LDAP_SERVER,
+ "ldap_server_url": self.ldap_server,
+ "base_dn": self.base_dn,
+ "password": self.base_password,
+ "ldap_search_path_user": self.ldap_user_path,
+ "ldap_search_string": self.TEST_LDAP_SEARCH_STRING,
+ "ldap_search_path_group": self.ldap_group_path,
+ "ldap_user_creation_and_mapping_section": '',
+ "ldap_email_field": 'mail',
+ "ldap_username_field": self.LDAP_USERNAME_FIELD,
+ "ldap_first_name_field": 'givenname',
+ "ldap_middle_name_field": '',
+ "ldap_last_name_field": 'sn',
+ "ldap_phone_field": 'telephonenumber',
+ "ldap_mobile_field": 'mobile',
+ "ldap_security": '',
+ "ssl_tls_mode": '',
+ "require_trusted_certificate": 'No',
+ "local_private_key_file": '',
+ "local_server_certificate_file": '',
+ "local_ca_certs_file": '',
+ "ldap_group_objectclass": '',
+ "ldap_group_member_attribute": '',
+ "default_role": 'Newsletter Manager',
+ "ldap_groups": self.DOCUMENT_GROUP_MAPPINGS,
+ "ldap_group_field": ''}
+
+ self.server = Server(host=self.ldap_server, port=389, get_info=self.LDAP_SCHEMA)
+
+ self.connection = Connection(
+ self.server,
+ user=self.base_dn,
+ password=self.base_password,
+ read_only=True,
+ client_strategy=MOCK_SYNC)
+
+ self.connection.strategy.entries_from_json(os.path.abspath(os.path.dirname(__file__)) + '/' + self.LDAP_LDIF_JSON)
+
+ self.connection.bind()
+
+
+ @classmethod
+ def tearDownClass(self):
+ try:
+ frappe.get_doc('LDAP Settings').delete()
+
+ except Exception:
+ pass
+
+ try:
+ # return doc back to user data
+ self.user_ldap_settings.save()
+
+ except Exception:
+ pass
+
+ # Clean-up test users
+ self.clean_test_users()
+
+ # Clear OpenLDAP connection
+ self.connection = None
+
+
+ @mock_ldap_connection
+ def test_mandatory_fields(self):
+
+ mandatory_fields = [
+ 'ldap_server_url',
+ 'ldap_directory_server',
+ 'base_dn',
+ 'password',
+ 'ldap_search_path_user',
+ 'ldap_search_path_group',
+ 'ldap_search_string',
+ 'ldap_email_field',
+ 'ldap_username_field',
+ 'ldap_first_name_field',
+ 'require_trusted_certificate',
+ 'default_role'
+ ] # fields that are required to have ldap functioning need to be mandatory
+
+ for mandatory_field in mandatory_fields:
+
+ localdoc = self.doc.copy()
+ localdoc[mandatory_field] = ''
+
+ try:
+
+ frappe.get_doc(localdoc).save()
+
+ self.fail('Document LDAP Settings field [{0}] is not mandatory'.format(mandatory_field))
+
+ except frappe.exceptions.MandatoryError:
+ pass
+
+ except frappe.exceptions.ValidationError:
+ if mandatory_field == 'ldap_search_string':
+ # additional validation is done on this field, pass in this instance
+ pass
+
+
+ for non_mandatory_field in self.doc: # Ensure remaining fields have not been made mandatory
+
+ if non_mandatory_field == 'doctype' or non_mandatory_field in mandatory_fields:
+ continue
+
+ localdoc = self.doc.copy()
+ localdoc[non_mandatory_field] = ''
+
+ try:
+
+ frappe.get_doc(localdoc).save()
+
+ except frappe.exceptions.MandatoryError:
+ self.fail('Document LDAP Settings field [{0}] should not be mandatory'.format(non_mandatory_field))
+
+
+ @mock_ldap_connection
+ def test_validation_ldap_search_string(self):
+
+ invalid_ldap_search_strings = [
+ '',
+ 'uid={0}',
+ '(uid={0}',
+ 'uid={0})',
+ '(&(objectclass=posixgroup)(uid={0})',
+ '&(objectclass=posixgroup)(uid={0}))',
+ '(uid=no_placeholder)'
+ ] # ldap search string must be enclosed in '()' for ldap search to work for finding user and have the same number of opening and closing brackets.
+
+ for invalid_search_string in invalid_ldap_search_strings:
+
+ localdoc = self.doc.copy()
+ localdoc['ldap_search_string'] = invalid_search_string
+
+ try:
+ frappe.get_doc(localdoc).save()
+
+ self.fail("LDAP search string [{0}] should not validate".format(invalid_search_string))
+
+ except frappe.exceptions.ValidationError:
+ pass
+
+
+ def test_connect_to_ldap(self):
+
+ # setup a clean doc with ldap disabled so no validation occurs (this is tested seperatly)
+ local_doc = self.doc.copy()
+ local_doc['enabled'] = False
+ self.test_class = LDAPSettings(self.doc)
+
+ with mock.patch('ldap3.Server') as ldap3_server_method:
+
+ with mock.patch('ldap3.Connection') as ldap3_connection_method:
+ ldap3_connection_method.return_value = self.connection
+
+ with mock.patch('ldap3.Tls') as ldap3_Tls_method:
+
+ function_return = self.test_class.connect_to_ldap(base_dn=self.base_dn, password=self.base_password)
+
+ args, kwargs = ldap3_connection_method.call_args
+
+ prevent_connection_parameters = {
+ # prevent these parameters for security or lack of the und user from being able to configure
+ 'mode': {
+ 'IP_V4_ONLY': 'Locks the user to IPv4 without frappe providing a way to configure',
+ 'IP_V6_ONLY': 'Locks the user to IPv6 without frappe providing a way to configure'
+ },
+ 'auto_bind': {
+ 'NONE': 'ldap3.Connection must autobind with base_dn',
+ 'NO_TLS': 'ldap3.Connection must have TLS',
+ 'TLS_AFTER_BIND': '[Security] ldap3.Connection TLS bind must occur before bind'
+ }
+ }
+
+ for connection_arg in kwargs:
+
+ if connection_arg in prevent_connection_parameters and \
+ kwargs[connection_arg] in prevent_connection_parameters[connection_arg]:
+
+ self.fail('ldap3.Connection was called with {0}, failed reason: [{1}]'.format(
+ kwargs[connection_arg],
+ prevent_connection_parameters[connection_arg][kwargs[connection_arg]]))
+
+ if local_doc['require_trusted_certificate'] == 'Yes':
+ tls_validate = ssl.CERT_REQUIRED
+ tls_version = ssl.PROTOCOL_TLSv1
+ tls_configuration = ldap3.Tls(validate=tls_validate, version=tls_version)
+
+ self.assertTrue(kwargs['auto_bind'] == ldap3.AUTO_BIND_TLS_BEFORE_BIND,
+ 'Security: [ldap3.Connection] autobind TLS before bind with value ldap3.AUTO_BIND_TLS_BEFORE_BIND')
+
+ else:
+ tls_validate = ssl.CERT_NONE
+ tls_version = ssl.PROTOCOL_TLSv1
+ tls_configuration = ldap3.Tls(validate=tls_validate, version=tls_version)
+
+ self.assertTrue(kwargs['auto_bind'],
+ 'ldap3.Connection must autobind')
+
+
+ ldap3_Tls_method.assert_called_with(validate=tls_validate, version=tls_version)
+
+ ldap3_server_method.assert_called_with(host=self.doc['ldap_server_url'], tls=tls_configuration)
+
+ self.assertTrue(kwargs['password'] == self.base_password,
+ 'ldap3.Connection password does not match provided password')
+
+ self.assertTrue(kwargs['raise_exceptions'],
+ 'ldap3.Connection must raise exceptions for error handling')
+
+ self.assertTrue(kwargs['user'] == self.base_dn,
+ 'ldap3.Connection user does not match provided user')
+
+ ldap3_connection_method.assert_called_with(server=ldap3_server_method.return_value,
+ auto_bind=True,
+ password=self.base_password,
+ raise_exceptions=True,
+ read_only=True,
+ user=self.base_dn)
+
+ self.assertTrue(type(function_return) is ldap3.core.connection.Connection,
+ 'The return type must be of ldap3.Connection')
+
+ function_return = self.test_class.connect_to_ldap(base_dn=self.base_dn, password=self.base_password, read_only=False)
+
+ args, kwargs = ldap3_connection_method.call_args
+
+ self.assertFalse(kwargs['read_only'], 'connect_to_ldap() read_only parameter supplied as False but does not match the ldap3.Connection() read_only named parameter')
+
+
+
+
+ @mock_ldap_connection
+ def test_get_ldap_client_settings(self):
+
+ result = self.test_class.get_ldap_client_settings()
+
+ self.assertIsInstance(result, dict)
+
+ self.assertTrue(result['enabled'] == self.doc['enabled']) # settings should match doc
+
+ localdoc = self.doc.copy()
+ localdoc['enabled'] = False
+ frappe.get_doc(localdoc).save()
+
+ result = self.test_class.get_ldap_client_settings()
+
+ self.assertFalse(result['enabled']) # must match the edited doc
+
+
+ @mock_ldap_connection
+ def test_update_user_fields(self):
+
+ test_user_data = {
+ 'username': 'posix.user',
+ 'email': 'posix.user1@unit.testing',
+ 'first_name': 'posix',
+ 'middle_name': 'another',
+ 'last_name': 'user',
+ 'phone': '08 1234 5678',
+ 'mobile_no': '0421 123 456'
+ }
+
+ test_user = frappe.get_doc("User", test_user_data['email'])
+
+ self.test_class.update_user_fields(test_user, test_user_data)
+
+ updated_user = frappe.get_doc("User", test_user_data['email'])
+
+ self.assertTrue(updated_user.middle_name == test_user_data['middle_name'])
+ self.assertTrue(updated_user.last_name == test_user_data['last_name'])
+ self.assertTrue(updated_user.phone == test_user_data['phone'])
+ self.assertTrue(updated_user.mobile_no == test_user_data['mobile_no'])
+
+
+ @mock_ldap_connection
+ def test_sync_roles(self):
+
+ if self.TEST_LDAP_SERVER.lower() == 'openldap':
+ test_user_data = {
+ 'posix.user1': ['Users', 'Administrators', 'default_role', 'frappe_default_all','frappe_default_guest'],
+ 'posix.user2': ['Users', 'Group3', 'default_role', 'frappe_default_all', 'frappe_default_guest']
+ }
+
+ elif self.TEST_LDAP_SERVER.lower() == 'active directory':
+ test_user_data = {
+ 'posix.user1': ['Domain Users', 'Domain Administrators', 'default_role', 'frappe_default_all','frappe_default_guest'],
+ 'posix.user2': ['Domain Users', 'Enterprise Administrators', 'default_role', 'frappe_default_all', 'frappe_default_guest']
+ }
+
+
+ role_to_group_map = {
+ self.doc['ldap_groups'][0]['erpnext_role']: self.doc['ldap_groups'][0]['ldap_group'],
+ self.doc['ldap_groups'][1]['erpnext_role']: self.doc['ldap_groups'][1]['ldap_group'],
+ self.doc['ldap_groups'][2]['erpnext_role']: self.doc['ldap_groups'][2]['ldap_group'],
+ 'Newsletter Manager': 'default_role',
+ 'All': 'frappe_default_all',
+ 'Guest': 'frappe_default_guest',
+
+ }
+
+ # re-create user1 to ensure clean
+ frappe.get_doc("User", 'posix.user1@unit.testing').delete()
+ user = frappe.get_doc(self.user1doc)
+ user.insert(ignore_permissions=True)
+
+ for test_user in test_user_data:
+
+ test_user_doc = frappe.get_doc("User", test_user + '@unit.testing')
+ test_user_roles = frappe.get_roles(test_user + '@unit.testing')
+
+ self.assertTrue(len(test_user_roles) == 2,
+ 'User should only be a part of the All and Guest roles') # check default frappe roles
+
+ self.test_class.sync_roles(test_user_doc, test_user_data[test_user]) # update user roles
+
+ frappe.get_doc("User", test_user + '@unit.testing')
+ updated_user_roles = frappe.get_roles(test_user + '@unit.testing')
+
+ self.assertTrue(len(updated_user_roles) == len(test_user_data[test_user]),
+ 'syncing of the user roles failed. {0} != {1} for user {2}'.format(len(updated_user_roles), len(test_user_data[test_user]), test_user))
+
+ for user_role in updated_user_roles: # match each users role mapped to ldap groups
+
+ self.assertTrue(role_to_group_map[user_role] in test_user_data[test_user],
+ 'during sync_roles(), the user was given role {0} which should not have occured'.format(user_role))
+
+ @mock_ldap_connection
+ def test_create_or_update_user(self):
+
+ test_user_data = {
+ 'posix.user1': ['Users', 'Administrators', 'default_role', 'frappe_default_all','frappe_default_guest'],
+ }
+
+ test_user = 'posix.user1'
+
+ frappe.get_doc("User", test_user + '@unit.testing').delete() # remove user 1
+
+ with self.assertRaises(frappe.exceptions.DoesNotExistError): # ensure user deleted so function can be tested
+ frappe.get_doc("User", test_user + '@unit.testing')
+
+
+ with mock.patch('frappe.integrations.doctype.ldap_settings.ldap_settings.LDAPSettings.update_user_fields') \
+ as update_user_fields_method:
+
+ update_user_fields_method.return_value = None
+
+
+ with mock.patch('frappe.integrations.doctype.ldap_settings.ldap_settings.LDAPSettings.sync_roles') as sync_roles_method:
+
+ sync_roles_method.return_value = None
+
+ # New user
+ self.test_class.create_or_update_user(self.user1doc, test_user_data[test_user])
+
+ self.assertTrue(sync_roles_method.called, 'User roles need to be updated for a new user')
+ self.assertFalse(update_user_fields_method.called,
+ 'User roles are not required to be updated for a new user, this will occur during logon')
+
+
+ # Existing user
+ self.test_class.create_or_update_user(self.user1doc, test_user_data[test_user])
+
+ self.assertTrue(sync_roles_method.called, 'User roles need to be updated for an existing user')
+ self.assertTrue(update_user_fields_method.called, 'User fields need to be updated for an existing user')
+
+
+ @mock_ldap_connection
+ def test_get_ldap_attributes(self):
+
+ method_return = self.test_class.get_ldap_attributes()
+
+ self.assertTrue(type(method_return) is list)
+
+
+
+ @mock_ldap_connection
+ def test_fetch_ldap_groups(self):
+
+ if self.TEST_LDAP_SERVER.lower() == 'openldap':
+ test_users = {
+ 'posix.user': ['Users', 'Administrators'],
+ 'posix.user2': ['Users', 'Group3']
+
+ }
+ elif self.TEST_LDAP_SERVER.lower() == 'active directory':
+ test_users = {
+ 'posix.user': ['Domain Users', 'Domain Administrators'],
+ 'posix.user2': ['Domain Users', 'Enterprise Administrators']
+
+ }
+
+ for test_user in test_users:
+
+ self.connection.search(
+ search_base=self.ldap_user_path,
+ search_filter=self.TEST_LDAP_SEARCH_STRING.format(test_user),
+ attributes=self.test_class.get_ldap_attributes())
+
+ method_return = self.test_class.fetch_ldap_groups(self.connection.entries[0], self.connection)
+
+ self.assertIsInstance(method_return, list)
+ self.assertTrue(len(method_return) == len(test_users[test_user]))
+
+ for returned_group in method_return:
+
+ self.assertTrue(returned_group in test_users[test_user])
+
+
+
+ @mock_ldap_connection
+ def test_authenticate(self):
+
+ with mock.patch('frappe.integrations.doctype.ldap_settings.ldap_settings.LDAPSettings.fetch_ldap_groups') as \
+ fetch_ldap_groups_function:
+
+ fetch_ldap_groups_function.return_value = None
+
+ self.assertTrue(self.test_class.authenticate('posix.user', 'posix_user_password'))
+
+ self.assertTrue(fetch_ldap_groups_function.called,
+ 'As part of authentication function fetch_ldap_groups_function needs to be called')
+
+ invalid_users = [
+ {'prefix_posix.user': 'posix_user_password'},
+ {'posix.user_postfix': 'posix_user_password'},
+ {'posix.user': 'posix_user_password_postfix'},
+ {'posix.user': 'prefix_posix_user_password'},
+ {'posix.user': ''},
+ {'': 'posix_user_password'},
+ {'': ''}
+ ] # All invalid users should return 'invalid username or password'
+
+ for username, password in enumerate(invalid_users):
+
+ with self.assertRaises(frappe.exceptions.ValidationError) as display_massage:
+
+ self.test_class.authenticate(username, password)
+
+ self.assertTrue(str(display_massage.exception).lower() == 'invalid username or password',
+ 'invalid credentials passed authentication [user: {0}, password: {1}]'.format(username, password))
+
+
+ @mock_ldap_connection
+ def test_complex_ldap_search_filter(self):
+
+ ldap_search_filters = self.TEST_VALUES_LDAP_COMPLEX_SEARCH_STRING
+
+ for search_filter in ldap_search_filters:
+
+ self.test_class.ldap_search_string = search_filter
+
+ if 'ACCESS:test3' in search_filter: # posix.user does not have str in ldap.description auth should fail
+
+ with self.assertRaises(frappe.exceptions.ValidationError) as display_massage:
+
+ self.test_class.authenticate('posix.user', 'posix_user_password')
+
+ self.assertTrue(str(display_massage.exception).lower() == 'invalid username or password')
+
+ else:
+ self.assertTrue(self.test_class.authenticate('posix.user', 'posix_user_password'))
+
+
+ def test_reset_password(self):
+
+ self.test_class = LDAPSettings(self.doc)
+
+ # Create a clean doc
+ localdoc = self.doc.copy()
+
+ localdoc['enabled'] = False
+ frappe.get_doc(localdoc).save()
+
+ with mock.patch('frappe.integrations.doctype.ldap_settings.ldap_settings.LDAPSettings.connect_to_ldap') as connect_to_ldap:
+ connect_to_ldap.return_value = self.connection
+
+ with self.assertRaises(frappe.exceptions.ValidationError) as validation: # Fail if username string used
+ self.test_class.reset_password('posix.user', 'posix_user_password')
+
+ self.assertTrue(str(validation.exception) == 'No LDAP User found for email: posix.user')
+
+ try:
+ self.test_class.reset_password('posix.user1@unit.testing', 'posix_user_password') # Change Password
+
+ except Exception: # An exception from the tested class is ok, as long as the connection to LDAP was made writeable
+ pass
+
+ connect_to_ldap.assert_called_with(self.base_dn, self.base_password, read_only=False)
+
+
+ @mock_ldap_connection
+ def test_convert_ldap_entry_to_dict(self):
+
+ self.connection.search(
+ search_base=self.ldap_user_path,
+ search_filter=self.TEST_LDAP_SEARCH_STRING.format("posix.user"),
+ attributes=self.test_class.get_ldap_attributes())
+
+ test_ldap_entry = self.connection.entries[0]
+
+ method_return = self.test_class.convert_ldap_entry_to_dict(test_ldap_entry)
+
+ self.assertTrue(type(method_return) is dict) # must be dict
+ self.assertTrue(len(method_return) == 6) # there are 6 fields in mock_ldap for use
+
+
+
+class Test_OpenLDAP(LDAP_TestCase, unittest.TestCase):
+ TEST_LDAP_SERVER = 'OpenLDAP'
+ TEST_LDAP_SEARCH_STRING = '(uid={0})'
+ DOCUMENT_GROUP_MAPPINGS = [
+ {
+ "doctype": "LDAP Group Mapping",
+ "ldap_group": "Administrators",
+ "erpnext_role": "System Manager"
+ },
+ {
+ "doctype": "LDAP Group Mapping",
+ "ldap_group": "Users",
+ "erpnext_role": "Blogger"
+ },
+ {
+ "doctype": "LDAP Group Mapping",
+ "ldap_group": "Group3",
+ "erpnext_role": "Accounts User"
+ }
+ ]
+ LDAP_USERNAME_FIELD = 'uid'
+ LDAP_SCHEMA = OFFLINE_SLAPD_2_4
+ LDAP_LDIF_JSON = 'test_data_ldif_openldap.json'
+
+ TEST_VALUES_LDAP_COMPLEX_SEARCH_STRING = [
+ '(uid={0})',
+ '(&(objectclass=posixaccount)(uid={0}))',
+ '(&(description=*ACCESS:test1*)(uid={0}))', # OpenLDAP has no member of group, use description to filter posix.user has equivilent of AD 'memberOf'
+ '(&(objectclass=posixaccount)(description=*ACCESS:test3*)(uid={0}))' # OpenLDAP has no member of group, use description to filter posix.user doesn't have. equivilent of AD 'memberOf'
+ ]
+
+
+class Test_ActiveDirectory(LDAP_TestCase, unittest.TestCase):
+ TEST_LDAP_SERVER = 'Active Directory'
+ TEST_LDAP_SEARCH_STRING = '(samaccountname={0})'
+ DOCUMENT_GROUP_MAPPINGS = [
+ {
+ "doctype": "LDAP Group Mapping",
+ "ldap_group": "Domain Administrators",
+ "erpnext_role": "System Manager"
+ },
+ {
+ "doctype": "LDAP Group Mapping",
+ "ldap_group": "Domain Users",
+ "erpnext_role": "Blogger"
+ },
+ {
+ "doctype": "LDAP Group Mapping",
+ "ldap_group": "Enterprise Administrators",
+ "erpnext_role": "Accounts User"
+ }
+ ]
+ LDAP_USERNAME_FIELD = 'samaccountname'
+ LDAP_SCHEMA = OFFLINE_AD_2012_R2
+ LDAP_LDIF_JSON = 'test_data_ldif_activedirectory.json'
+
+ TEST_VALUES_LDAP_COMPLEX_SEARCH_STRING = [
+ '(samaccountname={0})',
+ '(&(objectclass=user)(samaccountname={0}))',
+ '(&(description=*ACCESS:test1*)(samaccountname={0}))', # OpenLDAP has no member of group, use description to filter posix.user has equivilent of AD 'memberOf'
+ '(&(objectclass=user)(description=*ACCESS:test3*)(samaccountname={0}))' # OpenLDAP has no member of group, use description to filter posix.user doesn't have. equivilent of AD 'memberOf'
+ ]
-class TestLDAPSettings(unittest.TestCase):
- pass
diff --git a/frappe/integrations/workspace/integrations/integrations.json b/frappe/integrations/workspace/integrations/integrations.json
index db96304207..4167858db2 100644
--- a/frappe/integrations/workspace/integrations/integrations.json
+++ b/frappe/integrations/workspace/integrations/integrations.json
@@ -1,22 +1,27 @@
{
- "category": "Administration",
+ "category": "",
"charts": [],
+ "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Backup\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Google Services\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Authentication\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Payments\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Settings\", \"col\": 4}}]",
"creation": "2020-03-02 15:16:18.714190",
"developer_mode_only": 0,
- "disable_user_customization": 1,
+ "disable_user_customization": 0,
"docstatus": 0,
"doctype": "Workspace",
+ "extends": "",
"extends_another_page": 0,
+ "for_user": "",
"hide_custom": 0,
"icon": "integration",
"idx": 0,
- "is_standard": 1,
+ "is_default": 0,
+ "is_standard": 0,
"label": "Integrations",
"links": [
{
"hidden": 0,
"is_query_report": 0,
"label": "Backup",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -25,6 +30,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Dropbox Settings",
+ "link_count": 0,
"link_to": "Dropbox Settings",
"link_type": "DocType",
"onboard": 0,
@@ -35,6 +41,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "S3 Backup Settings",
+ "link_count": 0,
"link_to": "S3 Backup Settings",
"link_type": "DocType",
"onboard": 0,
@@ -45,6 +52,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Google Drive",
+ "link_count": 0,
"link_to": "Google Drive",
"link_type": "DocType",
"onboard": 0,
@@ -54,6 +62,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Google Services",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -62,6 +71,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Google Settings",
+ "link_count": 0,
"link_to": "Google Settings",
"link_type": "DocType",
"onboard": 0,
@@ -72,6 +82,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Google Contacts",
+ "link_count": 0,
"link_to": "Google Contacts",
"link_type": "DocType",
"onboard": 0,
@@ -82,6 +93,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Google Calendar",
+ "link_count": 0,
"link_to": "Google Calendar",
"link_type": "DocType",
"onboard": 0,
@@ -92,6 +104,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Google Drive",
+ "link_count": 0,
"link_to": "Google Drive",
"link_type": "DocType",
"onboard": 0,
@@ -101,6 +114,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Authentication",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -109,6 +123,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Social Login Key",
+ "link_count": 0,
"link_to": "Social Login Key",
"link_type": "DocType",
"onboard": 0,
@@ -119,6 +134,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "LDAP Settings",
+ "link_count": 0,
"link_to": "LDAP Settings",
"link_type": "DocType",
"onboard": 0,
@@ -129,6 +145,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "OAuth Client",
+ "link_count": 0,
"link_to": "OAuth Client",
"link_type": "DocType",
"onboard": 0,
@@ -139,6 +156,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "OAuth Provider Settings",
+ "link_count": 0,
"link_to": "OAuth Provider Settings",
"link_type": "DocType",
"onboard": 0,
@@ -148,6 +166,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Payments",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -156,6 +175,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Braintree Settings",
+ "link_count": 0,
"link_to": "Braintree Settings",
"link_type": "DocType",
"onboard": 0,
@@ -166,6 +186,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "PayPal Settings",
+ "link_count": 0,
"link_to": "PayPal Settings",
"link_type": "DocType",
"onboard": 0,
@@ -176,6 +197,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Razorpay Settings",
+ "link_count": 0,
"link_to": "Razorpay Settings",
"link_type": "DocType",
"onboard": 0,
@@ -186,6 +208,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Stripe Settings",
+ "link_count": 0,
"link_to": "Stripe Settings",
"link_type": "DocType",
"onboard": 0,
@@ -196,6 +219,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Paytm Settings",
+ "link_count": 0,
"link_to": "Paytm Settings",
"link_type": "DocType",
"onboard": 0,
@@ -205,6 +229,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Settings",
+ "link_count": 0,
"onboard": 0,
"type": "Card Break"
},
@@ -213,6 +238,7 @@
"hidden": 0,
"is_query_report": 0,
"label": "Webhook",
+ "link_count": 0,
"link_to": "Webhook",
"link_type": "DocType",
"onboard": 0,
@@ -223,38 +249,37 @@
"hidden": 0,
"is_query_report": 0,
"label": "Slack Webhook URL",
+ "link_count": 0,
"link_to": "Slack Webhook URL",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Twilio Settings",
- "link_to": "Twilio Settings",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
{
"dependencies": "",
"hidden": 0,
"is_query_report": 0,
"label": "SMS Settings",
+ "link_count": 0,
"link_to": "SMS Settings",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
}
],
- "modified": "2020-12-01 13:38:39.706680",
+ "modified": "2021-08-05 12:16:00.355267",
"modified_by": "Administrator",
"module": "Integrations",
"name": "Integrations",
+ "onboarding": "",
"owner": "Administrator",
+ "parent_page": "",
"pin_to_bottom": 0,
"pin_to_top": 0,
- "shortcuts": []
+ "public": 1,
+ "restrict_to_domain": "",
+ "roles": [],
+ "sequence_id": 15,
+ "shortcuts": [],
+ "title": "Integrations"
}
\ No newline at end of file
diff --git a/frappe/model/__init__.py b/frappe/model/__init__.py
index 362f4c79b3..1acd7ee670 100644
--- a/frappe/model/__init__.py
+++ b/frappe/model/__init__.py
@@ -72,7 +72,8 @@ data_field_options = (
'Email',
'Name',
'Phone',
- 'URL'
+ 'URL',
+ 'Barcode'
)
default_fields = (
diff --git a/frappe/patches.txt b/frappe/patches.txt
index 989b13e049..87919b0247 100644
--- a/frappe/patches.txt
+++ b/frappe/patches.txt
@@ -182,3 +182,4 @@ frappe.patches.v13_0.jinja_hook
frappe.patches.v13_0.update_notification_channel_if_empty
frappe.patches.v14_0.drop_data_import_legacy
frappe.patches.v14_0.rename_cancelled_documents
+frappe.patches.v14_0.update_workspace2
diff --git a/frappe/patches/v12_0/set_correct_assign_value_in_docs.py b/frappe/patches/v12_0/set_correct_assign_value_in_docs.py
index 90766b5f64..5aaadd00e8 100644
--- a/frappe/patches/v12_0/set_correct_assign_value_in_docs.py
+++ b/frappe/patches/v12_0/set_correct_assign_value_in_docs.py
@@ -4,19 +4,17 @@ from frappe.query_builder.functions import GroupConcat, Coalesce
def execute():
frappe.reload_doc("desk", "doctype", "todo")
- ToDo = frappe.qb.Table("ToDo")
+ ToDo = frappe.qb.DocType("ToDo")
assignees = GroupConcat("owner").distinct().as_("assignees")
- query = (
+ assignments = (
frappe.qb.from_(ToDo)
.select(ToDo.name, ToDo.reference_type, assignees)
.where(Coalesce(ToDo.reference_type, "") != "")
.where(Coalesce(ToDo.reference_name, "") != "")
.where(ToDo.status != "Cancelled")
.groupby(ToDo.reference_type, ToDo.reference_name)
- )
-
- assignments = frappe.db.sql(query, as_dict=True)
+ ).run(as_dict=True)
for doc in assignments:
assignments = doc.assignees.split(",")
diff --git a/frappe/patches/v14_0/rename_cancelled_documents.py b/frappe/patches/v14_0/rename_cancelled_documents.py
index fbe49c2351..4b565d4f76 100644
--- a/frappe/patches/v14_0/rename_cancelled_documents.py
+++ b/frappe/patches/v14_0/rename_cancelled_documents.py
@@ -129,9 +129,9 @@ def update_linked_doctypes(doctype, cancelled_doc_names):
update
`tab{linked_dt}`
set
- {column}=CONCAT({column}, '-CANC')
+ `{column}`=CONCAT(`{column}`, '-CANC')
where
- {column} in %(cancelled_doc_names)s;
+ `{column}` in %(cancelled_doc_names)s;
""".format(linked_dt=linked_dt, column=field),
{'cancelled_doc_names': cancelled_doc_names})
else:
@@ -151,9 +151,9 @@ def update_dynamic_linked_doctypes(doctype, cancelled_doc_names):
update
`tab{linked_dt}`
set
- {column}=CONCAT({column}, '-CANC')
+ `{column}`=CONCAT(`{column}`, '-CANC')
where
- {column} in %(cancelled_doc_names)s and {doctype_fieldname}=%(dt)s;
+ `{column}` in %(cancelled_doc_names)s and {doctype_fieldname}=%(dt)s;
""".format(linked_dt=linked_dt, column=fieldname, doctype_fieldname=doctype_fieldname),
{'cancelled_doc_names': cancelled_doc_names, 'dt': doctype})
else:
diff --git a/frappe/patches/v14_0/update_workspace2.py b/frappe/patches/v14_0/update_workspace2.py
new file mode 100644
index 0000000000..2d7eb4cc76
--- /dev/null
+++ b/frappe/patches/v14_0/update_workspace2.py
@@ -0,0 +1,69 @@
+import frappe
+import json
+from frappe import _
+
+def execute():
+ frappe.reload_doc('desk', 'doctype', 'workspace', force=True)
+ order_by = "pin_to_top desc, pin_to_bottom asc, name asc"
+ for seq, wspace in enumerate(frappe.get_all('Workspace', order_by=order_by)):
+ doc = frappe.get_doc('Workspace', wspace.name)
+ content = create_content(doc)
+ update_wspace(doc, seq, content)
+ frappe.db.commit()
+
+def create_content(doc):
+ content = []
+ if doc.onboarding:
+ content.append({"type":"onboarding","data":{"onboarding_name":doc.onboarding,"col":12}})
+ if doc.charts:
+ invalid_links = []
+ for c in doc.charts:
+ if c.get_invalid_links()[0]:
+ invalid_links.append(c)
+ else:
+ content.append({"type":"chart","data":{"chart_name":c.label,"col":12}})
+ for l in invalid_links:
+ del doc.charts[doc.charts.index(l)]
+ if doc.shortcuts:
+ invalid_links = []
+ if doc.charts:
+ content.append({"type":"spacer","data":{"col":12}})
+ content.append({"type":"header","data":{"text":doc.shortcuts_label or _("Your Shortcuts"),"level":4,"col":12}})
+ for s in doc.shortcuts:
+ if s.get_invalid_links()[0]:
+ invalid_links.append(s)
+ else:
+ content.append({"type":"shortcut","data":{"shortcut_name":s.label,"col":4}})
+ for l in invalid_links:
+ del doc.shortcuts[doc.shortcuts.index(l)]
+ if doc.links:
+ invalid_links = []
+ content.append({"type":"spacer","data":{"col":12}})
+ content.append({"type":"header","data":{"text":doc.cards_label or _("Reports & Masters"),"level":4,"col":12}})
+ for l in doc.links:
+ if l.type == 'Card Break':
+ content.append({"type":"card","data":{"card_name":l.label,"col":4}})
+ if l.get_invalid_links()[0]:
+ invalid_links.append(l)
+ for l in invalid_links:
+ del doc.links[doc.links.index(l)]
+ return content
+
+def update_wspace(doc, seq, content):
+ if not doc.is_standard and not doc.public:
+ doc.sequence_id = seq + 1
+ doc.content = json.dumps(content)
+ doc.public = 0
+ doc.title = doc.extends
+ doc.extends = ''
+ doc.category = ''
+ doc.onboarding = ''
+ doc.extends_another_page = 0
+ doc.is_default = 0
+ doc.is_standard = 0
+ doc.developer_mode_only = 0
+ doc.disable_user_customization = 0
+ doc.pin_to_top = 0
+ doc.pin_to_bottom = 0
+ doc.hide_custom = 0
+ doc.save(ignore_permissions=True)
\ No newline at end of file
diff --git a/frappe/public/icons/timeless/symbol-defs.svg b/frappe/public/icons/timeless/symbol-defs.svg
index 6108daa938..f216374526 100644
--- a/frappe/public/icons/timeless/symbol-defs.svg
+++ b/frappe/public/icons/timeless/symbol-defs.svg
@@ -57,6 +57,9 @@
+
+
+
@@ -563,6 +566,10 @@
+
+
+
diff --git a/frappe/public/js/desk.bundle.js b/frappe/public/js/desk.bundle.js
index 66eb72cda0..294ac013fb 100644
--- a/frappe/public/js/desk.bundle.js
+++ b/frappe/public/js/desk.bundle.js
@@ -82,6 +82,7 @@ import "./frappe/ui/toolbar/toolbar.js";
import "./frappe/views/communication.js";
import "./frappe/views/translation_manager.js";
import "./frappe/views/workspace/workspace.js";
+import "./frappe/views/workspace/blocks/index.js";
import "./frappe/widgets/widget_group.js";
@@ -103,3 +104,4 @@ import "./frappe/ui/datatable.js";
import "./frappe/ui/driver.js";
import "./frappe/ui/plyr.js";
import "./frappe/barcode_scanner/index.js";
+import "./frappe/scanner";
diff --git a/frappe/public/js/frappe/desk.js b/frappe/public/js/frappe/desk.js
index 9d106f46f4..810b6a404a 100644
--- a/frappe/public/js/frappe/desk.js
+++ b/frappe/public/js/frappe/desk.js
@@ -283,7 +283,7 @@ frappe.Application = class Application {
frappe.workspaces = {};
for (let page of frappe.boot.allowed_workspaces || []) {
frappe.modules[page.module]=page;
- frappe.workspaces[frappe.router.slug(page.name)] = page;
+ frappe.workspaces[frappe.router.slug(page.title)] = page;
}
if (!frappe.workspaces['home']) {
// default workspace is settings for Frappe
diff --git a/frappe/public/js/frappe/form/controls/data.js b/frappe/public/js/frappe/form/controls/data.js
index 977789fc1b..e4a7dd6d59 100644
--- a/frappe/public/js/frappe/form/controls/data.js
+++ b/frappe/public/js/frappe/form/controls/data.js
@@ -67,6 +67,10 @@ frappe.ui.form.ControlData = class ControlData extends frappe.ui.form.ControlInp
if (this.df.options == 'URL') {
this.setup_url_field();
}
+
+ if (this.df.options == 'Barcode') {
+ this.setup_barcode_field();
+ }
}
setup_url_field() {
@@ -113,6 +117,43 @@ frappe.ui.form.ControlData = class ControlData extends frappe.ui.form.ControlInp
});
}
+ setup_barcode_field() {
+ this.$wrapper.find('.control-input').append(
+ `
+
+ ${frappe.utils.icon('scan', 'sm')}
+
+ `
+ );
+
+ this.$scan_btn = this.$wrapper.find('.link-btn');
+
+ this.$input.on("focus", () => {
+ setTimeout(() => {
+ this.$scan_btn.toggle(true);
+ }, 500);
+ });
+
+ const me = this;
+ this.$scan_btn.on('click', 'a', () => {
+ new frappe.ui.Scanner({
+ dialog: true,
+ multiple: false,
+ on_scan(data) {
+ if (data && data.result && data.result.text) {
+ me.set_value(data.result.text);
+ }
+ }
+ });
+ });
+
+ this.$input.on("blur", () => {
+ setTimeout(() => {
+ this.$scan_btn.toggle(false);
+ }, 500);
+ });
+ }
+
bind_change_event() {
const change_handler = e => {
if (this.change) this.change(e);
diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js
index 3bbc883b0c..fd49df027c 100644
--- a/frappe/public/js/frappe/form/form.js
+++ b/frappe/public/js/frappe/form/form.js
@@ -1145,7 +1145,7 @@ frappe.ui.form.Form = class FrappeForm {
// Add actions as menu item in Mobile View
let menu_item_label = group ? `${group} > ${label}` : label;
let menu_item = this.page.add_menu_item(menu_item_label, fn, false);
- menu_item.parent().addClass("hidden-lg");
+ menu_item.parent().addClass("hidden-xl");
this.custom_buttons[label] = btn;
}
diff --git a/frappe/public/js/frappe/form/grid.js b/frappe/public/js/frappe/form/grid.js
index cad32954e9..8de1600c05 100644
--- a/frappe/public/js/frappe/form/grid.js
+++ b/frappe/public/js/frappe/form/grid.js
@@ -37,6 +37,8 @@ export default class Grid {
}
this.is_grid = true;
+ this.debounced_refresh = this.refresh.bind(this);
+ this.debounced_refresh = frappe.utils.debounce(this.debounced_refresh, 500);
}
allow_on_grid_editing() {
@@ -500,7 +502,7 @@ export default class Grid {
this.set_editable_grid_column_disp(fieldname, show);
}
- this.refresh(true);
+ this.debounced_refresh();
}
set_editable_grid_column_disp(fieldname, show) {
@@ -544,17 +546,17 @@ export default class Grid {
toggle_reqd(fieldname, reqd) {
this.get_docfield(fieldname).reqd = reqd;
- this.refresh();
+ this.debounced_refresh();
}
toggle_enable(fieldname, enable) {
this.get_docfield(fieldname).read_only = enable ? 0 : 1;
- this.refresh();
+ this.debounced_refresh();
}
toggle_display(fieldname, show) {
this.get_docfield(fieldname).hidden = show ? 0 : 1;
- this.refresh();
+ this.debounced_refresh();
}
toggle_checkboxes(enable) {
@@ -675,6 +677,7 @@ export default class Grid {
if (!idx) {
idx = this.grid_rows.length - 1;
}
+
setTimeout(() => {
this.grid_rows[idx].row
.find('input[type="Text"],textarea,select').filter(':visible:first').focus();
@@ -934,6 +937,6 @@ export default class Grid {
// update the parent too (for new rows)
this.docfields.find(d => d.fieldname === fieldname)[property] = value;
- this.refresh();
+ this.debounced_refresh();
}
}
diff --git a/frappe/public/js/frappe/router.js b/frappe/public/js/frappe/router.js
index d0f93882fb..4360f3e887 100644
--- a/frappe/public/js/frappe/router.js
+++ b/frappe/public/js/frappe/router.js
@@ -118,6 +118,7 @@ frappe.router = {
convert_to_standard_route(route) {
// /app/settings = ["Workspaces", "Settings"]
+ // /app/private/settings = ["Workspaces", "private", "Settings"]
// /app/user = ["List", "User"]
// /app/user/view/report = ["List", "User", "Report"]
// /app/user/view/tree = ["Tree", "User"]
@@ -126,8 +127,11 @@ frappe.router = {
// /app/event/view/calendar/default = ["List", "Event", "Calendar", "Default"]
if (frappe.workspaces[route[0]]) {
- // workspace
- route = ['Workspaces', frappe.workspaces[route[0]].name];
+ // public workspace
+ route = ['Workspaces', frappe.workspaces[route[0]].title];
+ } else if (frappe.workspaces[route[1]]) {
+ // private workspace
+ route = ['Workspaces', 'private', frappe.workspaces[route[1]].title];
} else if (this.routes[route[0]]) {
// route
route = this.set_doctype_route(route);
@@ -136,6 +140,11 @@ frappe.router = {
return route;
},
+ doctype_route_exist(route) {
+ route = this.get_sub_path_string(route).split('/');
+ return this.routes[route[0]];
+ },
+
set_doctype_route(route) {
let doctype_route = this.routes[route[0]];
// doctype route
diff --git a/frappe/public/js/frappe/scanner/index.js b/frappe/public/js/frappe/scanner/index.js
new file mode 100644
index 0000000000..0b9acf7f9e
--- /dev/null
+++ b/frappe/public/js/frappe/scanner/index.js
@@ -0,0 +1,101 @@
+frappe.provide("frappe.ui");
+
+frappe.ui.Scanner = class Scanner {
+ constructor(options) {
+ this.dialog = null;
+ this.handler = null;
+ this.options = options;
+ this.is_alive = false;
+
+ if (!("multiple" in this.options)) {
+ this.options.multiple = false;
+ }
+ if (options.container) {
+ this.$scan_area = $(options.container);
+ this.scan_area_id = frappe.dom.set_unique_id(this.$scan_area);
+ }
+ if (options.dialog) {
+ this.dialog = this.make_dialog();
+ this.dialog.show();
+ }
+ }
+
+ scan() {
+ this.load_lib().then(() => this.start_scan());
+ }
+
+ start_scan() {
+ if (!this.handler) {
+ this.handler = new Html5Qrcode(this.scan_area_id); // eslint-disable-line
+ }
+ this.handler
+ .start(
+ { facingMode: "environment" },
+ { fps: 10, qrbox: 250 },
+ (decodedText, decodedResult) => {
+ if (this.options.on_scan) {
+ try {
+ this.options.on_scan(decodedResult);
+ } catch (error) {
+ console.error(error); // eslint-disable-line
+ }
+ }
+ if (!this.options.multiple) {
+ this.stop_scan();
+ this.hide_dialog();
+ }
+ },
+ errorMessage => { // eslint-disable-line
+ // parse error, ignore it.
+ }
+ )
+ .catch(err => {
+ this.is_alive = false;
+ this.hide_dialog();
+ console.error(err); // eslint-disable-line
+ });
+ this.is_alive = true;
+ }
+
+ stop_scan() {
+ if (this.handler && this.is_alive) {
+ this.handler.stop().then(() => {
+ this.is_alive = false;
+ this.$scan_area.empty();
+ this.hide_dialog();
+ });
+ }
+ }
+
+ make_dialog() {
+ let dialog = new frappe.ui.Dialog({
+ title: __("Scan QRCode"),
+ fields: [
+ {
+ fieldtype: "HTML",
+ fieldname: "scan_area"
+ }
+ ],
+ on_page_show: () => {
+ this.$scan_area = dialog.get_field("scan_area").$wrapper;
+ this.$scan_area.addClass("barcode-scanner");
+ this.scan_area_id = frappe.dom.set_unique_id(this.$scan_area);
+ this.scan();
+ },
+ on_hide: () => {
+ this.stop_scan();
+ }
+ });
+ return dialog;
+ }
+
+ hide_dialog() {
+ this.dialog && this.dialog.hide();
+ }
+
+ load_lib() {
+ return frappe.require(
+ "/assets/frappe/node_modules/html5-qrcode/dist/html5-qrcode.min.js"
+ );
+ }
+};
diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js
index 65635ec1dd..21841296dc 100644
--- a/frappe/public/js/frappe/utils/utils.js
+++ b/frappe/public/js/frappe/utils/utils.js
@@ -1325,6 +1325,19 @@ Object.assign(frappe.utils, {
return clipboard_data.getData('Text');
},
+ add_custom_button(html, action, class_name = "", title="", btn_type, wrapper, prepend) {
+ if (!btn_type) btn_type = 'btn-secondary';
+ let button = $(
+ ``
+ );
+ button.click(event => {
+ event.stopPropagation();
+ action && action(event);
+ });
+ !prepend && button.appendTo(wrapper);
+ prepend && wrapper.prepend(button);
+ },
+
sleep(time) {
return new Promise((resolve) => setTimeout(resolve, time));
}
diff --git a/frappe/public/js/frappe/views/workspace/blocks/block.js b/frappe/public/js/frappe/views/workspace/blocks/block.js
new file mode 100644
index 0000000000..aed3c2f727
--- /dev/null
+++ b/frappe/public/js/frappe/views/workspace/blocks/block.js
@@ -0,0 +1,115 @@
+import get_dialog_constructor from "../../../widgets/widget_dialog.js";
+
+export default class Block {
+ constructor(opts) {
+ Object.assign(this, opts);
+ }
+
+ make(block, block_name, widget_type = block) {
+ let block_data = this.config.page_data[block+'s'].items.find(obj => {
+ return obj.label == block_name;
+ });
+ if (!block_data) return false;
+ this.wrapper.innerHTML = '';
+ block_data.in_customize_mode = !this.readOnly;
+ this.block_widget = new frappe.widget.SingleWidgetGroup({
+ container: this.wrapper,
+ type: widget_type,
+ class_name: block == 'chart' ? 'widget-charts' : '',
+ options: this.options,
+ widgets: block_data,
+ api: this.api,
+ block: this.block
+ });
+ this.wrapper.setAttribute(block+'_name', block_name);
+ if (!this.readOnly) {
+ this.block_widget.customize();
+ }
+ return true;
+ }
+
+ rendered() {
+ var e = this.wrapper.closest('.ce-block');
+ e.classList.add("col-" + this.get_col());
+ }
+
+ new(block, widget_type = block) {
+ const dialog_class = get_dialog_constructor(widget_type);
+ let block_name = block+'_name';
+ this.dialog = new dialog_class({
+ label: this.label,
+ type: widget_type,
+ primary_action: (widget) => {
+ widget.in_customize_mode = 1;
+ this.block_widget = frappe.widget.make_widget({
+ ...widget,
+ widget_type: widget_type,
+ container: this.wrapper,
+ options: {
+ ...this.options,
+ on_delete: () => this.api.blocks.delete(),
+ on_edit: () => this.on_edit(this.block_widget)
+ }
+ });
+ this.block_widget.customize(this.options);
+ this.wrapper.setAttribute(block_name, this.block_widget.label);
+ this.new_block_widget = this.block_widget.get_config();
+ this.add_tune_button();
+ },
+ });
+
+ if (!this.readOnly && this.data && !this.data[block_name]) {
+ this.dialog.make();
+ }
+ }
+
+ on_edit(block_obj) {
+ let block_name = block_obj.edit_dialog.type+'_name';
+ if (block_obj.edit_dialog.type == 'links') {
+ block_name = 'card_name';
+ }
+ let block = block_obj.get_config();
+ this.block_widget.widgets = block;
+ this.wrapper.setAttribute(block_name, block.label);
+ this.new_block_widget = block_obj.get_config();
+ }
+
+ add_tune_button() {
+ let $widget_control = $(this.wrapper).find('.widget-control');
+ frappe.utils.add_custom_button(
+ frappe.utils.icon('dot-horizontal', 'xs'),
+ (event) => {
+ let evn = event;
+ !$('.ce-settings.ce-settings--opened').length &&
+ setTimeout(() => {
+ this.api.toolbar.toggleBlockSettings();
+ var position = $(evn.target).offset();
+ $('.ce-settings.ce-settings--opened').offset({
+ top: position.top + 25,
+ left: position.left - 77
+ });
+ }, 50);
+ },
+ "tune-btn",
+ `${__('Tune')}`,
+ null,
+ $widget_control,
+ true
+ );
+ }
+
+ get_col() {
+ let col = this.col || 12;
+ let class_name = "col-12";
+ let wrapper = this.wrapper.closest('.ce-block');
+ const col_class = new RegExp(/\bcol-.+?\b/, "g");
+ if (wrapper && wrapper.className.match(col_class)) {
+ wrapper.classList.forEach(function (cn) {
+ cn.match(col_class) && (class_name = cn);
+ });
+ let parts = class_name.split("-");
+ col = parseInt(parts[1]);
+ }
+ return col;
+ }
+}
\ No newline at end of file
diff --git a/frappe/public/js/frappe/views/workspace/blocks/card.js b/frappe/public/js/frappe/views/workspace/blocks/card.js
new file mode 100644
index 0000000000..15e27fed40
--- /dev/null
+++ b/frappe/public/js/frappe/views/workspace/blocks/card.js
@@ -0,0 +1,59 @@
+import Block from "./block.js";
+export default class Card extends Block {
+ static get toolbox() {
+ return {
+ title: 'Card',
+ icon: ''
+ };
+ }
+
+ static get isReadOnlySupported() {
+ return true;
+ }
+
+ constructor({ data, api, config, readOnly, block }) {
+ super({ data, api, config, readOnly, block });
+ this.sections = {};
+ this.col = this.data.col ? this.data.col : "4";
+ this.allow_customization = !this.readOnly;
+ this.options = {
+ allow_sorting: this.allow_customization,
+ allow_create: this.allow_customization,
+ allow_delete: this.allow_customization,
+ allow_hiding: false,
+ allow_edit: true,
+ };
+ }
+
+ render() {
+ this.wrapper = document.createElement('div');
+ this.new('card', 'links');
+
+ if (this.data && this.data.card_name) {
+ let has_data = this.make('card', this.data.card_name, 'links');
+ if (!has_data) return;
+ }
+
+ if (!this.readOnly) {
+ this.add_tune_button();
+ }
+
+ return this.wrapper;
+ }
+
+ validate(savedData) {
+ if (!savedData.card_name) {
+ return false;
+ }
+
+ return true;
+ }
+
+ save(blockContent) {
+ return {
+ card_name: blockContent.getAttribute('card_name'),
+ col: this.get_col(),
+ new: this.new_block_widget
+ };
+ }
+}
\ No newline at end of file
diff --git a/frappe/public/js/frappe/views/workspace/blocks/chart.js b/frappe/public/js/frappe/views/workspace/blocks/chart.js
new file mode 100644
index 0000000000..e41063e6fc
--- /dev/null
+++ b/frappe/public/js/frappe/views/workspace/blocks/chart.js
@@ -0,0 +1,59 @@
+import Block from "./block.js";
+export default class Chart extends Block {
+ static get toolbox() {
+ return {
+ title: 'Chart',
+ icon: ''
+ };
+ }
+
+ static get isReadOnlySupported() {
+ return true;
+ }
+
+ constructor({ data, api, config, readOnly, block }) {
+ super({ data, api, config, readOnly, block });
+ this.col = this.data.col ? this.data.col : "12";
+ this.allow_customization = !this.readOnly;
+ this.options = {
+ allow_sorting: this.allow_customization,
+ allow_create: this.allow_customization,
+ allow_delete: this.allow_customization,
+ allow_hiding: false,
+ allow_edit: true,
+ max_widget_count: 2,
+ };
+ }
+
+ render() {
+ this.wrapper = document.createElement('div');
+ this.new('chart');
+
+ if (this.data && this.data.chart_name) {
+ let has_data = this.make('chart', this.data.chart_name);
+ if (!has_data) return;
+ }
+
+ if (!this.readOnly) {
+ this.add_tune_button();
+ }
+
+ return this.wrapper;
+ }
+
+ validate(savedData) {
+ if (!savedData.chart_name) {
+ return false;
+ }
+
+ return true;
+ }
+
+ save(blockContent) {
+ return {
+ chart_name: blockContent.getAttribute('chart_name'),
+ col: this.get_col(),
+ new: this.new_block_widget
+ };
+ }
+}
\ No newline at end of file
diff --git a/frappe/public/js/frappe/views/workspace/blocks/header.js b/frappe/public/js/frappe/views/workspace/blocks/header.js
new file mode 100644
index 0000000000..356f9c3244
--- /dev/null
+++ b/frappe/public/js/frappe/views/workspace/blocks/header.js
@@ -0,0 +1,339 @@
+import Block from "./block.js";
+export default class Header extends Block {
+
+ constructor({ data, config, api, readOnly }) {
+ super({ config, api, readOnly });
+
+ this._CSS = {
+ block: this.api.styles.block,
+ settingsButton: this.api.styles.settingsButton,
+ settingsButtonActive: this.api.styles.settingsButtonActive,
+ wrapper: 'ce-header',
+ };
+
+ this._settings = this.config;
+ this._data = this.normalizeData(data);
+ this.settingsButtons = [];
+ this._element = this.getTag();
+
+ this.data = data;
+ this.col = this.data.col ? this.data.col : "12";
+ }
+
+ normalizeData(data) {
+ const newData = {};
+
+ if (typeof data !== 'object') {
+ data = {};
+ }
+
+ newData.text = data.text || '';
+ newData.level = parseInt(data.level) || this.defaultLevel.number;
+ newData.col = parseInt(data.col) || 12;
+
+ return newData;
+ }
+
+ render() {
+ this.wrapper = document.createElement('div');
+ this.wrapper.contentEditable = this.readOnly ? 'false' : 'true';
+ if (!this.readOnly) {
+ let $widget_head = $(``);
+ let $widget_control = $(``);
+
+ $widget_head[0].appendChild(this._element);
+ $widget_control.appendTo($widget_head);
+ $widget_head.appendTo(this.wrapper);
+
+ this.wrapper.classList.add('widget', 'header');
+
+ frappe.utils.add_custom_button(
+ frappe.utils.icon('dot-horizontal', 'xs'),
+ (event) => {
+ let evn = event;
+ !$('.ce-settings.ce-settings--opened').length &&
+ setTimeout(() => {
+ this.api.toolbar.toggleBlockSettings();
+ var position = $(evn.target).offset();
+ $('.ce-settings.ce-settings--opened').offset({
+ top: position.top + 25,
+ left: position.left - 77
+ });
+ }, 50);
+ },
+ "tune-btn",
+ `${__('Tune')}`,
+ null,
+ $widget_control
+ );
+
+ frappe.utils.add_custom_button(
+ frappe.utils.icon('drag', 'xs'),
+ null,
+ "drag-handle",
+ `${__('Drag')}`,
+ null,
+ $widget_control
+ );
+
+ frappe.utils.add_custom_button(
+ frappe.utils.icon('delete', 'xs'),
+ () => this.api.blocks.delete(),
+ "delete-header",
+ `${__('Delete')}`,
+ null,
+ $widget_control
+ );
+
+ return this.wrapper;
+ }
+ return this._element;
+ }
+
+ renderSettings() {
+ const holder = document.createElement('DIV');
+
+ if (this.levels.length <= 1) {
+ return holder;
+ }
+
+ this.levels.forEach(level => {
+ const selectTypeButton = document.createElement('SPAN');
+
+ selectTypeButton.classList.add(this._CSS.settingsButton);
+
+ if (this.currentLevel.number === level.number) {
+ selectTypeButton.classList.add(this._CSS.settingsButtonActive);
+ }
+
+ selectTypeButton.innerHTML = level.svg;
+
+ selectTypeButton.dataset.level = level.number;
+
+ selectTypeButton.addEventListener('click', () => {
+ this.setLevel(level.number);
+ });
+
+ holder.appendChild(selectTypeButton);
+
+ this.settingsButtons.push(selectTypeButton);
+ });
+
+ return holder;
+ }
+
+ setLevel(level) {
+ this.data = {
+ level: level,
+ text: this.data.text,
+ };
+
+ this.settingsButtons.forEach(button => {
+ button.classList.toggle(this._CSS.settingsButtonActive, parseInt(button.dataset.level) === level);
+ });
+ }
+
+ merge(data) {
+ const newData = {
+ text: this.data.text + data.text,
+ level: this.data.level,
+ };
+
+ this.data = newData;
+ }
+
+ validate(blockData) {
+ return blockData.text.trim() !== '';
+ }
+
+ save(toolsContent) {
+ this.wrapper = this._element;
+ return {
+ text: toolsContent.innerText,
+ level: this.currentLevel.number,
+ col: this.get_col()
+ };
+ }
+
+ rendered() {
+ var e = this._element.closest('.ce-block');
+ e.classList.add("col-" + this.get_col());
+ }
+
+ static get conversionConfig() {
+ return {
+ export: 'text', // use 'text' property for other blocks
+ import: 'text', // fill 'text' property from other block's export string
+ };
+ }
+
+ static get sanitize() {
+ return {
+ level: false,
+ text: {},
+ };
+ }
+
+ static get isReadOnlySupported() {
+ return true;
+ }
+
+ get data() {
+ this._data.text = this._element.innerHTML;
+ this._data.level = this.currentLevel.number;
+
+ return this._data;
+ }
+
+ set data(data) {
+ this._data = this.normalizeData(data);
+
+ if (data.level !== undefined && this._element.parentNode) {
+ const newHeader = this.getTag();
+ newHeader.innerHTML = this._element.innerHTML;
+ this._element.parentNode.replaceChild(newHeader, this._element);
+ this._element = newHeader;
+ }
+
+ if (data.text !== undefined) {
+ this._element.innerHTML = this._data.text || '';
+ }
+
+ if (!this.readOnly && this.wrapper) {
+ this.wrapper.classList.add('widget', 'header');
+ }
+ }
+
+ getTag() {
+ const tag = document.createElement(this.currentLevel.tag);
+
+ tag.innerHTML = this._data.text || '';
+
+ tag.classList.add(this._CSS.wrapper);
+
+ if (!this.readOnly) {
+ tag.contentEditable = true;
+ }
+
+ tag.dataset.placeholder = this.api.i18n.t(this._settings.placeholder || '');
+
+ return tag;
+ }
+
+ get currentLevel() {
+ let level = this.levels.find(levelItem => levelItem.number === this._data.level);
+
+ if (!level) {
+ level = this.defaultLevel;
+ }
+
+ return level;
+ }
+
+ get defaultLevel() {
+ if (this._settings.defaultLevel) {
+ const userSpecified = this.levels.find(levelItem => {
+ return levelItem.number === this._settings.defaultLevel;
+ });
+
+ if (userSpecified) {
+ return userSpecified;
+ } else {
+ // console.warn('(ง\'̀-\'́)ง Heading Tool: the default level specified was not found in available levels');
+ }
+ }
+
+ return this.levels[1];
+ }
+
+ get levels() {
+ const availableLevels = [
+ {
+ number: 1,
+ tag: 'H1',
+ svg: '',
+ },
+ {
+ number: 2,
+ tag: 'H2',
+ svg: '',
+ },
+ {
+ number: 3,
+ tag: 'H3',
+ svg: '',
+ },
+ {
+ number: 4,
+ tag: 'H4',
+ svg: '',
+ },
+ {
+ number: 5,
+ tag: 'H5',
+ svg: '',
+ },
+ {
+ number: 6,
+ tag: 'H6',
+ svg: '',
+ },
+ ];
+
+ return this._settings.levels ? availableLevels.filter(
+ l => this._settings.levels.includes(l.number)
+ ) : availableLevels;
+ }
+
+ onPaste(event) {
+ const content = event.detail.data;
+
+ let level = this.defaultLevel.number;
+
+ switch (content.tagName) {
+ case 'H1':
+ level = 1;
+ break;
+ case 'H2':
+ level = 2;
+ break;
+ case 'H3':
+ level = 3;
+ break;
+ case 'H4':
+ level = 4;
+ break;
+ case 'H5':
+ level = 5;
+ break;
+ case 'H6':
+ level = 6;
+ break;
+ }
+
+ if (this._settings.levels) {
+ // Fallback to nearest level when specified not available
+ level = this._settings.levels.reduce((prevLevel, currLevel) => {
+ return Math.abs(currLevel - level) < Math.abs(prevLevel - level) ? currLevel : prevLevel;
+ });
+ }
+
+ this.data = {
+ level,
+ text: content.innerHTML,
+ };
+ }
+
+ static get pasteConfig() {
+ return {
+ tags: ['H1', 'H2', 'H3', 'H4', 'H5', 'H6'],
+ };
+ }
+
+ static get toolbox() {
+ return {
+ icon: '',
+ title: 'Heading',
+ };
+ }
+}
\ No newline at end of file
diff --git a/frappe/public/js/frappe/views/workspace/blocks/index.js b/frappe/public/js/frappe/views/workspace/blocks/index.js
new file mode 100644
index 0000000000..00a9b8c83a
--- /dev/null
+++ b/frappe/public/js/frappe/views/workspace/blocks/index.js
@@ -0,0 +1,27 @@
+// import blocks
+import Header from "./header";
+import Paragraph from "./paragraph";
+import Card from "./card";
+import Chart from "./chart";
+import Shortcut from "./shortcut";
+import Spacer from "./spacer";
+import Onboarding from "./onboarding";
+
+// import tunes
+import SpacingTune from "./spacing_tune";
+
+frappe.provide("frappe.wspace_block");
+
+frappe.wspace_block.blocks = {
+ header: Header,
+ paragraph: Paragraph,
+ card: Card,
+ chart: Chart,
+ shortcut: Shortcut,
+ spacer: Spacer,
+ onboarding: Onboarding,
+};
+
+frappe.wspace_block.tunes = {
+ spacing_tune: SpacingTune
+};
\ No newline at end of file
diff --git a/frappe/public/js/frappe/views/workspace/blocks/onboarding.js b/frappe/public/js/frappe/views/workspace/blocks/onboarding.js
new file mode 100644
index 0000000000..7176b7726d
--- /dev/null
+++ b/frappe/public/js/frappe/views/workspace/blocks/onboarding.js
@@ -0,0 +1,129 @@
+import get_dialog_constructor from "../../../widgets/widget_dialog.js";
+import Block from "./block.js";
+export default class Onboarding extends Block {
+ static get toolbox() {
+ return {
+ title: 'Onboarding',
+ icon: ''
+ };
+ }
+
+ static get isReadOnlySupported() {
+ return true;
+ }
+
+ constructor({ data, api, config, readOnly, block }) {
+ super({ data, api, config, readOnly, block });
+ this.col = this.data.col ? this.data.col : "12";
+ this.allow_customization = !this.readOnly;
+ this.options = {
+ allow_sorting: this.allow_customization,
+ allow_create: this.allow_customization,
+ allow_delete: this.allow_customization,
+ allow_hiding: false,
+ allow_edit: true
+ };
+ }
+
+ rendered() {
+ var e = this.wrapper.closest('.ce-block');
+ if (this.readOnly && !$(this.wrapper).find('.onboarding-widget-box').is(':visible')) {
+ $(e).hide();
+ }
+ e.classList.add("col-" + this.get_col());
+ }
+
+ new(block, widget_type = block) {
+ const dialog_class = get_dialog_constructor(widget_type);
+ let block_name = block+'_name';
+ this.dialog = new dialog_class({
+ label: this.label,
+ type: widget_type,
+ primary_action: (widget) => {
+ widget.in_customize_mode = 1;
+ this.block_widget = frappe.widget.make_widget({
+ ...widget,
+ widget_type: widget_type,
+ container: this.wrapper,
+ options: {
+ ...this.options,
+ on_delete: () => this.api.blocks.delete(),
+ on_edit: () => this.on_edit(this.block_widget)
+ },
+ new: true
+ });
+ this.block_widget.customize(this.options);
+ this.wrapper.setAttribute(block_name, this.block_widget.label || this.block_widget.onboarding_name);
+ this.new_block_widget = this.block_widget.get_config();
+ this.add_tune_button();
+ },
+ });
+
+ if (!this.readOnly && this.data && !this.data[block_name]) {
+ this.dialog.make();
+ }
+ }
+
+ make(block, block_name) {
+ let block_data = this.config.page_data['onboardings'].items.find(obj => {
+ return obj.label == block_name;
+ });
+ if (!block_data) return false;
+ this.wrapper.innerHTML = '';
+ block_data.in_customize_mode = !this.readOnly;
+ this.block_widget = frappe.widget.make_widget({
+ container: this.wrapper,
+ widget_type: 'onboarding',
+ in_customize_mode: block_data.in_customize_mode,
+ options: {
+ ...this.options,
+ on_delete: () => this.api.blocks.delete(),
+ on_edit: () => this.on_edit(this.block_widget)
+ },
+ label: block_data.label,
+ title: block_data.title || __("Let's Get Started"),
+ subtitle: block_data.subtitle,
+ steps: block_data.items,
+ success: block_data.success,
+ docs_url: block_data.docs_url,
+ user_can_dismiss: block_data.user_can_dismiss,
+ });
+ this.wrapper.setAttribute(block+'_name', block_name);
+ if (!this.readOnly) {
+ this.block_widget.customize(this.options);
+ }
+ return true;
+ }
+
+ render() {
+ this.wrapper = document.createElement('div');
+ this.new('onboarding');
+
+ if (this.data && this.data.onboarding_name) {
+ let has_data = this.make('onboarding', this.data.onboarding_name);
+ if (!has_data) return;
+ }
+
+ if (!this.readOnly) {
+ this.add_tune_button();
+ }
+ $(this.wrapper).css("padding-bottom", "20px");
+ return this.wrapper;
+ }
+
+ validate(savedData) {
+ if (!savedData.onboarding_name) {
+ return false;
+ }
+
+ return true;
+ }
+
+ save(blockContent) {
+ return {
+ onboarding_name: blockContent.getAttribute('onboarding_name'),
+ col: this.get_col(),
+ new: this.new_block_widget
+ };
+ }
+}
\ No newline at end of file
diff --git a/frappe/public/js/frappe/views/workspace/blocks/paragraph.js b/frappe/public/js/frappe/views/workspace/blocks/paragraph.js
new file mode 100644
index 0000000000..26afa65d51
--- /dev/null
+++ b/frappe/public/js/frappe/views/workspace/blocks/paragraph.js
@@ -0,0 +1,195 @@
+import Block from "./block.js";
+export default class Paragraph extends Block {
+
+ static get DEFAULT_PLACEHOLDER() {
+ return '';
+ }
+
+ constructor({ data, config, api, readOnly }) {
+ super({ config, api, readOnly });
+
+ this._CSS = {
+ block: this.api.styles.block,
+ wrapper: 'ce-paragraph'
+ };
+
+ if (!this.readOnly) {
+ this.onKeyUp = this.onKeyUp.bind(this);
+ }
+
+ this._placeholder = this.config.placeholder ? this.config.placeholder : Paragraph.DEFAULT_PLACEHOLDER;
+ this._data = {};
+ this._element = this.drawView();
+ this._preserveBlank = this.config.preserveBlank !== undefined ? this.config.preserveBlank : false;
+
+ this.data = data;
+ this.col = this.data.col ? this.data.col : "12";
+ }
+
+ onKeyUp(e) {
+ if (e.code !== 'Backspace' && e.code !== 'Delete') {
+ return;
+ }
+
+ const {textContent} = this._element;
+
+ if (textContent === '') {
+ this._element.innerHTML = '';
+ }
+ }
+
+ drawView() {
+ let div = document.createElement('DIV');
+
+ div.classList.add(this._CSS.wrapper, this._CSS.block, 'widget');
+ div.contentEditable = false;
+ div.dataset.placeholder = this.api.i18n.t(this._placeholder);
+
+ if (!this.readOnly) {
+ div.contentEditable = true;
+ div.addEventListener('keyup', this.onKeyUp);
+ }
+ return div;
+ }
+
+ render() {
+ this.wrapper = document.createElement('div');
+ this.wrapper.contentEditable = this.readOnly ? 'false' : 'true';
+ if (!this.readOnly) {
+ let $para_control = $(``);
+
+ this.wrapper.appendChild(this._element);
+ this._element.classList.remove('widget');
+ $para_control.appendTo(this.wrapper);
+
+ this.wrapper.classList.add('widget');
+
+ frappe.utils.add_custom_button(
+ frappe.utils.icon('dot-horizontal', 'xs'),
+ (event) => {
+ let evn = event;
+ !$('.ce-settings.ce-settings--opened').length &&
+ setTimeout(() => {
+ this.api.toolbar.toggleBlockSettings();
+ var position = $(evn.target).offset();
+ $('.ce-settings.ce-settings--opened').offset({
+ top: position.top + 25,
+ left: position.left - 77
+ });
+ }, 50);
+ },
+ "tune-btn",
+ `${__('Tune')}`,
+ null,
+ $para_control
+ );
+
+ frappe.utils.add_custom_button(
+ frappe.utils.icon('drag', 'xs'),
+ null,
+ "drag-handle",
+ `${__('Drag')}`,
+ null,
+ $para_control
+ );
+
+ frappe.utils.add_custom_button(
+ frappe.utils.icon('delete', 'xs'),
+ () => this.api.blocks.delete(),
+ "delete-paragraph",
+ `${__('Delete')}`,
+ null,
+ $para_control
+ );
+
+ return this.wrapper;
+ }
+ return this._element;
+ }
+
+ merge(data) {
+ let newData = {
+ text: this.data.text + data.text
+ };
+
+ this.data = newData;
+ }
+
+ validate(savedData) {
+ if (savedData.text.trim() === '' && !this._preserveBlank) {
+ return false;
+ }
+
+ return true;
+ }
+
+ save() {
+ this.wrapper = this._element;
+ return {
+ text: this.wrapper.innerHTML,
+ col: this.get_col(),
+ };
+ }
+
+ rendered() {
+ var e = this._element.closest('.ce-block');
+ e.classList.add("col-" + this.get_col());
+ }
+
+ onPaste(event) {
+ const data = {
+ text: event.detail.data.innerHTML
+ };
+
+ this.data = data;
+ }
+
+ static get conversionConfig() {
+ return {
+ export: 'text', // to convert Paragraph to other block, use 'text' property of saved data
+ import: 'text' // to covert other block's exported string to Paragraph, fill 'text' property of tool data
+ };
+ }
+
+ static get sanitize() {
+ return {
+ text: {
+ br: true,
+ b: true,
+ i: true,
+ a: true
+ }
+ };
+ }
+
+ static get isReadOnlySupported() {
+ return true;
+ }
+
+ get data() {
+ let text = this._element.innerHTML;
+
+ this._data.text = text;
+
+ return this._data;
+ }
+
+ set data(data) {
+ this._data = data || {};
+
+ this._element.innerHTML = this._data.text || '';
+ }
+
+ static get pasteConfig() {
+ return {
+ tags: [ 'P' ]
+ };
+ }
+
+ static get toolbox() {
+ return {
+ icon: '',
+ title: 'Text'
+ };
+ }
+}
\ No newline at end of file
diff --git a/frappe/public/js/frappe/views/workspace/blocks/shortcut.js b/frappe/public/js/frappe/views/workspace/blocks/shortcut.js
new file mode 100644
index 0000000000..f7482a06f3
--- /dev/null
+++ b/frappe/public/js/frappe/views/workspace/blocks/shortcut.js
@@ -0,0 +1,57 @@
+import Block from "./block.js";
+export default class Shortcut extends Block {
+ static get toolbox() {
+ return {
+ title: 'Shortcut',
+ icon: ''
+ };
+ }
+
+ static get isReadOnlySupported() {
+ return true;
+ }
+
+ constructor({ data, api, config, readOnly, block }) {
+ super({ data, api, config, readOnly, block });
+ this.col = this.data.col ? this.data.col : "4";
+ this.allow_customization = !this.readOnly;
+ this.options = {
+ allow_sorting: this.allow_customization,
+ allow_create: this.allow_customization,
+ allow_delete: this.allow_customization,
+ allow_hiding: false,
+ allow_edit: true
+ };
+ }
+
+ render() {
+ this.wrapper = document.createElement('div');
+ this.new('shortcut');
+
+ if (this.data && this.data.shortcut_name) {
+ let has_data = this.make('shortcut', this.data.shortcut_name);
+ if (!has_data) return;
+ }
+
+ if (!this.readOnly) {
+ this.add_tune_button();
+ }
+ return this.wrapper;
+ }
+
+ validate(savedData) {
+ if (!savedData.shortcut_name) {
+ return false;
+ }
+
+ return true;
+ }
+
+ save(blockContent) {
+ return {
+ shortcut_name: blockContent.getAttribute('shortcut_name'),
+ col: this.get_col(),
+ new: this.new_block_widget
+ };
+ }
+}
\ No newline at end of file
diff --git a/frappe/public/js/frappe/views/workspace/blocks/spacer.js b/frappe/public/js/frappe/views/workspace/blocks/spacer.js
new file mode 100644
index 0000000000..3309cad4a4
--- /dev/null
+++ b/frappe/public/js/frappe/views/workspace/blocks/spacer.js
@@ -0,0 +1,82 @@
+import Block from './block.js';
+export default class Spacer extends Block {
+ static get toolbox() {
+ return {
+ title: 'Spacer',
+ icon: ''
+ };
+ }
+
+ static get isReadOnlySupported() {
+ return true;
+ }
+
+ constructor({ data, api, config, readOnly }) {
+ super({ data, api, config, readOnly });
+ this.col = this.data.col ? this.data.col : "12";
+ }
+
+ render() {
+ this.wrapper = document.createElement('div');
+ if (!this.readOnly) {
+ let $spacer = $(`
+
+ `);
+ $spacer.appendTo(this.wrapper);
+
+ this.wrapper.classList.add('widget', 'new-widget');
+ this.wrapper.style.minHeight = 50 + 'px';
+
+ let $widget_control = $spacer.find('.widget-control');
+
+ frappe.utils.add_custom_button(
+ frappe.utils.icon('dot-horizontal', 'xs'),
+ (event) => {
+ let evn = event;
+ !$('.ce-settings.ce-settings--opened').length &&
+ setTimeout(() => {
+ this.api.toolbar.toggleBlockSettings();
+ var position = $(evn.target).offset();
+ $('.ce-settings.ce-settings--opened').offset({
+ top: position.top + 25,
+ left: position.left - 77
+ });
+ }, 50);
+ },
+ "tune-btn",
+ `${__('Tune')}`,
+ null,
+ $widget_control
+ );
+
+ frappe.utils.add_custom_button(
+ frappe.utils.icon('drag', 'xs'),
+ null,
+ "drag-handle",
+ `${__('Drag')}`,
+ null,
+ $widget_control
+ );
+
+ frappe.utils.add_custom_button(
+ frappe.utils.icon('delete', 'xs'),
+ () => this.api.blocks.delete(),
+ "delete-spacer",
+ `${__('Delete')}`,
+ null,
+ $widget_control
+ );
+ }
+ return this.wrapper;
+ }
+
+ save() {
+ return {
+ col: this.get_col()
+ };
+ }
+}
\ No newline at end of file
diff --git a/frappe/public/js/frappe/views/workspace/blocks/spacing_tune.js b/frappe/public/js/frappe/views/workspace/blocks/spacing_tune.js
new file mode 100644
index 0000000000..365f7f590e
--- /dev/null
+++ b/frappe/public/js/frappe/views/workspace/blocks/spacing_tune.js
@@ -0,0 +1,123 @@
+export default class SpacingTune {
+ static get isTune() {
+ return true;
+ }
+
+ constructor({api, settings}) {
+ this.api = api;
+ this.settings = settings;
+ this.CSS = {
+ button: 'ce-settings__button',
+ wrapper: 'ce-tune-layout',
+ sidebar: 'cdx-settings-sidebar',
+ animation: 'wobble',
+ };
+ this.data = { colWidth: 12 };
+ this.wrapper = undefined;
+ this.sidebar = undefined;
+ }
+
+ render() {
+ let me = this;
+ let layoutWrapper = document.createElement('div');
+ layoutWrapper.classList.add(this.CSS.wrapper);
+ let decreaseWidthButton = document.createElement('div');
+ decreaseWidthButton.classList.add(this.CSS.button, 'ce-shrink-button');
+ let increaseWidthButton = document.createElement('div');
+ increaseWidthButton.classList.add(this.CSS.button, 'ce-expand-button');
+
+ layoutWrapper.appendChild(decreaseWidthButton);
+ layoutWrapper.appendChild(increaseWidthButton);
+
+ decreaseWidthButton.innerHTML = ``;
+ this.api.tooltip.onHover(decreaseWidthButton, 'Shrink', {
+ placement: 'top',
+ hidingDelay: 500,
+ });
+ this.api.listeners.on(
+ decreaseWidthButton,
+ 'click',
+ () => me.decreaseWidth(),
+ false
+ );
+
+ increaseWidthButton.innerHTML = ``;
+ this.api.tooltip.onHover(increaseWidthButton, 'Expand', {
+ placement: 'top',
+ hidingDelay: 500,
+ });
+ this.api.listeners.on(
+ increaseWidthButton,
+ 'click',
+ () => me.increaseWidth(),
+ false
+ );
+
+ this.wrapper = layoutWrapper;
+ return layoutWrapper;
+ }
+
+ decreaseWidth() {
+ const currentBlockIndex = this.api.blocks.getCurrentBlockIndex();
+
+ if (currentBlockIndex < 0) {
+ return;
+ }
+
+ let currentBlock = this.api.blocks.getBlockByIndex(currentBlockIndex);
+ if (!currentBlock) {
+ return;
+ }
+
+ let currentBlockElement = currentBlock.holder;
+
+ let className = 'col-12';
+ let colClass = new RegExp(/\bcol-.+?\b/, 'g');
+ if (currentBlockElement.className.match(colClass)) {
+ currentBlockElement.classList.forEach( cn => {
+ if (cn.match(colClass)) {
+ className = cn;
+ }
+ });
+ let parts = className.split('-');
+ let width = parseInt(parts[1]);
+ if (width >= 4) {
+ currentBlockElement.classList.remove('col-'+width);
+ width = width - 1;
+ currentBlockElement.classList.add('col-'+width);
+ }
+ }
+ }
+
+ increaseWidth() {
+ const currentBlockIndex = this.api.blocks.getCurrentBlockIndex();
+
+ if (currentBlockIndex < 0) {
+ return;
+ }
+
+ const currentBlock = this.api.blocks.getBlockByIndex(currentBlockIndex);
+ if (!currentBlock) {
+ return;
+ }
+
+ const currentBlockElement = currentBlock.holder;
+
+ let className = 'col-12';
+ const colClass = new RegExp(/\bcol-.+?\b/, 'g');
+ if (currentBlockElement.className.match(colClass)) {
+ currentBlockElement.classList.forEach( cn => {
+ if (cn.match(colClass)) {
+ className = cn;
+ }
+ });
+ let parts = className.split('-');
+ let width = parseInt(parts[1]);
+ if (width <= 11) {
+ currentBlockElement.classList.remove('col-'+width);
+ width = width + 1;
+ currentBlockElement.classList.add('col-'+width);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/frappe/public/js/frappe/views/workspace/workspace.js b/frappe/public/js/frappe/views/workspace/workspace.js
index f16228dca0..719645feea 100644
--- a/frappe/public/js/frappe/views/workspace/workspace.js
+++ b/frappe/public/js/frappe/views/workspace/workspace.js
@@ -1,3 +1,6 @@
+import EditorJS from '@editorjs/editorjs';
+import Undo from 'editorjs-undo';
+
frappe.standard_pages['Workspaces'] = function() {
var wrapper = frappe.container.add_page('Workspaces');
@@ -17,37 +20,60 @@ frappe.views.Workspace = class Workspace {
constructor(wrapper) {
this.wrapper = $(wrapper);
this.page = wrapper.page;
- this.prepare_container();
- this.show_or_hide_sidebar();
- this.setup_dropdown();
- this.pages = {};
- this.sidebar_items = {};
+ this.blocks = frappe.wspace_block.blocks;
+ this.is_read_only = true;
+ this.new_page = null;
+ this.sorted_public_items = [];
+ this.sorted_private_items = [];
+ this.deleted_sidebar_items = [];
+ this.current_page = {};
+ this.sidebar_items = {
+ 'public': {},
+ 'private': {}
+ };
this.sidebar_categories = [
- "Modules",
- "Domains",
- "Places",
- "Administration"
+ 'My Workspaces',
+ 'Public'
];
+ this.tools = {
+ header: {
+ class: this.blocks['header'],
+ inlineToolbar: true
+ },
+ paragraph: {
+ class: this.blocks['paragraph'],
+ inlineToolbar: true
+ },
+ chart: {
+ class: this.blocks['chart'],
+ config: {
+ page_data: this.page_data || []
+ }
+ },
+ card: {
+ class: this.blocks['card'],
+ config: {
+ page_data: this.page_data || []
+ }
+ },
+ shortcut: {
+ class: this.blocks['shortcut'],
+ config: {
+ page_data: this.page_data || []
+ }
+ },
+ onboarding: {
+ class: this.blocks['onboarding'],
+ config: {
+ page_data: this.page_data || []
+ }
+ },
+ spacer: this.blocks['spacer'],
+ spacingTune: frappe.wspace_block.tunes['spacing_tune'],
+ };
- this.setup_workspaces();
- this.make_sidebar();
- }
-
- setup_workspaces() {
- // workspaces grouped by categories
- this.workspaces = {};
- for (let page of frappe.boot.allowed_workspaces) {
- if (!this.workspaces[page.category]) {
- this.workspaces[page.category] = [];
- }
- this.workspaces[page.category].push(page);
- }
- }
-
- show() {
- let page = this.get_page_to_show();
- this.page.set_title(`${__(page)}`);
- this.show_page(page);
+ this.prepare_container();
+ this.setup_pages();
}
prepare_container() {
@@ -57,229 +83,183 @@ frappe.views.Workspace = class Workspace {
`).appendTo(this.wrapper.find(".layout-side-section"));
this.sidebar = list_sidebar.find(".desk-sidebar");
-
this.body = this.wrapper.find(".layout-main-section");
}
- get_page_to_show() {
- let default_page;
+ setup_pages() {
+ this.get_pages().then(pages => {
+ this.all_pages = pages.pages;
+ this.has_access = pages.has_access;
- if (localStorage.current_workspace) {
- default_page = localStorage.current_workspace;
- } else if (this.workspaces) {
- default_page = this.workspaces["Modules"][0].name;
- } else if (frappe.boot.allowed_workspaces) {
- default_page = frappe.boot.allowed_workspaces[0].name;
- } else {
- default_page = "Build";
- }
+ this.all_pages.forEach(page => {
+ page.is_editable = !page.public || pages.has_access;
+ });
- let page = frappe.get_route()[1] || default_page;
- return page;
+ this.public_pages = this.all_pages.filter(page => page.public);
+ this.private_pages = this.all_pages.filter(page => !page.public);
+
+ if (this.all_pages) {
+ frappe.workspaces = {};
+ for (let page of this.all_pages) {
+ frappe.workspaces[frappe.router.slug(page.title)] = {title: page.title};
+ }
+ if (this.new_page && this.new_page.name) {
+ if (!frappe.workspaces[frappe.router.slug(this.new_page.name)]) {
+ this.new_page = { name: this.all_pages[0].title, public: this.all_pages[0].public };
+ }
+ if (this.new_page.public) {
+ frappe.set_route(`${frappe.router.slug(this.new_page.name)}`);
+ } else {
+ frappe.set_route(`private/${frappe.router.slug(this.new_page.name)}`);
+ }
+ this.new_page = null;
+ }
+ this.make_sidebar();
+ frappe.router.route();
+ }
+ });
+ }
+
+ get_pages() {
+ return frappe.xcall("frappe.desk.desktop.get_wspace_sidebar_items");
+ }
+
+ sidebar_item_container(item) {
+ return $(`
+
+ `);
}
make_sidebar() {
+ if (this.sidebar.find('.standard-sidebar-section')[0]) {
+ this.sidebar.find('.standard-sidebar-section').remove();
+ }
+
this.sidebar_categories.forEach(category => {
- if (this.workspaces[category]) {
- this.build_sidebar_section(category, this.workspaces[category]);
+ let root_pages = this.public_pages.filter(page => page.parent_page == '' || page.parent_page == null);
+ if (category != 'Public') {
+ root_pages = this.private_pages.filter(page => page.parent_page == '' || page.parent_page == null);
}
- });
- }
-
- build_sidebar_section(title, items) {
- let sidebar_section = $(``);
-
- // DO NOT REMOVE: Comment to load translation
- // __("Modules") __("Domains") __("Places") __("Administration")
- $(`${__(title)}
`)
- .appendTo(sidebar_section);
-
- const get_sidebar_item = function (item) {
- return $(`
-
- ${frappe.utils.icon(item.icon || "folder-normal", "md")}
-
- `);
- };
-
- const make_sidebar_category_item = item => {
- if (item.name == this.get_page_to_show()) {
- item.selected = true;
- this.current_page_name = item.name;
- }
-
- let $item = get_sidebar_item(item);
-
- $item.appendTo(sidebar_section);
- this.sidebar_items[item.name] = $item;
- };
-
- items.forEach(item => make_sidebar_category_item(item));
-
- sidebar_section.appendTo(this.sidebar);
- }
-
- show_page(page) {
- if (this.current_page_name && this.pages[this.current_page_name]) {
- this.pages[this.current_page_name].hide();
- }
-
- if (this.sidebar_items && this.sidebar_items[this.current_page_name]) {
- this.sidebar_items[this.current_page_name].removeClass("selected");
- this.sidebar_items[page].addClass("selected");
- }
- this.current_page_name = page;
- localStorage.current_workspace = page;
-
- this.pages[page] ? this.pages[page].show() : this.make_page(page);
- this.current_page = this.pages[page];
- this.setup_dropdown();
- }
-
- make_page(page) {
- const $page = new DesktopPage({
- container: this.body,
- page_name: page
+ this.build_sidebar_section(category, root_pages);
});
- this.pages[page] = $page;
- return $page;
+ // Scroll sidebar to selected page if it is not in viewport.
+ !frappe.dom.is_element_in_viewport(this.sidebar.find('.selected'))
+ && this.sidebar.find('.selected')[0].scrollIntoView();
}
- customize() {
- if (this.current_page && this.current_page.allow_customization) {
- this.page.clear_menu();
- this.current_page.customize();
+ build_sidebar_section(title, root_pages) {
+ let sidebar_section = $(``);
- this.page.set_primary_action(
- __("Save Customizations"),
- () => {
- this.current_page.save_customization();
- this.page.clear_primary_action();
- this.page.clear_secondary_action();
- this.setup_dropdown();
- },
- null,
- __("Saving")
- );
+ let $title = $(`
+ ${frappe.utils.icon("small-down", "xs")}
+ ${__(title)}
+
`).appendTo(sidebar_section);
+ this.prepare_sidebar(root_pages, sidebar_section, this.sidebar);
- this.page.set_secondary_action(
- __("Discard"),
- () => {
- this.current_page.reload();
- frappe.show_alert({ message: __("Customizations Discarded"), indicator: "info" });
- this.page.clear_primary_action();
- this.page.clear_secondary_action();
- this.setup_dropdown();
- }
- );
+ $title.on('click', (e) => {
+ let icon = $(e.target).find("span use").attr("href")==="#icon-small-down" ? "#icon-right" : "#icon-small-down";
+ $(e.target).find("span use").attr("href", icon);
+ $(e.target).parent().find('.sidebar-item-container').toggleClass('hidden');
+ });
+
+ if (!this.current_page.name) {
+ $title.trigger("click");
+ }
+
+ if (Object.keys(root_pages).length === 0) {
+ sidebar_section.addClass('hidden');
}
}
- setup_dropdown() {
- this.page.clear_menu();
-
- this.page.set_secondary_action(__('Customize'), () => {
- this.customize();
- });
-
- this.page.add_menu_item(__('Reset Customizations'), () => {
- this.current_page.reset_customization();
- }, 1);
-
- this.page.add_menu_item(__('Toggle Sidebar'), () => {
- this.toggle_side_bar();
- }, 1);
+ prepare_sidebar(items, child_container, item_container) {
+ items.forEach(item => this.append_item(item, child_container));
+ child_container.appendTo(item_container);
}
- toggle_side_bar() {
- let show_workspace_sidebar = JSON.parse(localStorage.show_workspace_sidebar || "true");
- show_workspace_sidebar = !show_workspace_sidebar;
- localStorage.show_workspace_sidebar = show_workspace_sidebar;
- this.show_or_hide_sidebar();
- $(document.body).trigger("toggleDeskSidebar");
+ append_item(item, container) {
+ let is_current_page = frappe.router.slug(item.title) == frappe.router.slug(this.get_page_to_show().name)
+ && item.public == this.get_page_to_show().public;
+ if (is_current_page) {
+ item.selected = true;
+ this.current_page = { name: item.title, public: item.public };
+ }
+
+ let $item_container = this.sidebar_item_container(item);
+ let sidebar_control = $item_container.find('.sidebar-item-control');
+
+ this.add_sidebar_actions(item, sidebar_control);
+ let pages = item.public ? this.public_pages : this.private_pages;
+
+ let child_items = pages.filter(page => page.parent_page == item.title);
+ if (child_items.length > 0) {
+ let child_container = $(``);
+ this.prepare_sidebar(child_items, child_container, $item_container);
+ }
+
+ $item_container.appendTo(container);
+ this.sidebar_items[item.public ? 'public' : 'private'][item.title] = $item_container;
+
+ if ($item_container.parent().hasClass('hidden') && is_current_page) {
+ $item_container.parent().toggleClass('hidden');
+ }
+
+ this.add_drop_icon(item, sidebar_control, $item_container);
}
- show_or_hide_sidebar() {
- let show_workspace_sidebar = JSON.parse(localStorage.show_workspace_sidebar || "true");
- $('#page-workspace .layout-side-section').toggleClass('hidden', !show_workspace_sidebar);
- }
-};
-
-class DesktopPage {
- constructor({ container, page_name }) {
- frappe.desk_page = this;
- this.container = container;
- this.page_name = page_name;
- this.sections = {};
- this.allow_customization = false;
- this.reload();
- }
-
- show() {
- frappe.desk_page = this;
- this.page.show();
- if (this.sections.shortcuts) {
- this.sections.shortcuts.widgets_list.forEach(wid => {
- wid.set_actions();
+ add_drop_icon(item, sidebar_control, item_container) {
+ let $child_item_section = item_container.find('.sidebar-child-item');
+ let $drop_icon = $(`${frappe.utils.icon("small-down", "sm")}`)
+ .appendTo(sidebar_control);
+ let pages = item.public ? this.public_pages : this.private_pages;
+ if (pages.some(e => e.parent_page == item.title)) {
+ $drop_icon.removeClass('hidden');
+ $drop_icon.on('click', () => {
+ let icon = $drop_icon.find("use").attr("href")==="#icon-small-down" ? "#icon-small-up" : "#icon-small-down";
+ $drop_icon.find("use").attr("href", icon);
+ $child_item_section.toggleClass("hidden");
});
}
}
- hide() {
- this.page.hide();
- }
-
- reload() {
- this.in_customize_mode = false;
- this.page && this.page.remove();
- this.make();
- }
-
- make() {
- this.page = $(``);
- this.page.append(frappe.render_template('workspace_loading_skeleton'));
- this.page.appendTo(this.container);
-
- this.get_data().then(() => {
- if (Object.keys(this.data).length == 0) {
- delete localStorage.current_workspace;
- frappe.set_route("workspace");
- return;
- }
- this.refresh();
- }).finally(() => this.page.find('.workspace_loading_skeleton').remove());
- }
-
- refresh() {
- this.page.empty();
- this.allow_customization = this.data.allow_customization || false;
-
- if (frappe.is_mobile()) {
- this.allow_customization = false;
+ show() {
+ if (!this.all_pages) {
+ // pages not yet loaded, call again after a bit
+ setTimeout(() => this.show(), 100);
+ return;
}
- this.data.onboarding && this.data.onboarding.items.length && this.make_onboarding();
- this.make_charts();
- this.make_shortcuts();
- this.make_cards();
+ let page = {
+ name: this.get_page_to_show().name,
+ public: this.get_page_to_show().public
+ };
+ this.page.set_title(`${__(page.name)}`);
+
+ this.show_page(page);
}
- get_data() {
+ get_data(page) {
return frappe.xcall("frappe.desk.desktop.get_desktop_page", {
- page: this.page_name
+ page: page
}).then(data => {
- this.data = data;
- if (Object.keys(this.data).length == 0) return;
+ this.page_data = data;
+ if (!this.page_data || Object.keys(this.page_data).length === 0) return;
return frappe.dashboard_utils.get_dashboard_settings().then(settings => {
let chart_config = settings.chart_config ? JSON.parse(settings.chart_config) : {};
- if (this.data.charts.items) {
- this.data.charts.items.map(chart => {
+ if (this.page_data.charts && this.page_data.charts.items) {
+ this.page_data.charts.items.map(chart => {
chart.chart_settings = chart_config[chart.chart_name] || {};
});
}
@@ -287,128 +267,493 @@ class DesktopPage {
});
}
- customize() {
- if (this.in_customize_mode) {
+ get_page_to_show() {
+ let default_page;
+
+ if (localStorage.current_page && this.all_pages.filter(page => page.title == localStorage.current_page).length != 0) {
+ default_page = { name: localStorage.current_page, public: localStorage.is_current_page_public == 'true' };
+ } else if (Object.keys(this.all_pages).length !== 0) {
+ default_page = { name: this.all_pages[0].title, public: true };
+ } else {
+ default_page = { name: "Build", public: true };
+ }
+
+ let page = (frappe.get_route()[1] == 'private' ? frappe.get_route()[2] : frappe.get_route()[1]) || default_page.name;
+ let is_public = frappe.get_route()[1] ? frappe.get_route()[1] != 'private' : default_page.public;
+ return { name: page, public: is_public };
+ }
+
+ show_page(page) {
+ let section = this.current_page.public ? 'public' : 'private';
+ if (this.sidebar_items && this.sidebar_items[section] && this.sidebar_items[section][this.current_page.name]) {
+ this.sidebar_items[section][this.current_page.name][0].firstElementChild.classList.remove("selected");
+ this.sidebar_items[page.public ? 'public':'private'][page.name][0].firstElementChild.classList.add("selected");
+
+ if (this.sidebar_items[page.public ? 'public':'private'][page.name].parents('.sidebar-item-container')[0]) {
+ this.sidebar_items[page.public ? 'public':'private'][page.name]
+ .parents('.sidebar-item-container')
+ .find('.drop-icon use')
+ .attr("href", "#icon-small-up");
+ }
+ }
+
+ this.current_page = { name: page.name, public: page.public };
+ localStorage.current_page = page.name;
+ localStorage.is_current_page_public = page.public;
+
+ if (!this.body.find('#editorjs')[0]) {
+ this.$page = $(`
+
+ `).appendTo(this.body);
+ }
+ this.$page.prepend(frappe.render_template('workspace_loading_skeleton'));
+ this.$page.find('.codex-editor').addClass('hidden');
+
+ if (this.all_pages) {
+ let pages = page.public ? this.public_pages : this.private_pages;
+ let this_page = pages.filter(p => p.title == page.name)[0];
+ this.setup_actions(page);
+ this.content = this_page && JSON.parse(this_page.content);
+
+ this.add_custom_cards_in_content();
+
+ $('.item-anchor').addClass('disable-click');
+ this.get_data(this_page).then(() => {
+ this.prepare_editorjs();
+ $('.item-anchor').removeClass('disable-click');
+ this.$page.find('.codex-editor').removeClass('hidden');
+ this.$page.find('.workspace-skeleton').remove();
+ });
+ }
+ }
+
+ add_custom_cards_in_content() {
+ let index = -1;
+ this.content.find((item, i) => {
+ if (item.type == 'card') index = i;
+ });
+ if (index !== -1) {
+ this.content.splice(index+1, 0, {"type": "card", "data": {"card_name": "Custom Documents", "col": 4}});
+ this.content.splice(index+2, 0, {"type": "card", "data": {"card_name": "Custom Reports", "col": 4}});
+ }
+ }
+
+ prepare_editorjs() {
+ if (this.editor) {
+ this.editor.isReady.then(() => {
+ this.editor.configuration.tools.chart.config.page_data = this.page_data;
+ this.editor.configuration.tools.shortcut.config.page_data = this.page_data;
+ this.editor.configuration.tools.card.config.page_data = this.page_data;
+ this.editor.configuration.tools.onboarding.config.page_data = this.page_data;
+ this.editor.render({ blocks: this.content || [] });
+ });
+ } else {
+ this.initialize_editorjs(this.content);
+ }
+ }
+
+ setup_actions(page) {
+ let pages = page.public ? this.public_pages : this.private_pages;
+ let current_page = pages.filter(p => p.title == page.name)[0];
+
+ if (!this.is_read_only) {
+ this.setup_customization_buttons(current_page);
return;
}
- // We need to remove this as the chart group will be visible during customization
- $('.widget.onboarding-widget-box').hide();
+ this.page.clear_primary_action();
+ this.page.clear_secondary_action();
+ this.page.clear_inner_toolbar();
- Object.keys(this.sections).forEach(section => {
- this.sections[section].customize();
+ current_page.is_editable && this.page.set_secondary_action(__("Edit"), () => {
+ if (!this.editor || !this.editor.readOnly) return;
+ this.is_read_only = false;
+ this.editor.readOnly.toggle();
+ this.editor.isReady.then(() => {
+ this.initialize_editorjs_undo();
+ this.setup_customization_buttons(current_page);
+ this.show_sidebar_actions();
+ this.make_sidebar_sortable();
+ this.make_blocks_sortable();
+ });
});
- this.in_customize_mode = true;
+ this.page.add_inner_button(__("Create Workspace"), () => {
+ this.initialize_new_page();
+ });
}
- save_customization() {
+ initialize_editorjs_undo() {
+ this.undo = new Undo({ editor: this.editor });
+ this.undo.initialize({ blocks: this.content || [] });
+ this.undo.readOnly = false;
+ }
+
+ setup_customization_buttons(page) {
+ let me = this;
+ this.page.clear_primary_action();
+ this.page.clear_secondary_action();
+ this.page.clear_inner_toolbar();
+
+ page.is_editable && this.page.set_primary_action(
+ __("Save Customizations"),
+ () => {
+ this.page.clear_primary_action();
+ this.page.clear_secondary_action();
+ this.page.clear_inner_toolbar();
+ this.undo.readOnly = true;
+ this.save_page();
+ this.editor.readOnly.toggle();
+ this.is_read_only = true;
+ },
+ null,
+ __("Saving")
+ );
+
+ this.page.set_secondary_action(
+ __("Discard"),
+ () => {
+ this.page.clear_primary_action();
+ this.page.clear_secondary_action();
+ this.page.clear_inner_toolbar();
+ this.editor.readOnly.toggle();
+ this.is_read_only = true;
+ this.deleted_sidebar_items = [];
+ this.reload();
+ frappe.show_alert({ message: __("Customizations Discarded"), indicator: "info" });
+ }
+ );
+
+ page.name && this.page.add_inner_button(__("Settings"), () => {
+ frappe.set_route(`workspace/${page.name}`);
+ });
+
+ Object.keys(this.blocks).forEach(key => {
+ this.page.add_inner_button(`
+
+
+ `, function() {
+ const index = me.editor.blocks.getBlocksCount() + 1;
+ me.editor.blocks.insert(key, {}, {}, index, true);
+ me.editor.caret.setToLastBlock('start', 0);
+ $('.ce-block:last-child')[0].scrollIntoView();
+ }, __('Add Block'));
+ });
+ }
+
+ show_sidebar_actions() {
+ this.sidebar.find('.standard-sidebar-section').addClass('show-control');
+ }
+
+ add_sidebar_actions(item, sidebar_control) {
+ if (!item.is_editable) {
+ $(``)
+ .appendTo(sidebar_control);
+ sidebar_control.parent().click(() => {
+ !this.is_read_only && frappe.show_alert({
+ message: __("Only Workspace Manager can sort or edit this page"),
+ indicator: 'info'
+ }, 5);
+ });
+ } else {
+ frappe.utils.add_custom_button(
+ frappe.utils.icon('drag', 'xs'),
+ null,
+ "drag-handle",
+ `${__('Drag')}`,
+ null,
+ sidebar_control
+ );
+ frappe.utils.add_custom_button(
+ frappe.utils.icon('delete', 'xs'),
+ () => this.delete_page(item),
+ "delete-page",
+ `${__('Delete')}`,
+ null,
+ sidebar_control
+ );
+ }
+ }
+
+ delete_page(item) {
+ frappe.confirm(__("Are you sure you want to delete page {0}?", [item.title]), () => {
+ this.deleted_sidebar_items.push(item);
+ this.sidebar.find(`.standard-sidebar-section [item-name="${item.title}"][item-public="${item.public}"]`).addClass('hidden');
+ });
+ }
+
+ make_sidebar_sortable() {
+ let me = this;
+ $('.nested-container').each( function() {
+ new Sortable(this, {
+ handle: ".drag-handle",
+ draggable: ".sidebar-item-container.is-draggable",
+ group: 'nested',
+ animation: 150,
+ fallbackOnBody: true,
+ swapThreshold: 0.65,
+ onEnd: function (evt) {
+ let is_public = $(evt.item).attr('item-public') == '1';
+ me.prepare_sorted_sidebar(is_public);
+ }
+ });
+ });
+ }
+
+ prepare_sorted_sidebar(is_public) {
+ if (is_public) {
+ this.sorted_public_items = this.sort_sidebar(this.sidebar.find('.standard-sidebar-section').last());
+ } else {
+ this.sorted_private_items = this.sort_sidebar(this.sidebar.find('.standard-sidebar-section').first());
+ }
+ }
+
+ sort_sidebar($sidebar_section) {
+ let sorted_items = [];
+ for (let page of $sidebar_section.find('.sidebar-item-container')) {
+ let parent_page = "";
+ if (page.closest('.nested-container').classList.contains('sidebar-child-item')) {
+ parent_page = page.parentElement.parentElement.attributes["item-name"].value;
+ }
+ sorted_items.push({
+ title: page.attributes['item-name'].value,
+ parent_page: parent_page,
+ public: page.attributes['item-public'].value
+ });
+ }
+ return sorted_items;
+ }
+
+ make_blocks_sortable() {
+ let me = this;
+ this.page_sortable = Sortable.create(this.page.main.find(".codex-editor__redactor").get(0), {
+ handle: ".drag-handle",
+ draggable: ".ce-block",
+ animation: 150,
+ onEnd: function (evt) {
+ me.editor.blocks.move(evt.newIndex, evt.oldIndex);
+ },
+ setData: function () {
+ //Do Nothing
+ }
+ });
+ }
+
+ initialize_new_page() {
+ this.public_parent_pages = ['', ...this.public_pages.filter(page => !page.parent_page).map(page => page.title)];
+ this.private_parent_pages = ['', ...this.private_pages.filter(page => !page.parent_page).map(page => page.title)];
+ var me = this;
+ const d = new frappe.ui.Dialog({
+ title: __('Set Title'),
+ fields: [
+ {
+ label: __('Title'),
+ fieldtype: 'Data',
+ fieldname: 'title',
+ reqd: 1
+ },
+ {
+ label: __('Parent'),
+ fieldtype: 'Select',
+ fieldname: 'parent',
+ options: this.private_parent_pages
+ },
+ {
+ label: __('Public'),
+ fieldtype: 'Check',
+ fieldname: 'is_public',
+ depends_on: `eval:${this.has_access}`,
+ onchange: function() {
+ d.set_df_property('parent', 'options',
+ this.get_value() ? me.public_parent_pages : me.private_parent_pages);
+ }
+ },
+ {
+ fieldtype: 'Column Break'
+ },
+ {
+ label: __('Icon'),
+ fieldtype: 'Icon',
+ fieldname: 'icon'
+ },
+ ],
+ primary_action_label: __('Create'),
+ primary_action: (values) => {
+ if (!this.validate_page(values)) return;
+ d.hide();
+ this.initialize_editorjs_undo();
+ this.setup_customization_buttons({is_editable: true});
+ this.title = values.title;
+ this.icon = values.icon;
+ this.parent = values.parent;
+ this.public = values.is_public;
+ this.editor.render({
+ blocks: [
+ {
+ type: "header",
+ data: {
+ text: this.title,
+ level: 4
+ }
+ }
+ ]
+ }).then(() => {
+ if (this.editor.configuration.readOnly) {
+ this.is_read_only = false;
+ this.editor.readOnly.toggle();
+ }
+ this.add_page_to_sidebar(values);
+ this.show_sidebar_actions();
+ this.make_sidebar_sortable();
+ this.make_blocks_sortable();
+ this.prepare_sorted_sidebar(values.is_public);
+ });
+ }
+ });
+ d.show();
+ }
+
+ validate_page(values) {
+ let message = "";
+ let pages = values.is_public ? this.public_pages : this.private_pages;
+
+ if (pages && pages.filter(p => p.title == values.title)[0]) {
+ message = "Page with title '{0}' already exist.";
+ } else if (frappe.router.doctype_route_exist(frappe.router.slug(values.title))) {
+ message = "Doctype with same route already exist. Please choose different title.";
+ }
+
+ if (message) {
+ frappe.throw(__(message, [__(values.title)]));
+ return false;
+ }
+ return true;
+ }
+
+ add_page_to_sidebar({title, icon, parent, is_public}) {
+ let $sidebar = $('.standard-sidebar-section');
+ let item = {
+ title: title,
+ icon: icon,
+ parent_page: parent,
+ public: is_public
+ };
+ let $sidebar_item = this.sidebar_item_container(item);
+ $sidebar_item.addClass('is-draggable');
+
+ frappe.utils.add_custom_button(
+ frappe.utils.icon('drag', 'xs'),
+ null,
+ "drag-handle",
+ `${__('Drag')}`,
+ null,
+ $sidebar_item.find('.sidebar-item-control')
+ );
+ $sidebar_item.find('.sidebar-item-control .drag-handle').css('margin-right', '8px');
+
+ let $sidebar_section = is_public ? $sidebar[1] : $sidebar[0];
+
+ if (!parent) {
+ !is_public && $sidebar.last().removeClass('hidden');
+ $sidebar_item.appendTo($sidebar_section);
+ } else {
+ let $item_container = $($sidebar_section).find(`[item-name="${parent}"]`);
+ let $child_section = $item_container.find('.sidebar-child-item');
+ let $drop_icon = $item_container.find('.drop-icon');
+ if (!$child_section[0]) {
+ $child_section = $(``)
+ .appendTo($item_container);
+ $drop_icon.toggleClass('hidden');
+ }
+ $sidebar_item.appendTo($child_section);
+ $child_section.removeClass('hidden');
+ $item_container.find('.drop-icon use').attr("href", "#icon-small-up");
+ }
+ }
+
+ initialize_editorjs(blocks) {
+ this.editor = new EditorJS({
+ data: {
+ blocks: blocks || []
+ },
+ tools: this.tools,
+ autofocus: false,
+ tunes: ['spacingTune'],
+ readOnly: true,
+ logLevel: 'ERROR'
+ });
+ }
+
+ save_page() {
frappe.dom.freeze();
- const config = {};
+ let save = true;
+ if (!this.title && this.current_page) {
+ let pages = this.current_page.public ? this.public_pages : this.private_pages;
+ this.title = this.current_page.name;
+ this.public = pages.filter(p => p.title == this.title)[0].public;
+ save = false;
+ } else {
+ this.current_page = { name: this.title, public: this.public };
+ }
+ let me = this;
+ this.editor.save().then((outputData) => {
+ let new_widgets = {};
+ outputData.blocks.forEach(item => {
+ if (item.data.new) {
+ if (!new_widgets[item.type]) {
+ new_widgets[item.type] = [];
+ }
+ new_widgets[item.type].push(item.data.new);
+ delete item.data['new'];
+ }
+ });
- if (this.sections.charts) config.charts = this.sections.charts.get_widget_config();
- if (this.sections.shortcuts) config.shortcuts = this.sections.shortcuts.get_widget_config();
- if (this.sections.cards) config.cards = this.sections.cards.get_widget_config();
+ let blocks = outputData.blocks.filter(
+ item => item.type != 'card' ||
+ (item.data.card_name !== 'Custom Documents' &&
+ item.data.card_name !== 'Custom Reports')
+ );
- frappe.call('frappe.desk.desktop.save_customization', {
- page: this.page_name,
- config: config
- }).then(res => {
- frappe.dom.unfreeze();
- if (res.message) {
- frappe.show_alert({ message: __("Customizations Saved Successfully"), indicator: "green" });
- this.reload();
- } else {
- frappe.throw({ message: __("Something went wrong while saving customizations"), indicator: "red" });
- this.reload();
- }
+ frappe.call({
+ method: "frappe.desk.doctype.workspace.workspace.save_page",
+ args: {
+ title: me.title,
+ icon: me.icon || '',
+ parent: me.parent || '',
+ public: me.public || 0,
+ sb_public_items: me.sorted_public_items,
+ sb_private_items: me.sorted_private_items,
+ deleted_pages: me.deleted_sidebar_items,
+ new_widgets: new_widgets,
+ blocks: JSON.stringify(blocks),
+ save: save
+ },
+ callback: function(res) {
+ frappe.dom.unfreeze();
+ if (res.message) {
+ me.new_page = res.message;
+ me.title = '';
+ me.icon = '';
+ me.parent = '';
+ me.public = false;
+ me.sorted_public_items = [];
+ me.sorted_private_items = [];
+ me.deleted_sidebar_items = [];
+ me.reload();
+ frappe.show_alert({ message: __("Page Saved Successfully"), indicator: "green" });
+ }
+ }
+ });
+ }).catch((error) => {
+ error;
+ // console.log('Saving failed: ', error);
});
}
- reset_customization() {
- frappe.call('frappe.desk.desktop.reset_customization', {
- page: this.page_name
- }).then(() => {
- frappe.show_alert({ message: __("Removed page customizations"), indicator: "green" });
- this.reload();
- });
+ reload() {
+ this.$page.prepend(frappe.render_template('workspace_loading_skeleton'));
+ this.$page.find('.codex-editor').addClass('hidden');
+ this.setup_pages();
+ this.undo.readOnly = true;
}
-
- make_onboarding() {
- this.onboarding_widget = frappe.widget.make_widget({
- label: this.data.onboarding.label || __("Let's Get Started"),
- subtitle: this.data.onboarding.subtitle,
- steps: this.data.onboarding.items,
- success: this.data.onboarding.success,
- docs_url: this.data.onboarding.docs_url,
- user_can_dismiss: this.data.onboarding.user_can_dismiss,
- widget_type: 'onboarding',
- container: this.page,
- options: {
- allow_sorting: false,
- allow_create: false,
- allow_delete: false,
- allow_hiding: false,
- allow_edit: false,
- max_widget_count: 2,
- }
- });
- }
-
- make_charts() {
- this.sections["charts"] = new frappe.widget.WidgetGroup({
- container: this.page,
- type: "chart",
- columns: 1,
- class_name: "widget-charts",
- hidden: Boolean(this.onboarding_widget),
- options: {
- allow_sorting: this.allow_customization,
- allow_create: this.allow_customization,
- allow_delete: this.allow_customization,
- allow_hiding: false,
- allow_edit: true,
- max_widget_count: 2,
- },
- widgets: this.data.charts.items
- });
- }
-
- make_shortcuts() {
- this.sections["shortcuts"] = new frappe.widget.WidgetGroup({
- title: this.data.shortcuts.label || __('Your Shortcuts'),
- container: this.page,
- type: "shortcut",
- columns: 3,
- options: {
- allow_sorting: this.allow_customization,
- allow_create: this.allow_customization,
- allow_delete: this.allow_customization,
- allow_hiding: false,
- allow_edit: true,
- },
- widgets: this.data.shortcuts.items
- });
- }
-
- make_cards() {
- let cards = new frappe.widget.WidgetGroup({
- title: this.data.cards.label || __("Reports & Masters"),
- container: this.page,
- type: "links",
- columns: 3,
- options: {
- allow_sorting: this.allow_customization,
- allow_create: false,
- allow_delete: false,
- allow_hiding: this.allow_customization,
- allow_edit: false,
- },
- widgets: this.data.cards.items
- });
-
- this.sections["cards"] = cards;
- }
-}
-
-
+};
\ No newline at end of file
diff --git a/frappe/public/js/frappe/widgets/base_widget.js b/frappe/public/js/frappe/widgets/base_widget.js
index 9bbfb916e5..e6ae64d9dc 100644
--- a/frappe/public/js/frappe/widgets/base_widget.js
+++ b/frappe/public/js/frappe/widgets/base_widget.js
@@ -25,18 +25,23 @@ export default class Widget {
this.action_area.empty();
options.allow_sorting &&
- this.add_custom_button(
+ frappe.utils.add_custom_button(
frappe.utils.icon('drag', 'xs'),
null,
"drag-handle",
+ `${__('Drag')}`,
+ null,
+ this.action_area
);
options.allow_delete &&
- this.add_custom_button(
+ frappe.utils.add_custom_button(
frappe.utils.icon('delete', 'xs'),
() => this.delete(),
"",
- `${__('Delete')}`
+ `${__('Delete')}`,
+ null,
+ this.action_area
);
if (options.allow_hiding) {
@@ -48,11 +53,13 @@ export default class Widget {
}
const classname = this.hidden ? 'fa fa-eye' : 'fa fa-eye-slash';
const title = this.hidden ? `${__('Show')}` : `${__('Hide')}`;
- this.add_custom_button(
+ frappe.utils.add_custom_button(
``,
() => this.hide_or_show(),
"show-or-hide-button",
- title
+ title,
+ null,
+ this.action_area
);
this.show_or_hide_button = this.action_area.find(
@@ -61,18 +68,24 @@ export default class Widget {
}
options.allow_edit &&
- this.add_custom_button(
+ frappe.utils.add_custom_button(
frappe.utils.icon("edit", "xs"),
- () => this.edit()
+ () => this.edit(),
+ null,
+ `${__('Edit')}`,
+ null,
+ this.action_area
);
if (options.allow_resize) {
const title = this.width == 'Full'? `${__('Collapse')}` : `${__('Expand')}`;
- this.add_custom_button(
+ frappe.utils.add_custom_button(
'',
() => this.toggle_width(),
"resize-button",
- title
+ title,
+ null,
+ this.action_area
);
this.resize_button = this.action_area.find(
@@ -88,12 +101,11 @@ export default class Widget {
make_widget() {
this.widget = $(`