Merge branch 'frappe:develop' into folder-navigation
This commit is contained in:
commit
96a1e57783
105 changed files with 5765 additions and 945 deletions
6
.github/helper/documentation.py
vendored
6
.github/helper/documentation.py
vendored
|
|
@ -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):
|
||||
|
|
|
|||
7
.github/workflows/patch-mariadb-tests.yml
vendored
7
.github/workflows/patch-mariadb-tests.yml
vendored
|
|
@ -1,6 +1,11 @@
|
|||
name: Patch
|
||||
|
||||
on: [pull_request, workflow_dispatch]
|
||||
on:
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- '**.js'
|
||||
- '**.md'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
|
|
|||
6
.github/workflows/server-mariadb-tests.yml
vendored
6
.github/workflows/server-mariadb-tests.yml
vendored
|
|
@ -2,9 +2,15 @@ name: Server
|
|||
|
||||
on:
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- '**.js'
|
||||
- '**.md'
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ develop ]
|
||||
paths-ignore:
|
||||
- '**.js'
|
||||
- '**.md'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
|
|
|||
3
.github/workflows/server-postgres-tests.yml
vendored
3
.github/workflows/server-postgres-tests.yml
vendored
|
|
@ -2,6 +2,9 @@ name: Server
|
|||
|
||||
on:
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- '**.js'
|
||||
- '**.md'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
|
|
|||
2
.github/workflows/ui-tests.yml
vendored
2
.github/workflows/ui-tests.yml
vendored
|
|
@ -2,6 +2,8 @@ name: UI
|
|||
|
||||
on:
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ develop ]
|
||||
|
|
|
|||
12
.mergify.yml
12
.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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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"]')
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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]}`);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
90
cypress/integration/workspace.js
Normal file
90
cypress/integration/workspace.js
Normal file
|
|
@ -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');
|
||||
});
|
||||
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ frappe.ui.form.on('Auto Repeat', {
|
|||
refresh: function(frm) {
|
||||
// auto repeat message
|
||||
if (frm.is_new()) {
|
||||
let customize_form_link = `<a href="/app/customize form">${__('Customize Form')}</a>`;
|
||||
let customize_form_link = `<a href="/app/customize-form">${__('Customize Form')}</a>`;
|
||||
frm.dashboard.set_headline(__('To configure Auto Repeat, enable "Allow Auto Repeat" from {0}.', [customize_form_link]));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
13
frappe/email/doctype/newsletter/exceptions.py
Normal file
13
frappe/email/doctype/newsletter/exceptions.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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 = """
|
||||
<p>{0}. {1}.</p>
|
||||
<p><a href="{2}">{3}</a></p>
|
||||
""".format(*messages)
|
||||
<p>{0}. {1}.</p>
|
||||
<p><a href="{2}">{3}</a></p>
|
||||
""".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()
|
||||
|
|
|
|||
|
|
@ -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 <test_sender@example.com>",
|
||||
"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 <test_sender@example.com>",
|
||||
"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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -72,7 +72,8 @@ data_field_options = (
|
|||
'Email',
|
||||
'Name',
|
||||
'Phone',
|
||||
'URL'
|
||||
'URL',
|
||||
'Barcode'
|
||||
)
|
||||
|
||||
default_fields = (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(",")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
69
frappe/patches/v14_0/update_workspace2.py
Normal file
69
frappe/patches/v14_0/update_workspace2.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -57,6 +57,9 @@
|
|||
<symbol viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg" id="icon-small-down">
|
||||
<path d="M2.625 4.375L6 7.75l3.375-3.375" stroke="var(--icon-stroke)" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</symbol>
|
||||
<symbol viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg" id="icon-small-up">
|
||||
<path d="M9.5 7.75L6 4.25L2.5 7.75" stroke="var(--icon-stroke)" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</symbol>
|
||||
<symbol viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" id="icon-small-add">
|
||||
<path d="M8 4v8M4 8h8" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</symbol>
|
||||
|
|
@ -563,6 +566,10 @@
|
|||
<path d="M8.81836 6.45466L10.7943 4.47873C11.4212 3.85205 12.2714 3.5 13.1578 3.5C14.0443 3.5 14.8945 3.85205 15.5214 4.47873V4.47873C16.1481 5.10568 16.5001 5.95584 16.5001 6.84229C16.5001 7.72873 16.1481 8.5789 15.5214 9.20584L13.5455 11.1818" stroke="var(--icon-stroke)" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6.45466 8.81824L4.47873 10.7942C3.85205 11.4211 3.5 12.2713 3.5 13.1577C3.5 14.0442 3.85205 14.8943 4.47873 15.5213V15.5213C5.10568 16.148 5.95584 16.5 6.84229 16.5C7.72874 16.5 8.5789 16.148 9.20584 15.5213L11.1818 13.5453" stroke="var(--icon-stroke)" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</symbol>
|
||||
<symbol viewBox="0 0 24 24" fill="none" id="icon-scan" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"
|
||||
stroke="var(--icon-stroke)" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||
</symbol>
|
||||
<symbol viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" id="icon-dashboard">
|
||||
<path d="M6.5 3.5v9m-3-9h9a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1z" stroke="var(--icon-stroke)" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</symbol>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
`<span class="link-btn">
|
||||
<a class="btn-open no-decoration" title="${__("Scan")}">
|
||||
${frappe.utils.icon('scan', 'sm')}
|
||||
</a>
|
||||
</span>`
|
||||
);
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
101
frappe/public/js/frappe/scanner/index.js
Normal file
101
frappe/public/js/frappe/scanner/index.js
Normal file
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -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 class="btn ${btn_type} btn-xs ${class_name}" title="${title}">${html}</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));
|
||||
}
|
||||
|
|
|
|||
115
frappe/public/js/frappe/views/workspace/blocks/block.js
Normal file
115
frappe/public/js/frappe/views/workspace/blocks/block.js
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
59
frappe/public/js/frappe/views/workspace/blocks/card.js
Normal file
59
frappe/public/js/frappe/views/workspace/blocks/card.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import Block from "./block.js";
|
||||
export default class Card extends Block {
|
||||
static get toolbox() {
|
||||
return {
|
||||
title: 'Card',
|
||||
icon: '<svg height="20" width="20" viewBox="2 2 20 20"><path d="M7 15h3a1 1 0 000-2H7a1 1 0 000 2zM19 5H5a3 3 0 00-3 3v9a3 3 0 003 3h14a3 3 0 003-3V8a3 3 0 00-3-3zm1 12a1 1 0 01-1 1H5a1 1 0 01-1-1v-6h16zm0-8H4V8a1 1 0 011-1h14a1 1 0 011 1z"/></svg>'
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
59
frappe/public/js/frappe/views/workspace/blocks/chart.js
Normal file
59
frappe/public/js/frappe/views/workspace/blocks/chart.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import Block from "./block.js";
|
||||
export default class Chart extends Block {
|
||||
static get toolbox() {
|
||||
return {
|
||||
title: 'Chart',
|
||||
icon: '<svg height="18" width="18" viewBox="0 0 512 512"><path d="M117.547 234.667H10.88c-5.888 0-10.667 4.779-10.667 10.667v256C.213 507.221 4.992 512 10.88 512h106.667c5.888 0 10.667-4.779 10.667-10.667v-256a10.657 10.657 0 00-10.667-10.666zM309.12 0H202.453c-5.888 0-10.667 4.779-10.667 10.667v490.667c0 5.888 4.779 10.667 10.667 10.667H309.12c5.888 0 10.667-4.779 10.667-10.667V10.667C319.787 4.779 315.008 0 309.12 0zM501.12 106.667H394.453c-5.888 0-10.667 4.779-10.667 10.667v384c0 5.888 4.779 10.667 10.667 10.667H501.12c5.888 0 10.667-4.779 10.667-10.667v-384c0-5.889-4.779-10.667-10.667-10.667z"/></svg>'
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
339
frappe/public/js/frappe/views/workspace/blocks/header.js
Normal file
339
frappe/public/js/frappe/views/workspace/blocks/header.js
Normal file
|
|
@ -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 = $(`<div class="widget-head"></div>`);
|
||||
let $widget_control = $(`<div class="widget-control"></div>`);
|
||||
|
||||
$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: '<svg width="16" height="14" xmlns="http://www.w3.org/2000/svg"><path d="M2.14 1.494V4.98h4.62V1.494c0-.498.098-.871.293-1.12A.927.927 0 0 1 7.82 0c.322 0 .583.123.782.37.2.246.3.62.3 1.124v9.588c0 .503-.101.88-.303 1.128a.957.957 0 0 1-.779.374.921.921 0 0 1-.77-.378c-.193-.251-.29-.626-.29-1.124V6.989H2.14v4.093c0 .503-.1.88-.302 1.128a.957.957 0 0 1-.778.374.921.921 0 0 1-.772-.378C.096 11.955 0 11.58 0 11.082V1.494C0 .996.095.623.285.374A.922.922 0 0 1 1.06 0c.321 0 .582.123.782.37.199.246.299.62.299 1.124zm11.653 9.985V5.27c-1.279.887-2.14 1.33-2.583 1.33a.802.802 0 0 1-.563-.228.703.703 0 0 1-.245-.529c0-.232.08-.402.241-.511.161-.11.446-.25.854-.424.61-.259 1.096-.532 1.462-.818a5.84 5.84 0 0 0 .97-.962c.282-.355.466-.573.552-.655.085-.082.246-.123.483-.123.267 0 .481.093.642.28.161.186.242.443.242.77v7.813c0 .914-.345 1.371-1.035 1.371-.307 0-.554-.093-.74-.28-.187-.186-.28-.461-.28-.825z"/></svg>',
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
tag: 'H2',
|
||||
svg: '<svg width="18" height="14" xmlns="http://www.w3.org/2000/svg"><path d="M2.152 1.494V4.98h4.646V1.494c0-.498.097-.871.293-1.12A.934.934 0 0 1 7.863 0c.324 0 .586.123.786.37.2.246.301.62.301 1.124v9.588c0 .503-.101.88-.304 1.128a.964.964 0 0 1-.783.374.928.928 0 0 1-.775-.378c-.194-.251-.29-.626-.29-1.124V6.989H2.152v4.093c0 .503-.101.88-.304 1.128a.964.964 0 0 1-.783.374.928.928 0 0 1-.775-.378C.097 11.955 0 11.58 0 11.082V1.494C0 .996.095.623.286.374A.929.929 0 0 1 1.066 0c.323 0 .585.123.786.37.2.246.3.62.3 1.124zm10.99 9.288h3.527c.351 0 .62.072.804.216.185.144.277.34.277.588 0 .22-.073.408-.22.56-.146.154-.368.23-.665.23h-4.972c-.338 0-.601-.093-.79-.28a.896.896 0 0 1-.284-.659c0-.162.06-.377.182-.645s.255-.478.399-.631a38.617 38.617 0 0 1 1.621-1.598c.482-.444.827-.735 1.034-.875.369-.261.676-.523.922-.787.245-.263.432-.534.56-.81.129-.278.193-.549.193-.815 0-.288-.069-.546-.206-.773a1.428 1.428 0 0 0-.56-.53 1.618 1.618 0 0 0-.774-.19c-.59 0-1.054.26-1.392.777-.045.068-.12.252-.226.554-.106.302-.225.534-.358.696-.133.162-.328.243-.585.243a.76.76 0 0 1-.56-.223c-.149-.148-.223-.351-.223-.608 0-.31.07-.635.21-.972.139-.338.347-.645.624-.92a3.093 3.093 0 0 1 1.054-.665c.426-.169.924-.253 1.496-.253.69 0 1.277.108 1.764.324.315.144.592.343.83.595.24.252.425.544.558.875.133.33.2.674.2 1.03 0 .558-.14 1.066-.416 1.523-.277.457-.56.815-.848 1.074-.288.26-.771.666-1.45 1.22-.677.554-1.142.984-1.394 1.29a3.836 3.836 0 0 0-.331.44z"/></svg>',
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
tag: 'H3',
|
||||
svg: '<svg width="18" height="14" xmlns="http://www.w3.org/2000/svg"><path d="M2.152 1.494V4.98h4.646V1.494c0-.498.097-.871.293-1.12A.934.934 0 0 1 7.863 0c.324 0 .586.123.786.37.2.246.301.62.301 1.124v9.588c0 .503-.101.88-.304 1.128a.964.964 0 0 1-.783.374.928.928 0 0 1-.775-.378c-.194-.251-.29-.626-.29-1.124V6.989H2.152v4.093c0 .503-.101.88-.304 1.128a.964.964 0 0 1-.783.374.928.928 0 0 1-.775-.378C.097 11.955 0 11.58 0 11.082V1.494C0 .996.095.623.286.374A.929.929 0 0 1 1.066 0c.323 0 .585.123.786.37.2.246.3.62.3 1.124zm11.61 4.919c.418 0 .778-.123 1.08-.368.301-.245.452-.597.452-1.055 0-.35-.12-.65-.36-.902-.241-.252-.566-.378-.974-.378-.277 0-.505.038-.684.116a1.1 1.1 0 0 0-.426.306 2.31 2.31 0 0 0-.296.49c-.093.2-.178.388-.255.565a.479.479 0 0 1-.245.225.965.965 0 0 1-.409.081.706.706 0 0 1-.5-.22c-.152-.148-.228-.345-.228-.59 0-.236.071-.484.214-.745a2.72 2.72 0 0 1 .627-.746 3.149 3.149 0 0 1 1.024-.568 4.122 4.122 0 0 1 1.368-.214c.44 0 .842.06 1.205.18.364.12.679.294.947.52.267.228.47.49.606.79.136.3.204.622.204.967 0 .454-.099.843-.296 1.168-.198.324-.48.64-.848.95.354.19.653.408.895.653.243.245.426.516.548.813.123.298.184.619.184.964 0 .413-.083.812-.248 1.198-.166.386-.41.73-.732 1.031a3.49 3.49 0 0 1-1.147.708c-.443.17-.932.256-1.467.256a3.512 3.512 0 0 1-1.464-.293 3.332 3.332 0 0 1-1.699-1.64c-.142-.314-.214-.573-.214-.777 0-.263.085-.475.255-.636a.89.89 0 0 1 .637-.242c.127 0 .25.037.367.112a.53.53 0 0 1 .232.27c.236.63.489 1.099.759 1.405.27.306.65.46 1.14.46a1.714 1.714 0 0 0 1.46-.824c.17-.273.256-.588.256-.947 0-.53-.145-.947-.436-1.249-.29-.302-.694-.453-1.212-.453-.09 0-.231.01-.422.028-.19.018-.313.027-.367.027-.25 0-.443-.062-.579-.187-.136-.125-.204-.299-.204-.521 0-.218.081-.394.245-.528.163-.134.406-.2.728-.2h.28z"/></svg>',
|
||||
},
|
||||
{
|
||||
number: 4,
|
||||
tag: 'H4',
|
||||
svg: '<svg width="20" height="14" xmlns="http://www.w3.org/2000/svg"><path d="M2.152 1.494V4.98h4.646V1.494c0-.498.097-.871.293-1.12A.934.934 0 0 1 7.863 0c.324 0 .586.123.786.37.2.246.301.62.301 1.124v9.588c0 .503-.101.88-.304 1.128a.964.964 0 0 1-.783.374.928.928 0 0 1-.775-.378c-.194-.251-.29-.626-.29-1.124V6.989H2.152v4.093c0 .503-.101.88-.304 1.128a.964.964 0 0 1-.783.374.928.928 0 0 1-.775-.378C.097 11.955 0 11.58 0 11.082V1.494C0 .996.095.623.286.374A.929.929 0 0 1 1.066 0c.323 0 .585.123.786.37.2.246.3.62.3 1.124zm13.003 10.09v-1.252h-3.38c-.427 0-.746-.097-.96-.29-.213-.193-.32-.456-.32-.788 0-.085.016-.171.048-.259.031-.088.078-.18.141-.276.063-.097.128-.19.195-.28.068-.09.15-.2.25-.33l3.568-4.774a5.44 5.44 0 0 1 .576-.683.763.763 0 0 1 .542-.212c.682 0 1.023.39 1.023 1.171v5.212h.29c.346 0 .623.047.832.142.208.094.313.3.313.62 0 .26-.086.45-.256.568-.17.12-.427.179-.768.179h-.41v1.252c0 .346-.077.603-.23.771-.152.168-.356.253-.612.253a.78.78 0 0 1-.61-.26c-.154-.173-.232-.427-.232-.764zm-2.895-2.76h2.895V4.91L12.26 8.823z"/></svg>',
|
||||
},
|
||||
{
|
||||
number: 5,
|
||||
tag: 'H5',
|
||||
svg: '<svg width="18" height="14" xmlns="http://www.w3.org/2000/svg"><path d="M2.152 1.494V4.98h4.646V1.494c0-.498.097-.871.293-1.12A.934.934 0 0 1 7.863 0c.324 0 .586.123.786.37.2.246.301.62.301 1.124v9.588c0 .503-.101.88-.304 1.128a.964.964 0 0 1-.783.374.928.928 0 0 1-.775-.378c-.194-.251-.29-.626-.29-1.124V6.989H2.152v4.093c0 .503-.101.88-.304 1.128a.964.964 0 0 1-.783.374.928.928 0 0 1-.775-.378C.097 11.955 0 11.58 0 11.082V1.494C0 .996.095.623.286.374A.929.929 0 0 1 1.066 0c.323 0 .585.123.786.37.2.246.3.62.3 1.124zm14.16 2.645h-3.234l-.388 2.205c.644-.344 1.239-.517 1.783-.517.436 0 .843.082 1.222.245.38.164.712.39.998.677.286.289.51.63.674 1.025.163.395.245.82.245 1.273 0 .658-.148 1.257-.443 1.797-.295.54-.72.97-1.276 1.287-.556.318-1.197.477-1.923.477-.813 0-1.472-.15-1.978-.45-.506-.3-.865-.643-1.076-1.031-.21-.388-.316-.727-.316-1.018 0-.177.073-.345.22-.504a.725.725 0 0 1 .556-.238c.381 0 .665.22.85.66.182.404.427.719.736.943.309.225.654.337 1.035.337.35 0 .656-.09.919-.272.263-.182.466-.431.61-.749.142-.318.214-.678.214-1.082 0-.436-.078-.808-.232-1.117a1.607 1.607 0 0 0-.62-.69 1.674 1.674 0 0 0-.864-.229c-.39 0-.67.048-.837.143-.168.095-.41.262-.725.5-.316.239-.576.358-.78.358a.843.843 0 0 1-.592-.242c-.173-.16-.259-.344-.259-.548 0-.022.025-.177.075-.463l.572-3.26c.063-.39.181-.675.354-.852.172-.177.454-.265.844-.265h3.595c.708 0 1.062.27 1.062.81a.711.711 0 0 1-.26.572c-.172.145-.426.218-.762.218z"/></svg>',
|
||||
},
|
||||
{
|
||||
number: 6,
|
||||
tag: 'H6',
|
||||
svg: '<svg width="18" height="14" xmlns="http://www.w3.org/2000/svg"><path d="M2.152 1.494V4.98h4.646V1.494c0-.498.097-.871.293-1.12A.934.934 0 0 1 7.863 0c.324 0 .586.123.786.37.2.246.301.62.301 1.124v9.588c0 .503-.101.88-.304 1.128a.964.964 0 0 1-.783.374.928.928 0 0 1-.775-.378c-.194-.251-.29-.626-.29-1.124V6.989H2.152v4.093c0 .503-.101.88-.304 1.128a.964.964 0 0 1-.783.374.928.928 0 0 1-.775-.378C.097 11.955 0 11.58 0 11.082V1.494C0 .996.095.623.286.374A.929.929 0 0 1 1.066 0c.323 0 .585.123.786.37.2.246.3.62.3 1.124zM12.53 7.058a3.093 3.093 0 0 1 1.004-.814 2.734 2.734 0 0 1 1.214-.264c.43 0 .827.08 1.19.24.365.161.684.39.957.686.274.296.485.645.635 1.048a3.6 3.6 0 0 1 .223 1.262c0 .637-.145 1.216-.437 1.736-.292.52-.699.926-1.221 1.218-.522.292-1.114.438-1.774.438-.76 0-1.416-.186-1.967-.557-.552-.37-.974-.919-1.265-1.645-.292-.726-.438-1.613-.438-2.662 0-.855.088-1.62.265-2.293.176-.674.43-1.233.76-1.676.33-.443.73-.778 1.2-1.004.47-.226 1.006-.339 1.608-.339.579 0 1.089.113 1.53.34.44.225.773.506.997.84.224.335.335.656.335.964 0 .185-.07.354-.21.505a.698.698 0 0 1-.536.227.874.874 0 0 1-.529-.18 1.039 1.039 0 0 1-.36-.498 1.42 1.42 0 0 0-.495-.655 1.3 1.3 0 0 0-.786-.247c-.24 0-.479.069-.716.207a1.863 1.863 0 0 0-.6.56c-.33.479-.525 1.333-.584 2.563zm1.832 4.213c.456 0 .834-.186 1.133-.56.298-.373.447-.862.447-1.468 0-.412-.07-.766-.21-1.062a1.584 1.584 0 0 0-.577-.678 1.47 1.47 0 0 0-.807-.234c-.28 0-.548.074-.804.224-.255.149-.461.365-.617.647a2.024 2.024 0 0 0-.234.994c0 .61.158 1.12.475 1.527.316.407.714.61 1.194.61z"/></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: '<svg width="10" height="14" viewBox="0 0 10 14"><path d="M7.6 8.15H2.25v4.525a1.125 1.125 0 0 1-2.25 0V1.125a1.125 1.125 0 1 1 2.25 0V5.9H7.6V1.125a1.125 1.125 0 0 1 2.25 0v11.55a1.125 1.125 0 0 1-2.25 0V8.15z"></path></svg>',
|
||||
title: 'Heading',
|
||||
};
|
||||
}
|
||||
}
|
||||
27
frappe/public/js/frappe/views/workspace/blocks/index.js
Normal file
27
frappe/public/js/frappe/views/workspace/blocks/index.js
Normal file
|
|
@ -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
|
||||
};
|
||||
129
frappe/public/js/frappe/views/workspace/blocks/onboarding.js
Normal file
129
frappe/public/js/frappe/views/workspace/blocks/onboarding.js
Normal file
|
|
@ -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: '<svg width="24" height="24" viewBox="2 0 20 24" fill="none"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10zM12 11.09v5.455" stroke="#1F272E" fill="none"/><path d="M12.41 7.455a.41.41 0 11-.82 0 .41.41 0 01.82 0z" stroke="#1F272E"/></svg>'
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
195
frappe/public/js/frappe/views/workspace/blocks/paragraph.js
Normal file
195
frappe/public/js/frappe/views/workspace/blocks/paragraph.js
Normal file
|
|
@ -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 = $(`<div class="paragraph-control"></div>`);
|
||||
|
||||
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: '<svg viewBox="0.2 -0.3 9 11.4" width="12" height="14"><path d="M0 2.77V.92A1 1 0 01.2.28C.35.1.56 0 .83 0h7.66c.28.01.48.1.63.28.14.17.21.38.21.64v1.85c0 .26-.08.48-.23.66-.15.17-.37.26-.66.26-.28 0-.5-.09-.64-.26a1 1 0 01-.21-.66V1.69H5.6v7.58h.5c.25 0 .45.08.6.23.17.16.25.35.25.6s-.08.45-.24.6a.87.87 0 01-.62.22H3.21a.87.87 0 01-.61-.22.78.78 0 01-.24-.6c0-.25.08-.44.24-.6a.85.85 0 01.61-.23h.5V1.7H1.73v1.08c0 .26-.08.48-.23.66-.15.17-.37.26-.66.26-.28 0-.5-.09-.64-.26A1 1 0 010 2.77z"/></svg>',
|
||||
title: 'Text'
|
||||
};
|
||||
}
|
||||
}
|
||||
57
frappe/public/js/frappe/views/workspace/blocks/shortcut.js
Normal file
57
frappe/public/js/frappe/views/workspace/blocks/shortcut.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import Block from "./block.js";
|
||||
export default class Shortcut extends Block {
|
||||
static get toolbox() {
|
||||
return {
|
||||
title: 'Shortcut',
|
||||
icon: '<svg height="18" width="18" viewBox="0 0 122.88 115.71"><path d="M116.56 3.69l-3.84 53.76-17.69-15c-19.5 8.72-29.96 23.99-30.51 43.77-17.95-26.98-7.46-50.4 12.46-65.97L64.96 3l51.6.69zM28.3 0h14.56v19.67H32.67c-4.17 0-7.96 1.71-10.72 4.47-2.75 2.75-4.46 6.55-4.46 10.72l-.03 46c.03 4.16 1.75 7.95 4.5 10.71 2.76 2.76 6.56 4.48 10.71 4.48h58.02c4.15 0 7.95-1.72 10.71-4.48 2.76-2.76 4.48-6.55 4.48-10.71V73.9h17.01v11.33c0 7.77-3.2 17.04-8.32 22.16-5.12 5.12-12.21 8.32-19.98 8.32H28.3c-7.77 0-14.86-3.2-19.98-8.32C3.19 102.26 0 95.18 0 87.41l.03-59.1c-.03-7.79 3.16-14.88 8.28-20C13.43 3.19 20.51 0 28.3 0z" fill-rule="evenodd" clip-rule="evenodd"/></svg>'
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
82
frappe/public/js/frappe/views/workspace/blocks/spacer.js
Normal file
82
frappe/public/js/frappe/views/workspace/blocks/spacer.js
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import Block from './block.js';
|
||||
export default class Spacer extends Block {
|
||||
static get toolbox() {
|
||||
return {
|
||||
title: 'Spacer',
|
||||
icon: '<svg width="18" height="18" viewBox="0 0 400 400"><path d="M377.87 24.126C361.786 8.042 342.417 0 319.769 0H82.227C59.579 0 40.211 8.042 24.125 24.126 8.044 40.212.002 59.576.002 82.228v237.543c0 22.647 8.042 42.014 24.123 58.101 16.086 16.085 35.454 24.127 58.102 24.127h237.542c22.648 0 42.011-8.042 58.102-24.127 16.085-16.087 24.126-35.453 24.126-58.101V82.228c-.004-22.648-8.046-42.016-24.127-58.102zm-12.422 295.645c0 12.559-4.47 23.314-13.415 32.264-8.945 8.945-19.698 13.411-32.265 13.411H82.227c-12.563 0-23.317-4.466-32.264-13.411-8.945-8.949-13.418-19.705-13.418-32.264V82.228c0-12.562 4.473-23.316 13.418-32.264 8.947-8.946 19.701-13.418 32.264-13.418h237.542c12.566 0 23.319 4.473 32.265 13.418 8.945 8.947 13.415 19.701 13.415 32.264v237.543h-.001z"/></svg>'
|
||||
};
|
||||
}
|
||||
|
||||
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 = $(`
|
||||
<div class="widget-head">
|
||||
<div></div>
|
||||
<div>Spacer</div>
|
||||
<div class="widget-control"></div>
|
||||
</div>
|
||||
`);
|
||||
$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()
|
||||
};
|
||||
}
|
||||
}
|
||||
123
frappe/public/js/frappe/views/workspace/blocks/spacing_tune.js
Normal file
123
frappe/public/js/frappe/views/workspace/blocks/spacing_tune.js
Normal file
|
|
@ -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 = `<svg version="1.1" height="10" x="0px" y="0px" viewBox="-674 380 17 10" style="enable-background:new -674 380 17 10;" xml:space="preserve"><path d="M-674,383.9h3.6l-1.7-1.7c-0.4-0.4-0.4-1.2,0-1.6c0.4-0.4,1.1-0.4,1.6,0l3.2,3.2c0.6,0.2,0.8,0.8,0.6,1.4 c-0.1,0.1-0.1,0.3-0.2,0.4l-3.8,3.8c-0.4,0.4-1.1,0.4-1.5,0c-0.4-0.4-0.4-1.1,0-1.5l1.8-1.8h-3.6V383.9z"/><path d="M-657,386.1h-3.6l1.7,1.7c0.4,0.4,0.4,1.2,0,1.6c-0.4,0.4-1.1,0.4-1.6,0l-3.2-3.2c-0.6-0.2-0.8-0.8-0.6-1.4 c0.1-0.1,0.1-0.3,0.2-0.4l3.8-3.8c0.4-0.4,1.1-0.4,1.5,0c0.4,0.4,0.4,1.1,0,1.5l-1.8,1.8h3.6V386.1z"/></svg>`;
|
||||
this.api.tooltip.onHover(decreaseWidthButton, 'Shrink', {
|
||||
placement: 'top',
|
||||
hidingDelay: 500,
|
||||
});
|
||||
this.api.listeners.on(
|
||||
decreaseWidthButton,
|
||||
'click',
|
||||
() => me.decreaseWidth(),
|
||||
false
|
||||
);
|
||||
|
||||
increaseWidthButton.innerHTML = `<svg width="17" height="10" viewBox="0 0 17 10"><path d="M13.568 5.925H4.056l1.703 1.703a1.125 1.125 0 0 1-1.59 1.591L.962 6.014A1.069 1.069 0 0 1 .588 4.26L4.38.469a1.069 1.069 0 0 1 1.512 1.511L4.084 3.787h9.606l-1.85-1.85a1.069 1.069 0 1 1 1.512-1.51l3.792 3.791a1.069 1.069 0 0 1-.475 1.788L13.514 9.16a1.125 1.125 0 0 1-1.59-1.591l1.644-1.644z"/></svg>`;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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(
|
||||
`<i class="${classname}" aria-hidden="true"></i>`,
|
||||
() => 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(
|
||||
'<i class="fa fa-expand" aria-hidden="true"></i>',
|
||||
() => 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 = $(`<div class="widget
|
||||
${ this.hidden ? "hidden" : " " }
|
||||
${ this.shadow ? "widget-shadow" : " " }
|
||||
" data-widget-name="${this.name ? this.name : ''}">
|
||||
<div class="widget-head">
|
||||
<div>
|
||||
<div class="widget-title ellipsis"></div>
|
||||
<div class="widget-label">
|
||||
<div class="widget-title"></div>
|
||||
<div class="widget-subtitle"></div>
|
||||
</div>
|
||||
<div class="widget-control"></div>
|
||||
|
|
@ -114,37 +126,25 @@ export default class Widget {
|
|||
}
|
||||
|
||||
set_title(max_chars) {
|
||||
let base = this.label || this.name;
|
||||
let base = this.title || this.label || this.name;
|
||||
let title = max_chars ? frappe.ellipsis(base, max_chars) : base;
|
||||
|
||||
if (this.icon) {
|
||||
let icon = frappe.utils.icon(this.icon);
|
||||
this.title_field[0].innerHTML = `${icon} <span>${title}</span>`;
|
||||
this.title_field[0].innerHTML = `${icon} <span class="ellipsis" title="${title}">${title}</span>`;
|
||||
} else {
|
||||
this.title_field[0].innerHTML = title;
|
||||
this.title_field[0].innerHTML = `<span class="ellipsis" title="${title}">${title}</span>`;
|
||||
if (max_chars) {
|
||||
this.title_field[0].setAttribute('title', this.label);
|
||||
this.title_field[0].setAttribute('title', this.title || this.label);
|
||||
}
|
||||
}
|
||||
this.subtitle && this.subtitle_field.html(this.subtitle);
|
||||
}
|
||||
|
||||
add_custom_button(html, action, class_name = "", title="", btn_type) {
|
||||
if (!btn_type) btn_type = 'btn-secondary';
|
||||
let button = $(
|
||||
`<button class="btn ${btn_type} btn-xs ${class_name}" title="${title}">${html}</button>`
|
||||
);
|
||||
button.click(event => {
|
||||
event.stopPropagation();
|
||||
action && action();
|
||||
});
|
||||
button.appendTo(this.action_area);
|
||||
}
|
||||
|
||||
delete(animate=true) {
|
||||
delete(animate=true, dismissed=false) {
|
||||
let remove_widget = (setup_new) => {
|
||||
this.widget.remove();
|
||||
this.options.on_delete && this.options.on_delete(this.name, setup_new);
|
||||
!dismissed && this.options.on_delete && this.options.on_delete(this.name, setup_new);
|
||||
};
|
||||
|
||||
if (animate) {
|
||||
|
|
@ -168,8 +168,9 @@ export default class Widget {
|
|||
primary_action: (data) => {
|
||||
Object.assign(this, data);
|
||||
data.name = this.name;
|
||||
|
||||
this.new = true;
|
||||
this.refresh();
|
||||
this.options.on_edit && this.options.on_edit(data);
|
||||
},
|
||||
primary_action_label: __("Save")
|
||||
});
|
||||
|
|
|
|||
|
|
@ -349,6 +349,8 @@ export default class ChartWidget extends Widget {
|
|||
}
|
||||
|
||||
setup_filter_button() {
|
||||
if (this.in_customize_mode) return;
|
||||
|
||||
this.is_document_type =
|
||||
this.chart_doc.chart_type !== "Report" &&
|
||||
this.chart_doc.chart_type !== "Custom";
|
||||
|
|
|
|||
|
|
@ -12,14 +12,18 @@ export default class LinksWidget extends Widget {
|
|||
return {
|
||||
name: this.name,
|
||||
links: JSON.stringify(this.links),
|
||||
link_count: this.links.length,
|
||||
label: this.label,
|
||||
hidden: this.hidden,
|
||||
};
|
||||
}
|
||||
|
||||
set_body() {
|
||||
this.options = {};
|
||||
this.options.links = this.links;
|
||||
|
||||
if (!this.options) {
|
||||
this.options = {};
|
||||
this.options.links = this.links;
|
||||
}
|
||||
this.widget.addClass("links-widget-box");
|
||||
const is_link_disabled = item => {
|
||||
return item.dependencies && item.incomplete_dependencies;
|
||||
|
|
@ -81,7 +85,9 @@ export default class LinksWidget extends Widget {
|
|||
${get_link_for_item(item)}
|
||||
</a>`);
|
||||
});
|
||||
|
||||
if (this.in_customize_mode) {
|
||||
this.body.empty();
|
||||
}
|
||||
this.link_list.forEach(link => link.appendTo(this.body));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,23 @@ import Widget from "./base_widget.js";
|
|||
frappe.provide("frappe.utils");
|
||||
|
||||
export default class OnboardingWidget extends Widget {
|
||||
|
||||
async refresh() {
|
||||
this.new && await this.get_onboarding_data();
|
||||
this.set_title();
|
||||
this.set_actions();
|
||||
this.set_body();
|
||||
this.setup_events();
|
||||
}
|
||||
|
||||
get_config() {
|
||||
return {
|
||||
label: this.onboarding_name
|
||||
};
|
||||
}
|
||||
|
||||
make_body() {
|
||||
this.body.empty();
|
||||
this.steps_wrapper = $(`<div class="onboarding-steps-wrapper"></div>`).appendTo(this.body);
|
||||
this.step_preview = $(`<div class="onboarding-step-preview">
|
||||
<div class="onboarding-step-body"></div>
|
||||
|
|
@ -477,11 +493,13 @@ export default class OnboardingWidget extends Widget {
|
|||
}
|
||||
|
||||
is_dismissed() {
|
||||
if (this.in_customize_mode) return false;
|
||||
|
||||
let dismissed = JSON.parse(
|
||||
localStorage.getItem("dismissed-onboarding") || "{}"
|
||||
);
|
||||
if (Object.keys(dismissed).includes(this.label)) {
|
||||
let last_hidden = new Date(dismissed[this.label]);
|
||||
if (Object.keys(dismissed).includes(this.title)) {
|
||||
let last_hidden = new Date(dismissed[this.title]);
|
||||
let today = new Date();
|
||||
let diff = frappe.datetime.get_hour_diff(today, last_hidden);
|
||||
return diff < 24;
|
||||
|
|
@ -490,6 +508,8 @@ export default class OnboardingWidget extends Widget {
|
|||
}
|
||||
|
||||
set_actions() {
|
||||
if (this.in_customize_mode) return;
|
||||
|
||||
this.action_area.empty();
|
||||
const dismiss = $(
|
||||
`<div class="small" style="cursor:pointer;">${__('Dismiss', null, 'Stop showing the onboarding widget.')}</div>`
|
||||
|
|
@ -498,14 +518,38 @@ export default class OnboardingWidget extends Widget {
|
|||
let dismissed = JSON.parse(
|
||||
localStorage.getItem("dismissed-onboarding") || "{}"
|
||||
);
|
||||
dismissed[this.label] = frappe.datetime.now_datetime();
|
||||
dismissed[this.title] = frappe.datetime.now_datetime();
|
||||
|
||||
localStorage.setItem(
|
||||
"dismissed-onboarding",
|
||||
JSON.stringify(dismissed)
|
||||
);
|
||||
this.delete();
|
||||
this.delete(true, true);
|
||||
this.widget.closest('.ce-block').hide();
|
||||
});
|
||||
dismiss.appendTo(this.action_area);
|
||||
}
|
||||
|
||||
get_onboarding_data() {
|
||||
return frappe.model
|
||||
.with_doc("Module Onboarding", this.onboarding_name)
|
||||
.then(onboarding_doc => {
|
||||
if (onboarding_doc) {
|
||||
this.onboarding_doc = onboarding_doc;
|
||||
this.label = onboarding_doc.label;
|
||||
this.title = onboarding_doc.title || __("Let's Get Started");
|
||||
this.subtitle = onboarding_doc.subtitle;
|
||||
this.success = onboarding_doc.success;
|
||||
this.docs_url = onboarding_doc.docs_url;
|
||||
this.user_can_dismiss = onboarding_doc.user_can_dismiss;
|
||||
const method =
|
||||
"frappe.desk.doctype.onboarding_step.onboarding_step.get_onboarding_steps";
|
||||
return frappe
|
||||
.xcall(method, { ob_steps: onboarding_doc.steps })
|
||||
.then(steps => {
|
||||
this.steps = steps;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,116 @@ class ChartDialog extends WidgetDialog {
|
|||
}
|
||||
}
|
||||
|
||||
class OnboardingDialog extends WidgetDialog {
|
||||
constructor(opts) {
|
||||
super(opts);
|
||||
}
|
||||
|
||||
get_fields() {
|
||||
return [
|
||||
{
|
||||
fieldtype: "Link",
|
||||
fieldname: "onboarding_name",
|
||||
label: "Onboarding Name",
|
||||
options: "Module Onboarding",
|
||||
reqd: 1,
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class CardDialog extends WidgetDialog {
|
||||
constructor(opts) {
|
||||
super(opts);
|
||||
}
|
||||
|
||||
get_fields() {
|
||||
let me = this;
|
||||
return [
|
||||
{
|
||||
fieldtype: "Data",
|
||||
fieldname: "label",
|
||||
label: "Label",
|
||||
},
|
||||
{
|
||||
fieldname: 'links',
|
||||
fieldtype: 'Table',
|
||||
label: __('Card Links'),
|
||||
editable_grid: 1,
|
||||
data: me.values ? JSON.parse(me.values.links) : [],
|
||||
get_data: () => {
|
||||
return me.values ? JSON.parse(me.values.links) : [];
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
fieldname: "label",
|
||||
fieldtype: "Data",
|
||||
in_list_view: 1,
|
||||
label: "Label"
|
||||
},
|
||||
{
|
||||
fieldname: "icon",
|
||||
fieldtype: "Data",
|
||||
label: "Icon"
|
||||
},
|
||||
{
|
||||
fieldname: "link_type",
|
||||
fieldtype: "Select",
|
||||
in_list_view: 1,
|
||||
label: "Link Type",
|
||||
options: ["DocType", "Page", "Report"],
|
||||
onchange: (e) => {
|
||||
me.link_to = e.currentTarget.value;
|
||||
}
|
||||
},
|
||||
{
|
||||
fieldname: "link_to",
|
||||
fieldtype: "Dynamic Link",
|
||||
in_list_view: 1,
|
||||
label: "Link To",
|
||||
options: "link_type",
|
||||
get_options: () => {
|
||||
return me.link_to;
|
||||
}
|
||||
},
|
||||
{
|
||||
fieldname: "column_break_7",
|
||||
fieldtype: "Column Break"
|
||||
},
|
||||
{
|
||||
fieldname: "dependencies",
|
||||
fieldtype: "Data",
|
||||
label: "Dependencies"
|
||||
},
|
||||
{
|
||||
fieldname: "only_for",
|
||||
fieldtype: "Link",
|
||||
label: "Only for ",
|
||||
options: "Country"
|
||||
},
|
||||
{
|
||||
default: "0",
|
||||
fieldname: "onboard",
|
||||
fieldtype: "Check",
|
||||
label: "Onboard"
|
||||
},
|
||||
{
|
||||
default: "0",
|
||||
fieldname: "is_query_report",
|
||||
fieldtype: "Check",
|
||||
label: "Is Query Report"
|
||||
}
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
process_data(data) {
|
||||
data.label = data.label ? data.label : data.chart_name;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
class ShortcutDialog extends WidgetDialog {
|
||||
constructor(opts) {
|
||||
super(opts);
|
||||
|
|
@ -438,6 +548,8 @@ export default function get_dialog_constructor(type) {
|
|||
chart: ChartDialog,
|
||||
shortcut: ShortcutDialog,
|
||||
number_card: NumberCardDialog,
|
||||
links: CardDialog,
|
||||
onboarding: OnboardingDialog
|
||||
};
|
||||
|
||||
return widget_map[type] || WidgetDialog;
|
||||
|
|
|
|||
|
|
@ -186,4 +186,51 @@ export default class WidgetGroup {
|
|||
}
|
||||
}
|
||||
|
||||
export class SingleWidgetGroup {
|
||||
constructor(opts) {
|
||||
Object.assign(this, opts);
|
||||
this.widgets_list = [];
|
||||
this.widgets_dict = {};
|
||||
this.widget_order = [];
|
||||
this.make();
|
||||
}
|
||||
|
||||
make() {
|
||||
this.add_widget(this.widgets);
|
||||
}
|
||||
|
||||
add_widget(widget) {
|
||||
let widget_object = frappe.widget.make_widget({
|
||||
...widget,
|
||||
widget_type: this.type,
|
||||
container: this.container,
|
||||
height: this.height || null,
|
||||
options: {
|
||||
...this.options,
|
||||
on_delete: () => this.on_delete(),
|
||||
on_edit: () => this.on_edit(widget_object)
|
||||
}
|
||||
});
|
||||
this.widgets_list.push(widget_object);
|
||||
this.widgets_dict[widget.name] = widget_object;
|
||||
|
||||
return widget_object;
|
||||
}
|
||||
|
||||
on_delete() {
|
||||
this.api.blocks.delete();
|
||||
}
|
||||
|
||||
on_edit(widget_object) {
|
||||
this.block.call("on_edit", widget_object);
|
||||
}
|
||||
|
||||
customize() {
|
||||
this.widgets_list.forEach((wid) => {
|
||||
wid.customize(this.options);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
frappe.widget.WidgetGroup = WidgetGroup;
|
||||
frappe.widget.SingleWidgetGroup = SingleWidgetGroup;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ body {
|
|||
.standard-sidebar-label {
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
|
||||
span {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.standard-sidebar-section {
|
||||
|
|
@ -128,19 +133,25 @@ body {
|
|||
.widget-head {
|
||||
@include flex(flex, space-between, center, null);
|
||||
|
||||
.widget-title {
|
||||
@include flex(flex, null, center, null);
|
||||
font-size: var(--text-lg);
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
line-height: 1.3em;
|
||||
color: var(--heading-color);
|
||||
.widget-label {
|
||||
min-width: 0px;
|
||||
|
||||
svg {
|
||||
margin-right: 6px;
|
||||
box-shadow: none;
|
||||
.widget-title {
|
||||
@include flex(flex, null, center, null);
|
||||
font-size: var(--text-lg);
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
line-height: 1.3em;
|
||||
color: var(--heading-color);
|
||||
|
||||
svg {
|
||||
flex: none;
|
||||
margin-right: 6px;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.widget-control {
|
||||
@include flex(flex, null, center, row-reverse);
|
||||
|
||||
|
|
@ -781,4 +792,267 @@ body {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.block-menu-item-icon svg{
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.standard-sidebar-item {
|
||||
justify-content: space-between;
|
||||
padding: 0px;
|
||||
|
||||
.sidebar-item-control {
|
||||
|
||||
> * {
|
||||
align-self: center;
|
||||
margin-left: 3px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: all-scroll;
|
||||
cursor: -webkit-grabbing;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.delete-page {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.drop-icon {
|
||||
padding: 10px 12px 10px 2px;
|
||||
}
|
||||
|
||||
.sidebar-info {
|
||||
display: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-item-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item-anchor {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
padding: 8px 0px 8px 12px;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-item-container {
|
||||
.sidebar-item-container{
|
||||
margin-left: 10px;
|
||||
|
||||
.standard-sidebar-item {
|
||||
justify-content: start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.standard-sidebar-section.show-control {
|
||||
.desk-sidebar-item.standard-sidebar-item {
|
||||
|
||||
&:hover, &.selected {
|
||||
.drag-handle {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.delete-page {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.sidebar-info {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.drop-icon {
|
||||
padding: 10px 8px 10px 2px;
|
||||
margin-left: -4px;
|
||||
}
|
||||
}
|
||||
|
||||
.block-click {
|
||||
pointer-events:none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.codex-editor {
|
||||
min-height: 630px;
|
||||
|
||||
.codex-editor__redactor{
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
margin: 0px -7px;
|
||||
padding-bottom: 20px !important;
|
||||
|
||||
.ce-block{
|
||||
width: 100%;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
|
||||
&.ce-block--selected {
|
||||
.ce-block__content {
|
||||
background-color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.ce-block__content {
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
padding: 7px;
|
||||
|
||||
&> div {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tune-btn > * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ce-header {
|
||||
padding: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.widget{
|
||||
&.header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
padding-left: 15px !important;
|
||||
padding-right: 15px !important;
|
||||
min-height: 50px;
|
||||
box-shadow: none;
|
||||
background-color: var(--control-bg);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&.new-widget {
|
||||
align-items: inherit;
|
||||
}
|
||||
|
||||
&.ce-paragraph {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.paragraph-control {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
gap: 5px;
|
||||
background-color: var(--card-bg);
|
||||
padding-left: 5px;
|
||||
|
||||
.drag-handle {
|
||||
cursor: all-scroll;
|
||||
cursor: -webkit-grabbing;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.ce-toolbar {
|
||||
svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.icon {
|
||||
stroke: none;
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.ce-settings {
|
||||
width: fit-content;
|
||||
|
||||
.ce-settings__button, .cdx-settings-button {
|
||||
color: #707684;
|
||||
}
|
||||
|
||||
.cdx-settings-button--active {
|
||||
color: #388ae5;
|
||||
}
|
||||
|
||||
.cdx-settings-button.disabled{
|
||||
pointer-events: none;
|
||||
opacity: .5
|
||||
}
|
||||
.cdx-settings-sidebar{
|
||||
position: absolute;
|
||||
right: 100%;
|
||||
top:0;
|
||||
background: #fff;
|
||||
width: 108px;
|
||||
height: 145px;
|
||||
box-shadow: 0 3px 15px -3px rgba(13,20,33,.13);
|
||||
border-radius: 0 4px 4px 0;z-index: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ce-toolbar__settings-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ce-inline-tool, .ce-inline-toolbar__dropdown {
|
||||
.icon {
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1199px) {
|
||||
.ce-toolbar__content {
|
||||
max-width: 930px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 995px) {
|
||||
.ce-toolbar__content {
|
||||
max-width: 760px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1199px) {
|
||||
.ce-block.col-4 {
|
||||
flex: 0 0 50%;
|
||||
max-width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 750px) {
|
||||
.ce-block.col-4 {
|
||||
flex: 0 0 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
@media (max-width: 750px) {
|
||||
.ce-block.col-6 {
|
||||
flex: 0 0 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,6 +126,12 @@ pre {
|
|||
}
|
||||
}
|
||||
|
||||
.hidden-xl {
|
||||
@include media-breakpoint-up(lg) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.visible-xs {
|
||||
@include media-breakpoint-up(sm) {
|
||||
display: none !important;
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
from frappe.query_builder.utils import get_query_builder
|
||||
from frappe.query_builder.utils import get_query_builder, patch_query_execute
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@ class Base:
|
|||
terms = terms
|
||||
desc = Order.desc
|
||||
Schema = Schema
|
||||
Table = Table
|
||||
|
||||
@staticmethod
|
||||
def Table(table_name: str, *args, **kwargs) -> Table:
|
||||
def DocType(table_name: str, *args, **kwargs) -> Table:
|
||||
table_name = get_table_name(table_name)
|
||||
return Table(table_name, *args, **kwargs)
|
||||
|
||||
|
|
@ -20,7 +21,7 @@ class MariaDB(Base, MySQLQuery):
|
|||
@classmethod
|
||||
def from_(cls, table, *args, **kwargs):
|
||||
if isinstance(table, str):
|
||||
table = cls.Table(table)
|
||||
table = cls.DocType(table)
|
||||
return super().from_(table, *args, **kwargs)
|
||||
|
||||
|
||||
|
|
@ -50,6 +51,6 @@ class Postgres(Base, PostgreSQLQuery):
|
|||
table = cls.schema_translation[table._table_name]
|
||||
|
||||
elif isinstance(table, str):
|
||||
table = cls.Table(table)
|
||||
table = cls.DocType(table)
|
||||
|
||||
return super().from_(table, *args, **kwargs)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from enum import Enum
|
||||
from typing import Any, Callable, Dict
|
||||
from typing import Any, Callable, Dict, get_type_hints
|
||||
from importlib import import_module
|
||||
|
||||
from pypika import Query
|
||||
|
||||
|
|
@ -19,6 +20,9 @@ class ImportMapper:
|
|||
db = db_type_is(frappe.conf.db_type or "mariadb")
|
||||
return self.func_map[db](*args, **kwds)
|
||||
|
||||
class BuilderIdentificationFailed(Exception):
|
||||
def __init__(self):
|
||||
super().__init__("Couldn't guess builder")
|
||||
|
||||
def get_query_builder(type_of_db: str) -> Query:
|
||||
"""[return the query builder object]
|
||||
|
|
@ -32,3 +36,25 @@ def get_query_builder(type_of_db: str) -> Query:
|
|||
db = db_type_is(type_of_db)
|
||||
picks = {db_type_is.MARIADB: MariaDB, db_type_is.POSTGRES: Postgres}
|
||||
return picks[db]
|
||||
|
||||
def get_attr(method_string):
|
||||
modulename = '.'.join(method_string.split('.')[:-1])
|
||||
methodname = method_string.split('.')[-1]
|
||||
return getattr(import_module(modulename), methodname)
|
||||
|
||||
def patch_query_execute():
|
||||
"""Patch the Query Builder with helper execute method
|
||||
This excludes the use of `frappe.db.sql` method while
|
||||
executing the query object
|
||||
"""
|
||||
|
||||
def execute_query(query, **kwargs):
|
||||
return frappe.db.sql(query, **kwargs)
|
||||
|
||||
query_class = get_attr(str(frappe.qb).split("'")[1])
|
||||
builder_class = get_type_hints(query_class._builder).get('return')
|
||||
|
||||
if not builder_class:
|
||||
raise BuilderIdentificationFailed
|
||||
|
||||
builder_class.run = execute_query
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ def slugs_with_web_view(_items_to_index):
|
|||
fields=["route", doctype.website_search_field]
|
||||
filters={doctype.is_published_field: 1},
|
||||
if doctype.website_search_field:
|
||||
docs = frappe.get_all(doctype.name, filters=filters, fields=fields.append("title"))
|
||||
docs = frappe.get_all(doctype.name, filters=filters, fields=fields + ["title"])
|
||||
for doc in docs:
|
||||
content = frappe.utils.md_to_html(getattr(doc, doctype.website_search_field))
|
||||
soup = BeautifulSoup(content, "html.parser")
|
||||
|
|
|
|||
|
|
@ -3,5 +3,5 @@
|
|||
<p><a class="btn btn-primary" href="{{ link }}">{{_("Reset your password")}}</a></p>
|
||||
<p>
|
||||
{{_("Thank you")}},<br>
|
||||
{{ user_fullname }}
|
||||
{{ created_by }}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -7,9 +7,6 @@
|
|||
<form>
|
||||
<fieldset>
|
||||
<div class="row" style="margin-bottom: 15px;">
|
||||
<div class="col-sm-6">
|
||||
<input class="form-control feedback_email" name="feedback_email" placeholder="{{ _("Your Email Address") }}" type="email">
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<div class="rating">
|
||||
{% for rating in [1, 2, 3, 4, 5 ,6, 7, 8, 9, 10] %}
|
||||
|
|
@ -41,7 +38,6 @@
|
|||
feedback && $("#submit-feedback").html(__("Update"));
|
||||
|
||||
if (frappe.is_user_logged_in()) {
|
||||
$(".feedback_email").parent().toggleClass("hidden");
|
||||
if (feedback) {
|
||||
$("[name='feedback']").val(feedback);
|
||||
toggle_feedback();
|
||||
|
|
@ -83,12 +79,12 @@
|
|||
|
||||
$('#submit-feedback').click((ev) => {
|
||||
let update = ev.target.innerText !== __("Submit");
|
||||
let rating = $('.rating').find('.rating-click').length;
|
||||
let args = {
|
||||
reference_doctype: "{{ reference_doctype or doctype }}",
|
||||
reference_name: "{{ reference_name or name }}",
|
||||
rating: rating,
|
||||
feedback: $("[name='feedback']").val(),
|
||||
feedback_email: $("[name='feedback_email']").val() || frappe.user_id
|
||||
feedback: $("[name='feedback']").val()
|
||||
}
|
||||
|
||||
if (args.rating == 0) {
|
||||
|
|
@ -101,16 +97,14 @@
|
|||
return false;
|
||||
}
|
||||
|
||||
if (args.feedback_email!=='Administrator' && !validate_email(args.feedback_email)) {
|
||||
frappe.msgprint("{{ _("Please enter a valid email address.") }}");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!update) {
|
||||
frappe.call({
|
||||
method: "frappe.templates.includes.feedback.feedback.add_feedback",
|
||||
args: args,
|
||||
callback: function(r) {
|
||||
if (!r.message) {
|
||||
return
|
||||
}
|
||||
toggle_feedback();
|
||||
if (!frappe.is_user_logged_in()) {
|
||||
$("[name='feedback']").val('');
|
||||
|
|
|
|||
|
|
@ -3,21 +3,33 @@
|
|||
from __future__ import unicode_literals
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_to_date, now
|
||||
|
||||
from frappe import _
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def add_feedback(reference_doctype, reference_name, rating, feedback, feedback_email):
|
||||
def add_feedback(reference_doctype, reference_name, rating, feedback):
|
||||
doc = frappe.get_doc(reference_doctype, reference_name)
|
||||
if doc.disable_feedback == 1:
|
||||
return
|
||||
|
||||
feedback_count = frappe.db.count("Feedback", {
|
||||
"reference_doctype": reference_doctype,
|
||||
"reference_name": reference_name,
|
||||
"ip_address": frappe.local.request_ip,
|
||||
"creation": (">", add_to_date(now(), hours=-1))
|
||||
})
|
||||
|
||||
if feedback_count > 20:
|
||||
frappe.msgprint(_('Hourly feedback limit reached'))
|
||||
return
|
||||
|
||||
doc = frappe.new_doc('Feedback')
|
||||
doc.reference_doctype = reference_doctype
|
||||
doc.reference_name = reference_name
|
||||
doc.rating = rating
|
||||
doc.feedback = feedback
|
||||
doc.email = feedback_email
|
||||
doc.ip_address = frappe.local.request_ip
|
||||
doc.save(ignore_permissions=True)
|
||||
|
||||
subject = _('New Feedback on {0}: {1}').format(reference_doctype, reference_name)
|
||||
|
|
@ -25,13 +37,13 @@ def add_feedback(reference_doctype, reference_name, rating, feedback, feedback_e
|
|||
return doc
|
||||
|
||||
@frappe.whitelist()
|
||||
def update_feedback(reference_doctype, reference_name, rating, feedback, feedback_email):
|
||||
def update_feedback(reference_doctype, reference_name, rating, feedback):
|
||||
doc = frappe.get_doc(reference_doctype, reference_name)
|
||||
if doc.disable_feedback == 1:
|
||||
return
|
||||
|
||||
filters = {
|
||||
"email": feedback_email,
|
||||
"owner": frappe.session.user,
|
||||
"reference_doctype": reference_doctype,
|
||||
"reference_name": reference_name
|
||||
}
|
||||
|
|
@ -49,7 +61,7 @@ def send_mail(feedback, subject):
|
|||
doc = frappe.get_doc(feedback.reference_doctype, feedback.reference_name)
|
||||
|
||||
message = ("<p>{0} ({1})</p>".format(feedback.feedback, feedback.rating)
|
||||
+ "<p><a href='{0}/app/marketing-asset-feedback/{1}' style='font-size: 80%'>{2}</a></p>".format(frappe.utils.get_request_site_address(),
|
||||
+ "<p><a href='{0}/app/feedback/{1}' style='font-size: 80%'>{2}</a></p>".format(frappe.utils.get_request_site_address(),
|
||||
feedback.name,
|
||||
_("View Feedback")))
|
||||
|
||||
|
|
|
|||
|
|
@ -38,9 +38,16 @@ class TestCustomFunctionsPostgres(unittest.TestCase):
|
|||
|
||||
class TestBuilderBase(object):
|
||||
def test_adding_tabs(self):
|
||||
self.assertEqual("tabNotes", frappe.qb.Table("Notes").get_sql())
|
||||
self.assertEqual("__Auth", frappe.qb.Table("__Auth").get_sql())
|
||||
self.assertEqual("tabNotes", frappe.qb.DocType("Notes").get_sql())
|
||||
self.assertEqual("__Auth", frappe.qb.DocType("__Auth").get_sql())
|
||||
self.assertEqual("Notes", frappe.qb.Table("Notes").get_sql())
|
||||
|
||||
def test_run_patcher(self):
|
||||
query = frappe.qb.from_("ToDo").select("*").limit(1)
|
||||
data = query.run(as_dict=True)
|
||||
self.assertTrue("run" in dir(query))
|
||||
self.assertIsInstance(query.run, Callable)
|
||||
self.assertIsInstance(data, list)
|
||||
|
||||
@run_only_if(db_type_is.MARIADB)
|
||||
class TestBuilderMaria(unittest.TestCase, TestBuilderBase):
|
||||
|
|
|
|||
|
|
@ -280,6 +280,16 @@ class TestWebsite(unittest.TestCase):
|
|||
|
||||
frappe.flags.force_website_cache = False
|
||||
|
||||
def test_safe_render(self):
|
||||
content = get_response_content('/_test/_test_safe_render_on')
|
||||
self.assertNotIn("Safe Render On", content)
|
||||
self.assertIn("frappe.exceptions.ValidationError: Illegal template", content)
|
||||
|
||||
content = get_response_content('/_test/_test_safe_render_off')
|
||||
self.assertIn("Safe Render Off", content)
|
||||
self.assertIn("test.__test", content)
|
||||
self.assertNotIn("frappe.exceptions.ValidationError: Illegal template", content)
|
||||
|
||||
|
||||
def set_home_page_hook(key, value):
|
||||
from frappe import hooks
|
||||
|
|
|
|||
|
|
@ -442,10 +442,10 @@ def search(text, start=0, limit=20, doctype=""):
|
|||
elif allowed_doctypes:
|
||||
query = query.where(global_search.doctype.isin(allowed_doctypes))
|
||||
|
||||
if start > 0:
|
||||
if cint(start) > 0:
|
||||
query = query.offset(start)
|
||||
|
||||
result = frappe.db.sql(query, as_dict=True)
|
||||
result = query.run(as_dict=True)
|
||||
|
||||
results.extend(result)
|
||||
|
||||
|
|
|
|||
|
|
@ -147,12 +147,15 @@ class BlogPost(WebsiteGenerator):
|
|||
context.comment_text = _('{0} comments').format(len(context.comment_list))
|
||||
|
||||
def load_feedback(self, context):
|
||||
user = frappe.session.user
|
||||
if user == 'Guest':
|
||||
user = ''
|
||||
feedback = frappe.get_all('Feedback',
|
||||
fields=['email', 'feedback', 'rating'],
|
||||
fields=['feedback', 'rating'],
|
||||
filters=dict(
|
||||
reference_doctype=self.doctype,
|
||||
reference_name=self.name,
|
||||
email=frappe.session.user
|
||||
owner=user
|
||||
)
|
||||
)
|
||||
context.user_feedback = feedback[0] if feedback else ''
|
||||
|
|
|
|||
|
|
@ -204,7 +204,12 @@ class TemplatePage(BaseTemplatePage):
|
|||
if self.template_path.endswith('min.js'):
|
||||
html = self.source # static
|
||||
else:
|
||||
html = frappe.render_template(self.source, self.context)
|
||||
if self.context.safe_render is not None:
|
||||
safe_render = self.context.safe_render
|
||||
else:
|
||||
safe_render = True
|
||||
|
||||
html = frappe.render_template(self.source, self.context, safe_render=safe_render)
|
||||
|
||||
return html
|
||||
|
||||
|
|
|
|||
|
|
@ -59,12 +59,12 @@ class WebsiteAnalytics(object):
|
|||
]
|
||||
|
||||
def get_data(self):
|
||||
WebPageView = frappe.qb.Table("Web Page View")
|
||||
WebPageView = frappe.qb.DocType("Web Page View")
|
||||
count_all = Count("*").as_("count")
|
||||
case = frappe.qb.terms.Case().when(WebPageView.is_unique == "1", "1")
|
||||
count_is_unique = Count(case).as_("unique_count")
|
||||
|
||||
query = (
|
||||
return (
|
||||
frappe.qb.from_(WebPageView)
|
||||
.select("path", count_all, count_is_unique)
|
||||
.where(
|
||||
|
|
@ -72,8 +72,7 @@ class WebsiteAnalytics(object):
|
|||
)
|
||||
.groupby(WebPageView.path)
|
||||
.orderby("count", Order=frappe.qb.desc)
|
||||
)
|
||||
return frappe.db.sql(query)
|
||||
).run()
|
||||
|
||||
def _get_query_for_mariadb(self):
|
||||
filters_range = self.filters.range
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import frappe
|
||||
|
||||
METATAGS = ('title', 'description', 'image', 'author', 'published_on')
|
||||
|
||||
class MetaTags():
|
||||
def __init__(self, path, context):
|
||||
self.path = path
|
||||
|
|
@ -12,7 +14,7 @@ class MetaTags():
|
|||
self.set_metatags_from_website_route_meta()
|
||||
|
||||
def init_metatags_from_context(self):
|
||||
for key in ('title', 'description', 'image', 'author', 'url', 'published_on'):
|
||||
for key in METATAGS:
|
||||
if key not in self.tags and self.context.get(key):
|
||||
self.tags[key] = self.context[key]
|
||||
|
||||
|
|
@ -28,12 +30,12 @@ class MetaTags():
|
|||
if "og:type" not in self.tags:
|
||||
self.tags["og:type"] = "article"
|
||||
|
||||
for key in ('title', 'description', 'image', 'author', 'url'):
|
||||
for key in METATAGS:
|
||||
if self.tags.get(key):
|
||||
self.tags['og:' + key] = self.tags.get(key)
|
||||
|
||||
def set_twitter_tags(self):
|
||||
for key in ('title', 'description', 'image', 'author', 'url'):
|
||||
for key in METATAGS:
|
||||
if self.tags.get(key):
|
||||
self.tags['twitter:' + key] = self.tags.get(key)
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue