diff --git a/.github/helper/install.sh b/.github/helper/install.sh index 6c81d6298a..454cc89694 100644 --- a/.github/helper/install.sh +++ b/.github/helper/install.sh @@ -50,6 +50,7 @@ if [ "$TYPE" == "server" ]; then sed -i 's/^socketio:/# socketio:/g' Procfile; f if [ "$TYPE" == "server" ]; then sed -i 's/^redis_socketio:/# redis_socketio:/g' Procfile; fi if [ "$TYPE" == "ui" ]; then bench setup requirements --node; fi +if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi # install node-sass which is required for website theme test cd ./apps/frappe || exit diff --git a/.github/helper/semgrep_rules/README.md b/.github/helper/semgrep_rules/README.md deleted file mode 100644 index 670d8d280f..0000000000 --- a/.github/helper/semgrep_rules/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# Semgrep linting - -## What is semgrep? -Semgrep or "semantic grep" is language agnostic static analysis tool. In simple terms semgrep is syntax-aware `grep`, so unlike regex it doesn't get confused by different ways of writing same thing or whitespaces or code split in multiple lines etc. - -Example: - -To check if a translate function is using f-string or not the regex would be `r"_\(\s*f[\"']"` while equivalent rule in semgrep would be `_(f"...")`. As semgrep knows grammer of language it takes care of unnecessary whitespace, type of quotation marks etc. - -You can read more such examples in `.github/helper/semgrep_rules` directory. - -# Why/when to use this? -We want to maintain quality of contributions, at the same time remembering all the good practices can be pain to deal with while evaluating contributions. Using semgrep if you can translate "best practice" into a rule then it can automate the task for us. - -## Running locally - -Install semgrep using homebrew `brew install semgrep` or pip `pip install semgrep`. - -To run locally use following command: - -`semgrep --config=.github/helper/semgrep_rules [file/folder names]` - -## Testing -semgrep allows testing the tests. Refer to this page: https://semgrep.dev/docs/writing-rules/testing-rules/ - -When writing new rules you should write few positive and few negative cases as shown in the guide and current tests. - -To run current tests: `semgrep --test --test-ignore-todo .github/helper/semgrep_rules` - - -## Reference - -If you are new to Semgrep read following pages to get started on writing/modifying rules: - -- https://semgrep.dev/docs/getting-started/ -- https://semgrep.dev/docs/writing-rules/rule-syntax -- https://semgrep.dev/docs/writing-rules/pattern-examples/ -- https://semgrep.dev/docs/writing-rules/rule-ideas/#common-use-cases diff --git a/.github/helper/semgrep_rules/frappe_correctness.py b/.github/helper/semgrep_rules/frappe_correctness.py deleted file mode 100644 index 745e6463b8..0000000000 --- a/.github/helper/semgrep_rules/frappe_correctness.py +++ /dev/null @@ -1,64 +0,0 @@ -import frappe -from frappe import _, flt - -from frappe.model.document import Document - - -# ruleid: frappe-modifying-but-not-comitting -def on_submit(self): - if self.value_of_goods == 0: - frappe.throw(_('Value of goods cannot be 0')) - self.status = 'Submitted' - - -# ok: frappe-modifying-but-not-comitting -def on_submit(self): - if self.value_of_goods == 0: - frappe.throw(_('Value of goods cannot be 0')) - self.status = 'Submitted' - self.db_set('status', 'Submitted') - -# ok: frappe-modifying-but-not-comitting -def on_submit(self): - if self.value_of_goods == 0: - frappe.throw(_('Value of goods cannot be 0')) - x = "y" - self.status = x - self.db_set('status', x) - - -# ok: frappe-modifying-but-not-comitting -def on_submit(self): - x = "y" - self.status = x - self.save() - -# ruleid: frappe-modifying-but-not-comitting-other-method -class DoctypeClass(Document): - def on_submit(self): - self.good_method() - self.tainted_method() - - def tainted_method(self): - self.status = "uptate" - - -# ok: frappe-modifying-but-not-comitting-other-method -class DoctypeClass(Document): - def on_submit(self): - self.good_method() - self.tainted_method() - - def tainted_method(self): - self.status = "update" - self.db_set("status", "update") - -# ok: frappe-modifying-but-not-comitting-other-method -class DoctypeClass(Document): - def on_submit(self): - self.good_method() - self.tainted_method() - self.save() - - def tainted_method(self): - self.status = "uptate" diff --git a/.github/helper/semgrep_rules/frappe_correctness.yml b/.github/helper/semgrep_rules/frappe_correctness.yml deleted file mode 100644 index 33a22fba6a..0000000000 --- a/.github/helper/semgrep_rules/frappe_correctness.yml +++ /dev/null @@ -1,146 +0,0 @@ -# This file specifies rules for correctness according to how frappe doctype data model works. - -rules: -- id: frappe-modifying-but-not-comitting - patterns: - - pattern: | - def $METHOD(self, ...): - ... - self.$ATTR = ... - - pattern-not: | - def $METHOD(self, ...): - ... - self.$ATTR = ... - ... - self.db_set(..., self.$ATTR, ...) - - pattern-not: | - def $METHOD(self, ...): - ... - self.$ATTR = $SOME_VAR - ... - self.db_set(..., $SOME_VAR, ...) - - pattern-not: | - def $METHOD(self, ...): - ... - self.$ATTR = $SOME_VAR - ... - self.save() - - metavariable-regex: - metavariable: '$ATTR' - # this is negative look-ahead, add more attrs to ignore like (ignore|ignore_this_too|ignore_me) - regex: '^(?!ignore_linked_doctypes|status_updater)(.*)$' - - metavariable-regex: - metavariable: "$METHOD" - regex: "(on_submit|on_cancel)" - message: | - DocType modified in self.$METHOD. Please check if modification of self.$ATTR is commited to database. - languages: [python] - severity: ERROR - -- id: frappe-modifying-but-not-comitting-other-method - patterns: - - pattern: | - class $DOCTYPE(...): - def $METHOD(self, ...): - ... - self.$ANOTHER_METHOD() - ... - - def $ANOTHER_METHOD(self, ...): - ... - self.$ATTR = ... - - pattern-not: | - class $DOCTYPE(...): - def $METHOD(self, ...): - ... - self.$ANOTHER_METHOD() - ... - - def $ANOTHER_METHOD(self, ...): - ... - self.$ATTR = ... - ... - self.db_set(..., self.$ATTR, ...) - - pattern-not: | - class $DOCTYPE(...): - def $METHOD(self, ...): - ... - self.$ANOTHER_METHOD() - ... - - def $ANOTHER_METHOD(self, ...): - ... - self.$ATTR = $SOME_VAR - ... - self.db_set(..., $SOME_VAR, ...) - - pattern-not: | - class $DOCTYPE(...): - def $METHOD(self, ...): - ... - self.$ANOTHER_METHOD() - ... - self.save() - def $ANOTHER_METHOD(self, ...): - ... - self.$ATTR = ... - - metavariable-regex: - metavariable: "$METHOD" - regex: "(on_submit|on_cancel)" - message: | - self.$ANOTHER_METHOD is called from self.$METHOD, check if changes to self.$ATTR are commited to database. - languages: [python] - severity: ERROR - -- id: frappe-print-function-in-doctypes - pattern: print(...) - message: | - Did you mean to leave this print statement in? Consider using msgprint or logger instead of print statement. - languages: [python] - severity: WARNING - paths: - include: - - "*/**/doctype/*" - -- id: frappe-modifying-child-tables-while-iterating - pattern-either: - - pattern: | - for $ROW in self.$TABLE: - ... - self.remove(...) - - pattern: | - for $ROW in self.$TABLE: - ... - self.append(...) - message: | - Child table being modified while iterating on it. - languages: [python] - severity: ERROR - paths: - include: - - "*/**/doctype/*" - -- id: frappe-same-key-assigned-twice - pattern-either: - - pattern: | - {..., $X: $A, ..., $X: $B, ...} - - pattern: | - dict(..., ($X, $A), ..., ($X, $B), ...) - - pattern: | - _dict(..., ($X, $A), ..., ($X, $B), ...) - message: | - key `$X` is uselessly assigned twice. This could be a potential bug. - languages: [python] - severity: ERROR - -- id: frappe-using-db-sql - pattern-either: - - pattern: frappe.db.sql(...) - - pattern: frappe.db.sql_ddl(...) - - pattern: frappe.db.sql_list(...) - paths: - exclude: - - "test_*.py" - message: | - The PR contains a SQL query that may be re-written with frappe.qb (https://frappeframework.com/docs/user/en/api/query-builder) or the Database API (https://frappeframework.com/docs/user/en/api/database) - languages: [python] - severity: ERROR diff --git a/.github/helper/semgrep_rules/security.py b/.github/helper/semgrep_rules/security.py deleted file mode 100644 index f477d7c176..0000000000 --- a/.github/helper/semgrep_rules/security.py +++ /dev/null @@ -1,6 +0,0 @@ -def function_name(input): - # ruleid: frappe-codeinjection-eval - eval(input) - -# ok: frappe-codeinjection-eval -eval("1 + 1") diff --git a/.github/helper/semgrep_rules/security.yml b/.github/helper/semgrep_rules/security.yml deleted file mode 100644 index 5a5098bf50..0000000000 --- a/.github/helper/semgrep_rules/security.yml +++ /dev/null @@ -1,25 +0,0 @@ -rules: -- id: frappe-codeinjection-eval - patterns: - - pattern-not: eval("...") - - pattern: eval(...) - message: | - Detected the use of eval(). eval() can be dangerous if used to evaluate - dynamic content. Avoid it or use safe_eval(). - languages: [python] - severity: ERROR - -- id: frappe-sqli-format-strings - patterns: - - pattern-inside: | - @frappe.whitelist() - def $FUNC(...): - ... - - pattern-either: - - pattern: frappe.db.sql("..." % ...) - - pattern: frappe.db.sql(f"...", ...) - - pattern: frappe.db.sql("...".format(...), ...) - message: | - Detected use of raw string formatting for SQL queries. This can lead to sql injection vulnerabilities. Refer security guidelines - https://github.com/frappe/erpnext/wiki/Code-Security-Guidelines - languages: [python] - severity: WARNING diff --git a/.github/helper/semgrep_rules/translate.js b/.github/helper/semgrep_rules/translate.js deleted file mode 100644 index 9cdfb75d0b..0000000000 --- a/.github/helper/semgrep_rules/translate.js +++ /dev/null @@ -1,44 +0,0 @@ -// ruleid: frappe-translation-empty-string -__("") -// ruleid: frappe-translation-empty-string -__('') - -// ok: frappe-translation-js-formatting -__('Welcome {0}, get started with ERPNext in just a few clicks.', [full_name]); - -// ruleid: frappe-translation-js-formatting -__(`Welcome ${full_name}, get started with ERPNext in just a few clicks.`); - -// ok: frappe-translation-js-formatting -__('This is fine'); - - -// ok: frappe-translation-trailing-spaces -__('This is fine'); - -// ruleid: frappe-translation-trailing-spaces -__(' this is not ok '); -// ruleid: frappe-translation-trailing-spaces -__('this is not ok '); -// ruleid: frappe-translation-trailing-spaces -__(' this is not ok'); - -// ok: frappe-translation-js-splitting -__('You have {0} subscribers in your mailing list.', [subscribers.length]) - -// todoruleid: frappe-translation-js-splitting -__('You have') + subscribers.length + __('subscribers in your mailing list.') - -// ruleid: frappe-translation-js-splitting -__('You have' + 'subscribers in your mailing list.') - -// ruleid: frappe-translation-js-splitting -__('You have {0} subscribers' + - 'in your mailing list', [subscribers.length]) - -// ok: frappe-translation-js-splitting -__("Ctrl+Enter to add comment") - -// ruleid: frappe-translation-js-splitting -__('You have {0} subscribers \ - in your mailing list', [subscribers.length]) diff --git a/.github/helper/semgrep_rules/translate.py b/.github/helper/semgrep_rules/translate.py deleted file mode 100644 index 9de6aa94f0..0000000000 --- a/.github/helper/semgrep_rules/translate.py +++ /dev/null @@ -1,61 +0,0 @@ -# Examples taken from https://frappeframework.com/docs/user/en/translations -# This file is used for testing the tests. - -from frappe import _ - -full_name = "Jon Doe" -# ok: frappe-translation-python-formatting -_('Welcome {0}, get started with ERPNext in just a few clicks.').format(full_name) - -# ruleid: frappe-translation-python-formatting -_('Welcome %s, get started with ERPNext in just a few clicks.' % full_name) -# ruleid: frappe-translation-python-formatting -_('Welcome %(name)s, get started with ERPNext in just a few clicks.' % {'name': full_name}) - -# ruleid: frappe-translation-python-formatting -_('Welcome {0}, get started with ERPNext in just a few clicks.'.format(full_name)) - - -subscribers = ["Jon", "Doe"] -# ok: frappe-translation-python-formatting -_('You have {0} subscribers in your mailing list.').format(len(subscribers)) - -# ruleid: frappe-translation-python-splitting -_('You have') + len(subscribers) + _('subscribers in your mailing list.') - -# ruleid: frappe-translation-python-splitting -_('You have {0} subscribers \ - in your mailing list').format(len(subscribers)) - -# ok: frappe-translation-python-splitting -_('You have {0} subscribers') \ - + 'in your mailing list' - -# ruleid: frappe-translation-trailing-spaces -msg = _(" You have {0} pending invoice ") -# ruleid: frappe-translation-trailing-spaces -msg = _("You have {0} pending invoice ") -# ruleid: frappe-translation-trailing-spaces -msg = _(" You have {0} pending invoice") - -# ok: frappe-translation-trailing-spaces -msg = ' ' + _("You have {0} pending invoices") + ' ' - -# ruleid: frappe-translation-python-formatting -_(f"can not format like this - {subscribers}") -# ruleid: frappe-translation-python-splitting -_(f"what" + f"this is also not cool") - - -# ruleid: frappe-translation-empty-string -_("") -# ruleid: frappe-translation-empty-string -_('') - - -class Test: - # ok: frappe-translation-python-splitting - def __init__( - args - ): - pass diff --git a/.github/helper/semgrep_rules/translate.yml b/.github/helper/semgrep_rules/translate.yml deleted file mode 100644 index 5f03fb9fd0..0000000000 --- a/.github/helper/semgrep_rules/translate.yml +++ /dev/null @@ -1,64 +0,0 @@ -rules: -- id: frappe-translation-empty-string - pattern-either: - - pattern: _("") - - pattern: __("") - message: | - Empty string is useless for translation. - Please refer: https://frappeframework.com/docs/user/en/translations - languages: [python, javascript, json] - severity: ERROR - -- id: frappe-translation-trailing-spaces - pattern-either: - - pattern: _("=~/(^[ \t]+|[ \t]+$)/") - - pattern: __("=~/(^[ \t]+|[ \t]+$)/") - message: | - Trailing or leading whitespace not allowed in translate strings. - Please refer: https://frappeframework.com/docs/user/en/translations - languages: [python, javascript, json] - severity: ERROR - -- id: frappe-translation-python-formatting - pattern-either: - - pattern: _("..." % ...) - - pattern: _("...".format(...)) - - pattern: _(f"...") - message: | - Only positional formatters are allowed and formatting should not be done before translating. - Please refer: https://frappeframework.com/docs/user/en/translations - languages: [python] - severity: ERROR - -- id: frappe-translation-js-formatting - patterns: - - pattern: __(`...`) - - pattern-not: __("...") - message: | - Template strings are not allowed for text formatting. - Please refer: https://frappeframework.com/docs/user/en/translations - languages: [javascript, json] - severity: ERROR - -- id: frappe-translation-python-splitting - pattern-either: - - pattern: _(...) + _(...) - - pattern: _("..." + "...") - - pattern-regex: '[\s\.]_\([^\)]*\\\s*' # lines broken by `\` - - pattern-regex: '[\s\.]_\(\s*\n' # line breaks allowed by python for using ( ) - message: | - Do not split strings inside translate function. Do not concatenate using translate functions. - Please refer: https://frappeframework.com/docs/user/en/translations - languages: [python] - severity: ERROR - -- id: frappe-translation-js-splitting - pattern-either: - - pattern-regex: '__\([^\)]*[\\]\s+' - - pattern: __('...' + '...', ...) - - pattern: __('...') + __('...') - message: | - Do not split strings inside translate function. Do not concatenate using translate functions. - Please refer: https://frappeframework.com/docs/user/en/translations - languages: [javascript, json] - severity: ERROR diff --git a/.github/helper/semgrep_rules/ux.js b/.github/helper/semgrep_rules/ux.js deleted file mode 100644 index ae73f9cc60..0000000000 --- a/.github/helper/semgrep_rules/ux.js +++ /dev/null @@ -1,9 +0,0 @@ - -// ok: frappe-missing-translate-function-js -frappe.msgprint('{{ _("Both login and password required") }}'); - -// ruleid: frappe-missing-translate-function-js -frappe.msgprint('What'); - -// ok: frappe-missing-translate-function-js -frappe.throw(' {{ _("Both login and password required") }}. '); diff --git a/.github/helper/semgrep_rules/ux.py b/.github/helper/semgrep_rules/ux.py deleted file mode 100644 index a00d3cd8ae..0000000000 --- a/.github/helper/semgrep_rules/ux.py +++ /dev/null @@ -1,31 +0,0 @@ -import frappe -from frappe import msgprint, throw, _ - - -# ruleid: frappe-missing-translate-function-python -throw("Error Occured") - -# ruleid: frappe-missing-translate-function-python -frappe.throw("Error Occured") - -# ruleid: frappe-missing-translate-function-python -frappe.msgprint("Useful message") - -# ruleid: frappe-missing-translate-function-python -msgprint("Useful message") - - -# ok: frappe-missing-translate-function-python -translatedmessage = _("Hello") - -# ok: frappe-missing-translate-function-python -throw(translatedmessage) - -# ok: frappe-missing-translate-function-python -msgprint(translatedmessage) - -# ok: frappe-missing-translate-function-python -msgprint(_("Helpful message")) - -# ok: frappe-missing-translate-function-python -frappe.throw(_("Error occured")) diff --git a/.github/helper/semgrep_rules/ux.yml b/.github/helper/semgrep_rules/ux.yml deleted file mode 100644 index dd667f36c0..0000000000 --- a/.github/helper/semgrep_rules/ux.yml +++ /dev/null @@ -1,30 +0,0 @@ -rules: -- id: frappe-missing-translate-function-python - pattern-either: - - patterns: - - pattern: frappe.msgprint("...", ...) - - pattern-not: frappe.msgprint(_("..."), ...) - - patterns: - - pattern: frappe.throw("...", ...) - - pattern-not: frappe.throw(_("..."), ...) - message: | - All user facing text must be wrapped in translate function. Please refer to translation documentation. https://frappeframework.com/docs/user/en/guides/basics/translations - languages: [python] - severity: ERROR - -- id: frappe-missing-translate-function-js - pattern-either: - - patterns: - - pattern: frappe.msgprint("...", ...) - - pattern-not: frappe.msgprint(__("..."), ...) - # ignore microtemplating e.g. msgprint("{{ _("server side translation") }}") - - pattern-not: frappe.msgprint("=~/\{\{.*\_.*\}\}/i", ...) - - patterns: - - pattern: frappe.throw("...", ...) - - pattern-not: frappe.throw(__("..."), ...) - # ignore microtemplating - - pattern-not: frappe.throw("=~/\{\{.*\_.*\}\}/i", ...) - message: | - All user facing text must be wrapped in translate function. Please refer to translation documentation. https://frappeframework.com/docs/user/en/guides/basics/translations - languages: [javascript] - severity: ERROR diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml index e27b406df0..325411cf5c 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/semgrep.yml @@ -9,10 +9,14 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 + + - name: Download Semgrep rules + run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules + - uses: returntocorp/semgrep-action@v1 env: SEMGREP_TIMEOUT: 120 with: config: >- r/python.lang.correctness - .github/helper/semgrep_rules + ./frappe-semgrep-rules/rules diff --git a/cypress/integration/awesome_bar.js b/cypress/integration/awesome_bar.js index fb09b384a8..8e503cce46 100644 --- a/cypress/integration/awesome_bar.js +++ b/cypress/integration/awesome_bar.js @@ -7,12 +7,13 @@ context('Awesome Bar', () => { beforeEach(() => { cy.get('.navbar .navbar-home').click(); + cy.findByPlaceholderText('Search or type a command (Ctrl + G)').clear(); }); it('navigates to doctype list', () => { - cy.findByPlaceholderText('Search or type a command (Ctrl + G)').type('todo', { delay: 200 }); + cy.findByPlaceholderText('Search or type a command (Ctrl + G)').type('todo', { delay: 700 }); cy.get('.awesomplete').findByRole('listbox').should('be.visible'); - cy.findByPlaceholderText('Search or type a command (Ctrl + G)').type('{downarrow}{enter}', { delay: 100 }); + cy.findByPlaceholderText('Search or type a command (Ctrl + G)').type('{downarrow}{enter}', { delay: 700 }); cy.get('.title-text').should('contain', 'To Do'); @@ -21,7 +22,7 @@ context('Awesome Bar', () => { it('find text in doctype list', () => { cy.findByPlaceholderText('Search or type a command (Ctrl + G)') - .type('test in todo{downarrow}{enter}', { delay: 200 }); + .type('test in todo{downarrow}{enter}', { delay: 700 }); cy.get('.title-text').should('contain', 'To Do'); @@ -31,14 +32,14 @@ context('Awesome Bar', () => { it('navigates to new form', () => { cy.findByPlaceholderText('Search or type a command (Ctrl + G)') - .type('new blog post{downarrow}{enter}', { delay: 200 }); + .type('new blog post{downarrow}{enter}', { delay: 700 }); cy.get('.title-text:visible').should('have.text', 'New Blog Post'); }); it('calculates math expressions', () => { cy.findByPlaceholderText('Search or type a command (Ctrl + G)') - .type('55 + 32{downarrow}{enter}', { delay: 200 }); + .type('55 + 32{downarrow}{enter}', { delay: 700 }); cy.get('.modal-title').should('contain', 'Result'); cy.get('.msgprint').should('contain', '55 + 32 = 87'); diff --git a/cypress/integration/control_link.js b/cypress/integration/control_link.js index 7d44a71d06..2a81338c59 100644 --- a/cypress/integration/control_link.js +++ b/cypress/integration/control_link.js @@ -49,19 +49,19 @@ context('Control Link', () => { it('should unset invalid value', () => { get_dialog_with_link().as('dialog'); - cy.intercept('GET', '/api/method/frappe.desk.form.utils.validate_link*').as('validate_link'); + cy.intercept('GET', '/api/method/frappe.client.get_value*').as('get_value'); cy.get('.frappe-control[data-fieldname=link] input') .type('invalid value', { delay: 100 }) .blur(); - cy.wait('@validate_link'); + cy.wait('@get_value'); cy.get('.frappe-control[data-fieldname=link] input').should('have.value', ''); }); it('should route to form on arrow click', () => { get_dialog_with_link().as('dialog'); - cy.intercept('GET', '/api/method/frappe.desk.form.utils.validate_link*').as('validate_link'); + cy.intercept('GET', '/api/method/frappe.client.get_value*').as('get_value'); cy.intercept('POST', '/api/method/frappe.desk.search.search_link').as('search_link'); cy.get('@todos').then(todos => { @@ -69,7 +69,7 @@ context('Control Link', () => { cy.get('@input').focus(); cy.wait('@search_link'); cy.get('@input').type(todos[0]).blur(); - cy.wait('@validate_link'); + cy.wait('@get_value'); cy.get('@input').focus(); cy.findByTitle('Open Link') .should('be.visible') @@ -77,4 +77,19 @@ context('Control Link', () => { cy.location('pathname').should('eq', `/app/todo/${todos[0]}`); }); }); + + it('should fetch valid value', () => { + cy.get('@todos').then(todos => { + cy.visit(`/app/todo/${todos[0]}`); + cy.intercept('GET', '/api/method/frappe.client.get_value*').as('get_value'); + + cy.get('.frappe-control[data-fieldname=assigned_by] input').focus().as('input'); + cy.get('@input').type('Administrator', {delay: 100}).blur(); + cy.wait('@get_value'); + cy.get('.frappe-control[data-fieldname=assigned_by_full_name] .control-value').should( + 'contain', 'Administrator' + ); + }); + }); + }); diff --git a/cypress/integration/recorder.js b/cypress/integration/recorder.js index 7a62b2e6d9..caf1349e6e 100644 --- a/cypress/integration/recorder.js +++ b/cypress/integration/recorder.js @@ -13,13 +13,6 @@ context('Recorder', () => { }); }); - it('Navigate to Recorder', () => { - cy.visit('/app'); - cy.awesomebar('recorder'); - cy.findByTitle('Recorder').should('exist'); - cy.url().should('include', '/recorder/detail'); - }); - it('Recorder Empty State', () => { cy.findByTitle('Recorder').should('exist'); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 6484370946..64a3b18b2f 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -241,7 +241,7 @@ Cypress.Commands.add('get_table_field', (tablefieldname, row_idx, fieldname, fie }); Cypress.Commands.add('awesomebar', text => { - cy.get('#navbar-search').type(`${text}{downarrow}{enter}`, {delay: 100}); + cy.get('#navbar-search').type(`${text}{downarrow}{enter}`, {delay: 700}); }); Cypress.Commands.add('new_form', doctype => { @@ -354,4 +354,4 @@ Cypress.Commands.add('click_listview_primary_button', (btn_name) => { Cypress.Commands.add('click_timeline_action_btn', (btn_name) => { cy.get('.timeline-message-box .custom-actions > .btn').contains(btn_name).click(); -}); \ No newline at end of file +}); diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000000..df3ae9484a --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,3 @@ +Faker~=8.1.0 +pyngrok~=5.0.5 +unittest-xml-reporting~=3.0.4 diff --git a/esbuild/esbuild.js b/esbuild/esbuild.js index af2ffd3fc5..18de95b40d 100644 --- a/esbuild/esbuild.js +++ b/esbuild/esbuild.js @@ -46,7 +46,7 @@ let argv = yargs }) .option("live-reload", { type: "boolean", - description: `Automatically reload web pages when assets are rebuilt. + description: `Automatically reload Desk when assets are rebuilt. Can only be used with the --watch flag.` }) .option("production", { @@ -288,10 +288,24 @@ function get_watch_config() { assets_json, prev_assets_json } = await write_assets_json(result.metafile); + + let changed_files; if (prev_assets_json) { - log_rebuilt_assets(prev_assets_json, assets_json); + changed_files = get_rebuilt_assets( + prev_assets_json, + assets_json + ); + + let timestamp = new Date().toLocaleTimeString(); + let message = `${timestamp}: Compiled ${changed_files.length} files...`; + log(chalk.yellow(message)); + for (let filepath of changed_files) { + let filename = path.basename(filepath); + log(" " + filename); + } + log(); } - notify_redis({ success: true }); + notify_redis({ success: true, changed_files }); } } }; @@ -461,7 +475,7 @@ function run_build_command_for_apps(apps) { process.chdir(cwd); } -async function notify_redis({ error, success }) { +async function notify_redis({ error, success, changed_files }) { // notify redis which in turns tells socketio to publish this to browser let subscriber = get_redis_subscriber("redis_socketio"); subscriber.on("error", _ => { @@ -484,6 +498,7 @@ async function notify_redis({ error, success }) { if (success) { payload = { success: true, + changed_files, live_reload: argv["live-reload"] }; } @@ -514,7 +529,7 @@ function open_in_editor() { subscriber.subscribe("open_in_editor"); } -function log_rebuilt_assets(prev_assets, new_assets) { +function get_rebuilt_assets(prev_assets, new_assets) { let added_files = []; let old_files = Object.values(prev_assets); let new_files = Object.values(new_assets); @@ -524,17 +539,5 @@ function log_rebuilt_assets(prev_assets, new_assets) { added_files.push(filepath); } } - - log( - chalk.yellow( - `${new Date().toLocaleTimeString()}: Compiled ${ - added_files.length - } files...` - ) - ); - for (let filepath of added_files) { - let filename = path.basename(filepath); - log(" " + filename); - } - log(); + return added_files; } diff --git a/frappe/__init__.py b/frappe/__init__.py index 1b4429d55b..c8245b0bf0 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -30,9 +30,6 @@ from .utils.lazy_loader import lazy_import from frappe.query_builder import get_query_builder, patch_query_execute -# Lazy imports -faker = lazy_import('faker') - __version__ = '14.0.0-dev' __title__ = "Frappe Framework" @@ -1838,6 +1835,7 @@ def parse_json(val): return parse_json(val) def mock(type, size=1, locale='en'): + import faker results = [] fake = faker.Faker(locale) if type not in dir(fake): diff --git a/frappe/automation/workspace/tools/tools.json b/frappe/automation/workspace/tools/tools.json index f556be1c07..fa2606dc43 100644 --- a/frappe/automation/workspace/tools/tools.json +++ b/frappe/automation/workspace/tools/tools.json @@ -1,20 +1,13 @@ { - "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_default": 0, - "is_standard": 0, "label": "Tools", "links": [ { @@ -215,15 +208,12 @@ "type": "Link" } ], - "modified": "2021-08-05 12:16:02.839180", + "modified": "2021-08-05 12:16:02.839181", "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": [], diff --git a/frappe/build.py b/frappe/build.py index 8b32b03d60..6b93b8b93a 100644 --- a/frappe/build.py +++ b/frappe/build.py @@ -16,7 +16,6 @@ from frappe.utils.minify import JavascriptMinify import click import psutil from urllib.parse import urlparse -from simple_chalk import green from semantic_version import Version from requests import head from requests.exceptions import HTTPError @@ -108,7 +107,7 @@ def fetch_assets(url, frappe_head): if not assets_archive: raise AssetsNotDownloadedError(f"Assets could not be retrived from {url}") - print(f"\n{green('✔')} Downloaded Frappe assets from {url}") + click.echo(click.style("✔", fg="green") + f" Downloaded Frappe assets from {url}") return assets_archive @@ -131,7 +130,7 @@ def setup_assets(assets_archive): directories_created.add(asset_directory) tar.makefile(file, dest) - print("{0} Restored {1}".format(green('✔'), show)) + click.echo(click.style("✔", fg="green") + f" Restored {show}") return directories_created @@ -379,7 +378,7 @@ def make_asset_dirs(hard_link=False): except Exception: print(fail_message, end="\r") - print(unstrip(f"{green('✔')} Application Assets Linked") + "\n") + click.echo(unstrip(click.style("✔", fg="green") + " Application Assets Linked") + "\n") def link_assets_dir(source, target, hard_link=False): diff --git a/frappe/client.py b/frappe/client.py index 21d10e8271..0e9be0a7ee 100644 --- a/frappe/client.py +++ b/frappe/client.py @@ -258,6 +258,12 @@ def set_default(key, value, parent=None): frappe.db.set_default(key, value, parent or frappe.session.user) frappe.clear_cache(user=frappe.session.user) +@frappe.whitelist() +def get_default(key, parent=None): + """set a user default value""" + return frappe.db.get_default(key, parent) + + @frappe.whitelist(methods=['POST', 'PUT']) def make_width_property_setter(doc): '''Set width Property Setter @@ -276,18 +282,17 @@ def bulk_update(docs): docs = json.loads(docs) failed_docs = [] for doc in docs: + doc.pop("flags", None) try: - ddoc = {key: val for key, val in doc.items() if key not in ['doctype', 'docname']} - doctype = doc['doctype'] - docname = doc['docname'] - doc = frappe.get_doc(doctype, docname) - doc.update(ddoc) - doc.save() - except: + existing_doc = frappe.get_doc(doc["doctype"], doc["docname"]) + existing_doc.update(doc) + existing_doc.save() + except Exception: failed_docs.append({ 'doc': doc, 'exc': frappe.utils.get_traceback() }) + return {'failed_docs': failed_docs} @frappe.whitelist() diff --git a/frappe/core/doctype/activity_log/activity_log.py b/frappe/core/doctype/activity_log/activity_log.py index 183a1c264c..69565a2c2a 100644 --- a/frappe/core/doctype/activity_log/activity_log.py +++ b/frappe/core/doctype/activity_log/activity_log.py @@ -7,6 +7,9 @@ from frappe.utils import get_fullname, now from frappe.model.document import Document from frappe.core.utils import set_timeline_doc import frappe +from frappe.query_builder import DocType, Interval +from frappe.query_builder.functions import Now +from pypika.terms import PseudoColumn class ActivityLog(Document): def before_insert(self): @@ -44,6 +47,7 @@ def clear_activity_logs(days=None): if not days: days = 90 - - frappe.db.sql("""delete from `tabActivity Log` where \ - creation< (NOW() - INTERVAL '{0}' DAY)""".format(days)) \ No newline at end of file + doctype = DocType("Activity Log") + frappe.db.delete(doctype, filters=( + doctype.creation < PseudoColumn(f"({Now() - Interval(days=days)})") + )) \ No newline at end of file diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 5a91016e32..738fb73a34 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -87,10 +87,6 @@ class DocType(Document): if self.default_print_format and not self.custom: frappe.throw(_('Standard DocType cannot have default print format, use Customize Form')) - if frappe.conf.get('developer_mode'): - self.owner = 'Administrator' - self.modified_by = 'Administrator' - def validate_field_name_conflicts(self): """Check if field names dont conflict with controller properties and methods""" core_doctypes = [ @@ -177,7 +173,6 @@ class DocType(Document): if self.is_virtual and self.custom: frappe.throw(_("Not allowed to create custom Virtual DocType."), CannotCreateStandardDoctypeError) - if frappe.conf.get('developer_mode'): self.owner = 'Administrator' self.modified_by = 'Administrator' @@ -315,9 +310,7 @@ class DocType(Document): if allow_doctype_export: self.export_doc() self.make_controller_template() - - if self.has_web_view: - self.set_base_class_for_controller() + self.set_base_class_for_controller() # update index if not self.custom: @@ -355,23 +348,49 @@ class DocType(Document): now=now, doctype=self.name) def set_base_class_for_controller(self): - '''Updates the controller class to subclass from `WebsiteGenertor`, - if it is a subclass of `Document`''' - controller_path = frappe.get_module_path(frappe.scrub(self.module), - 'doctype', frappe.scrub(self.name), frappe.scrub(self.name) + '.py') + """If DocType.has_web_view has been changed, updates the controller class and import + from `WebsiteGenertor` to `Document` or viceversa""" - with open(controller_path, 'r') as f: + if not self.has_value_changed("has_web_view"): + return + + despaced_name = self.name.replace(" ", "_") + scrubbed_name = frappe.scrub(self.name) + scrubbed_module = frappe.scrub(self.module) + controller_path = frappe.get_module_path( + scrubbed_module, "doctype", scrubbed_name, f"{scrubbed_name}.py" + ) + + document_cls_tag = f"class {despaced_name}(Document)" + document_import_tag = "from frappe.model.document import Document" + website_generator_cls_tag = f"class {despaced_name}(WebsiteGenerator)" + website_generator_import_tag = "from frappe.website.generators.website_generator import WebsiteGenerator" + + with open(controller_path) as f: code = f.read() + updated_code = code - class_string = '\nclass {0}(Document)'.format(self.name.replace(' ', '')) - if '\nfrom frappe.model.document import Document' in code and class_string in code: - code = code.replace('from frappe.model.document import Document', - 'from frappe.website.website_generator import WebsiteGenerator') - code = code.replace('class {0}(Document)'.format(self.name.replace(' ', '')), - 'class {0}(WebsiteGenerator)'.format(self.name.replace(' ', ''))) + is_website_generator_class = all([ + website_generator_cls_tag in code, + website_generator_import_tag in code + ]) - with open(controller_path, 'w') as f: - f.write(code) + if self.has_web_view and not is_website_generator_class: + updated_code = updated_code.replace( + document_import_tag, website_generator_import_tag + ).replace( + document_cls_tag, website_generator_cls_tag + ) + elif not self.has_web_view and is_website_generator_class: + updated_code = updated_code.replace( + website_generator_import_tag, document_import_tag + ).replace( + website_generator_cls_tag, document_cls_tag + ) + + if updated_code != code: + with open(controller_path, "w") as f: + f.write(updated_code) def run_module_method(self, method): from frappe.modules import load_doctype_module diff --git a/frappe/core/doctype/log_settings/log_settings.json b/frappe/core/doctype/log_settings/log_settings.json index 8a2596b35c..f06d14f16b 100644 --- a/frappe/core/doctype/log_settings/log_settings.json +++ b/frappe/core/doctype/log_settings/log_settings.json @@ -49,7 +49,7 @@ "label": "Clear Activity Log After" }, { - "default": "90", + "default": "30", "description": "In Days", "fieldname": "clear_email_queue_after", "fieldtype": "Int", @@ -80,4 +80,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} \ No newline at end of file +} diff --git a/frappe/core/doctype/log_settings/log_settings.py b/frappe/core/doctype/log_settings/log_settings.py index c505302c52..5c9bc6c265 100644 --- a/frappe/core/doctype/log_settings/log_settings.py +++ b/frappe/core/doctype/log_settings/log_settings.py @@ -5,6 +5,10 @@ import frappe from frappe import _ from frappe.model.document import Document +from frappe.query_builder import DocType, Interval +from frappe.query_builder.functions import Now +from pypika.terms import PseudoColumn + class LogSettings(Document): def clear_logs(self): @@ -13,9 +17,10 @@ class LogSettings(Document): self.clear_email_queue() def clear_error_logs(self): - frappe.db.sql(""" DELETE FROM `tabError Log` - WHERE `creation` < (NOW() - INTERVAL '{0}' DAY) - """.format(self.clear_error_log_after)) + table = DocType("Error Log") + frappe.db.delete(table, filters=( + table.creation < PseudoColumn(f"({Now() - Interval(days=self.clear_error_log_after)})") + )) def clear_activity_logs(self): from frappe.core.doctype.activity_log.activity_log import clear_activity_logs diff --git a/frappe/core/doctype/report/report.py b/frappe/core/doctype/report/report.py index 6a54314667..be0346d869 100644 --- a/frappe/core/doctype/report/report.py +++ b/frappe/core/doctype/report/report.py @@ -105,7 +105,7 @@ class Report(Document): if not self.query.lower().startswith("select"): frappe.throw(_("Query must be a SELECT"), title=_('Report Document Error')) - result = [list(t) for t in frappe.db.sql(self.query, filters, debug=True)] + result = [list(t) for t in frappe.db.sql(self.query, filters)] columns = self.get_columns() or [cstr(c[0]) for c in frappe.db.get_description()] return [columns, result] diff --git a/frappe/core/doctype/server_script/test_server_script.py b/frappe/core/doctype/server_script/test_server_script.py index de858327a9..100e3c2790 100644 --- a/frappe/core/doctype/server_script/test_server_script.py +++ b/frappe/core/doctype/server_script/test_server_script.py @@ -122,7 +122,7 @@ class TestServerScript(unittest.TestCase): self.assertEqual(frappe.get_doc('Server Script', 'test_return_value').execute_method(), 'hello') def test_permission_query(self): - self.assertTrue('where (1 = 1)' in frappe.db.get_list('ToDo', return_query=1)) + self.assertTrue('where (1 = 1)' in frappe.db.get_list('ToDo', run=False)) self.assertTrue(isinstance(frappe.db.get_list('ToDo'), list)) def test_attribute_error(self): diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index e4b94cdbb6..45f7d47a27 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -16,6 +16,7 @@ from frappe.utils.user import get_system_managers from frappe.website.utils import is_signup_disabled from frappe.rate_limiter import rate_limit from frappe.core.doctype.user_type.user_type import user_linked_with_permission_on_doctype +from frappe.query_builder import DocType STANDARD_USERS = ("Guest", "Administrator") @@ -366,15 +367,21 @@ class User(Document): # delete shares frappe.db.delete("DocShare", {"user": self.name}) # delete messages - frappe.db.sql("""delete from `tabCommunication` - where communication_type in ('Chat', 'Notification') - and reference_doctype='User' - and (reference_name=%s or owner=%s)""", (self.name, self.name)) - + table = DocType("Communication") + frappe.db.delete( + table, + filters=( + (table.communication_type.isin(["Chat", "Notification"])) + & (table.reference_doctype == "User") + & ((table.reference_name == self.name) | table.owner == self.name) + ), + run=False, + ) # unlink contact - frappe.db.sql("""update `tabContact` - set `user`=null - where `user`=%s""", (self.name)) + table = DocType("Contact") + frappe.qb.update(table).where( + table.user == self.name + ).set(table.user, None).run() # delete notification settings frappe.delete_doc("Notification Settings", self.name, ignore_permissions=True) @@ -421,9 +428,10 @@ class User(Document): frappe.rename_doc("Notification Settings", old_name, new_name, force=True, show_alert=False) # set email - frappe.db.sql("""UPDATE `tabUser` - SET email = %s - WHERE name = %s""", (new_name, new_name)) + table = DocType("User") + frappe.qb.update(table).where( + table.name == new_name + ).set("email", new_name).run() def append_roles(self, *roles): """Add roles to user""" diff --git a/frappe/core/doctype/user_type/user_type.py b/frappe/core/doctype/user_type/user_type.py index 79a90933e7..c1fd678141 100644 --- a/frappe/core/doctype/user_type/user_type.py +++ b/frappe/core/doctype/user_type/user_type.py @@ -195,7 +195,7 @@ def get_user_linked_doctypes(doctype, txt, searchfield, start, page_len, filters ['DocType', 'read_only', '=', 0], ['DocType', 'name', 'like', '%{0}%'.format(txt)]] doctypes = frappe.get_all('DocType', fields = ['`tabDocType`.`name`'], filters=filters, - order_by = '`tabDocType`.`idx` desc', limit_start=start, limit_page_length=page_len, as_list=1, debug=1) + order_by = '`tabDocType`.`idx` desc', limit_start=start, limit_page_length=page_len, as_list=1) custom_dt_filters = [['Custom Field', 'dt', 'like', '%{0}%'.format(txt)], ['Custom Field', 'options', '=', 'User'], ['Custom Field', 'fieldtype', '=', 'Link']] diff --git a/frappe/core/workspace/build/build.json b/frappe/core/workspace/build/build.json index 8536c807d2..aabb4f9d1c 100644 --- a/frappe/core/workspace/build/build.json +++ b/frappe/core/workspace/build/build.json @@ -1,21 +1,13 @@ { - "cards_label": "Elements", - "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}},{\"type\":\"card\",\"data\":{\"card_name\":\"Packages\",\"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": 0, "label": "Build", "links": [ { @@ -230,15 +222,12 @@ "type": "Link" } ], - "modified": "2021-09-05 21:14:52.384815", + "modified": "2021-09-05 21:14:52.384816", "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": [], diff --git a/frappe/core/workspace/settings/settings.json b/frappe/core/workspace/settings/settings.json index 93a6c81c90..917ce2cbdc 100644 --- a/frappe/core/workspace/settings/settings.json +++ b/frappe/core/workspace/settings/settings.json @@ -1,20 +1,13 @@ { - "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}}]", + "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": 0, "docstatus": 0, "doctype": "Workspace", - "extends": "", - "extends_another_page": 0, "for_user": "", "hide_custom": 0, "icon": "setting", "idx": 0, - "is_default": 0, - "is_standard": 0, "label": "Settings", "links": [ { @@ -374,15 +367,12 @@ "type": "Link" } ], - "modified": "2021-08-05 12:16:03.456173", + "modified": "2021-08-05 12:16:03.456174", "modified_by": "Administrator", "module": "Core", "name": "Settings", - "onboarding": "", "owner": "Administrator", "parent_page": "", - "pin_to_bottom": 0, - "pin_to_top": 0, "public": 1, "restrict_to_domain": "", "roles": [], @@ -407,6 +397,5 @@ "type": "DocType" } ], - "shortcuts_label": "Settings", "title": "Settings" } \ No newline at end of file diff --git a/frappe/core/workspace/users/users.json b/frappe/core/workspace/users/users.json index 09a835ea2c..85c110151b 100644 --- a/frappe/core/workspace/users/users.json +++ b/frappe/core/workspace/users/users.json @@ -1,20 +1,13 @@ { - "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": 0, "label": "Users", "links": [ { @@ -152,15 +145,12 @@ "type": "Link" } ], - "modified": "2021-08-05 12:16:03.010204", + "modified": "2021-08-05 12:16:03.010205", "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": [], diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py index bf606701da..8c22d3c45c 100644 --- a/frappe/custom/doctype/custom_field/custom_field.py +++ b/frappe/custom/doctype/custom_field/custom_field.py @@ -131,7 +131,7 @@ def create_custom_field(doctype, df, ignore_validate=False): "permlevel": 0, "fieldtype": 'Data', "hidden": 0, - # Looks like we always use this programatically? + # Looks like we always use this programatically? # "is_standard": 1 }) custom_field.update(df) @@ -146,24 +146,29 @@ def create_custom_fields(custom_fields, ignore_validate = False, update=True): if not ignore_validate and frappe.flags.in_setup_wizard: ignore_validate = True - for doctype, fields in custom_fields.items(): + for doctypes, fields in custom_fields.items(): if isinstance(fields, dict): # only one field fields = [fields] - for df in fields: - field = frappe.db.get_value("Custom Field", {"dt": doctype, "fieldname": df["fieldname"]}) - if not field: - try: - df["owner"] = "Administrator" - create_custom_field(doctype, df, ignore_validate=ignore_validate) - except frappe.exceptions.DuplicateEntryError: - pass - elif update: - custom_field = frappe.get_doc("Custom Field", field) - custom_field.flags.ignore_validate = ignore_validate - custom_field.update(df) - custom_field.save() + if isinstance(doctypes, str): + # only one doctype + doctypes = (doctypes,) + + for doctype in doctypes: + for df in fields: + field = frappe.db.get_value("Custom Field", {"dt": doctype, "fieldname": df["fieldname"]}) + if not field: + try: + df["owner"] = "Administrator" + create_custom_field(doctype, df, ignore_validate=ignore_validate) + except frappe.exceptions.DuplicateEntryError: + pass + elif update: + custom_field = frappe.get_doc("Custom Field", field) + custom_field.flags.ignore_validate = ignore_validate + custom_field.update(df) + custom_field.save() frappe.clear_cache(doctype=doctype) frappe.db.updatedb(doctype) diff --git a/frappe/custom/doctype/custom_field/test_custom_field.py b/frappe/custom/doctype/custom_field/test_custom_field.py index 9633f0eb8a..ad3cf27eea 100644 --- a/frappe/custom/doctype/custom_field/test_custom_field.py +++ b/frappe/custom/doctype/custom_field/test_custom_field.py @@ -6,7 +6,42 @@ import frappe import unittest -test_records = frappe.get_test_records('Custom Field') +test_records = frappe.get_test_records("Custom Field") + class TestCustomField(unittest.TestCase): - pass + def test_create_custom_fields(self): + from .custom_field import create_custom_fields + + create_custom_fields( + { + "Address": [ + { + "fieldname": "_test_custom_field_1", + "label": "_Test Custom Field 1", + "fieldtype": "Data", + "insert_after": "phone", + }, + ], + ("Address", "Contact"): [ + { + "fieldname": "_test_custom_field_2", + "label": "_Test Custom Field 2", + "fieldtype": "Data", + "insert_after": "phone", + }, + ], + } + ) + + frappe.db.commit() + + self.assertTrue( + frappe.db.exists("Custom Field", "Address-_test_custom_field_1") + ) + self.assertTrue( + frappe.db.exists("Custom Field", "Address-_test_custom_field_2") + ) + self.assertTrue( + frappe.db.exists("Custom Field", "Contact-_test_custom_field_2") + ) diff --git a/frappe/custom/workspace/customization/customization.json b/frappe/custom/workspace/customization/customization.json index 136b1a57eb..7aec530604 100644 --- a/frappe/custom/workspace/customization/customization.json +++ b/frappe/custom/workspace/customization/customization.json @@ -1,20 +1,13 @@ { - "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": 0, "label": "Customization", "links": [ { @@ -130,15 +123,12 @@ "type": "Link" } ], - "modified": "2021-08-05 12:15:57.486112", + "modified": "2021-08-05 12:15:57.486113", "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": [], diff --git a/frappe/data/google_fonts.json b/frappe/data/google_fonts.json new file mode 100644 index 0000000000..232e509e77 --- /dev/null +++ b/frappe/data/google_fonts.json @@ -0,0 +1,56 @@ +[ + "Alegreya Sans", + "Alegreya", + "Andada Pro", + "Anton", + "Archivo Narrow", + "Archivo", + "BioRhyme", + "Cardo", + "Chivo", + "Cormorant", + "Crimson Text", + "DM Sans", + "Eczar", + "Encode Sans", + "Epilogue ", + "Fira Sans", + "Hahmlet", + "IBM Plex Sans", + "Inconsolata", + "Inknut Antiqua", + "Inter", + "JetBrains Mono", + "Karla", + "Lato", + "Libre Baskerville", + "Libre Franklin", + "Lora", + "Manrope", + "Merriweather", + "Montserrat", + "Neuton", + "Nunito", + "Old Standard TT", + "Open Sans", + "Oswald", + "Oxygen", + "Playfair Display", + "Poppins", + "Proza Libre", + "PT Sans", + "PT Serif", + "Raleway", + "Roboto Slab", + "Roboto", + "Rubik", + "Sora", + "Source Sans Pro", + "Source Serif Pro", + "Space Grotesk", + "Space Mono", + "Spectral", + "Syne", + "Work Sans" +] + diff --git a/frappe/database/database.py b/frappe/database/database.py index 3695c1c18b..c0d377fd42 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -37,6 +37,7 @@ class Database(object): STANDARD_VARCHAR_COLUMNS = ('name', 'owner', 'modified_by', 'parent', 'parentfield', 'parenttype') DEFAULT_COLUMNS = ['name', 'creation', 'modified', 'modified_by', 'owner', 'docstatus', 'parent', 'parentfield', 'parenttype', 'idx'] + MAX_WRITES_PER_TRANSACTION = 200_000 class InvalidColumnName(frappe.ValidationError): pass @@ -83,7 +84,8 @@ class Database(object): pass def sql(self, query, values=(), as_dict = 0, as_list = 0, formatted = 0, - debug=0, ignore_ddl=0, as_utf8=0, auto_commit=0, update=None, explain=False, run=True): + debug=0, ignore_ddl=0, as_utf8=0, auto_commit=0, update=None, + explain=False, run=True, pluck=False): """Execute a SQL query and fetch all rows. :param query: SQL query. @@ -184,6 +186,9 @@ class Database(object): if not self._cursor.description: return () + if pluck: + return [r[0] for r in self._cursor.fetchall()] + # scrub output if required if as_dict: ret = self.fetch_as_dict(formatted, as_utf8) @@ -239,7 +244,7 @@ class Database(object): except Exception: frappe.errprint("error in query explain") - def sql_list(self, query, values=(), debug=False): + def sql_list(self, query, values=(), debug=False, **kwargs): """Return data as list of single elements (first column). Example: @@ -247,7 +252,7 @@ class Database(object): # doctypes = ["DocType", "DocField", "User", ...] doctypes = frappe.db.sql_list("select name from DocType") """ - return [r[0] for r in self.sql(query, values, debug=debug)] + return [r[0] for r in self.sql(query, values, **kwargs, debug=debug)] def sql_ddl(self, query, values=(), debug=False): """Commit and execute a query. DDL (Data Definition Language) queries that alter schema @@ -268,7 +273,7 @@ class Database(object): if query[:6].lower() in ('update', 'insert', 'delete'): self.transaction_writes += 1 - if self.transaction_writes > 200000: + if self.transaction_writes > self.MAX_WRITES_PER_TRANSACTION: if self.auto_commit_on_many_writes: self.commit() else: @@ -330,7 +335,7 @@ class Database(object): return self.get_value(doctype, filters, "*", as_dict=as_dict, cache=cache) def get_value(self, doctype, filters=None, fieldname="name", ignore=None, as_dict=False, - debug=False, order_by=None, cache=False, for_update=False): + debug=False, order_by=None, cache=False, for_update=False, run=True): """Returns a document property or list of properties. :param doctype: DocType name. @@ -357,12 +362,15 @@ class Database(object): """ ret = self.get_values(doctype, filters, fieldname, ignore, as_dict, debug, - order_by, cache=cache, for_update=for_update) + order_by, cache=cache, for_update=for_update, run=run) + + if not run: + return ret return ((len(ret[0]) > 1 or as_dict) and ret[0] or ret[0][0]) if ret else None def get_values(self, doctype, filters=None, fieldname="name", ignore=None, as_dict=False, - debug=False, order_by=None, update=None, cache=False, for_update=False): + debug=False, order_by=None, update=None, cache=False, for_update=False, run=True): """Returns multiple document properties. :param doctype: DocType name. @@ -388,7 +396,7 @@ class Database(object): if isinstance(filters, list): order_by = order_by or "modified_desc" - out = self._get_value_for_many_names(doctype, filters, fieldname, debug=debug) + out = self._get_value_for_many_names(doctype, filters, fieldname, debug=debug, run=run) else: fields = fieldname @@ -401,26 +409,28 @@ class Database(object): if (filters is not None) and (filters!=doctype or doctype=="DocType"): try: order_by = order_by or "modified" - out = self._get_values_from_table(fields, filters, doctype, as_dict, debug, order_by, update, for_update=for_update) + out = self._get_values_from_table( + fields, filters, doctype, as_dict, debug, order_by, update, for_update=for_update, run=run + ) except Exception as e: if ignore and (frappe.db.is_missing_column(e) or frappe.db.is_table_missing(e)): # table or column not found, return None out = None elif (not ignore) and frappe.db.is_table_missing(e): # table not found, look in singles - out = self.get_values_from_single(fields, filters, doctype, as_dict, debug, update) + out = self.get_values_from_single(fields, filters, doctype, as_dict, debug, update, run=run) else: raise else: - out = self.get_values_from_single(fields, filters, doctype, as_dict, debug, update) + out = self.get_values_from_single(fields, filters, doctype, as_dict, debug, update, run=run) if cache and isinstance(filters, str): self.value_cache[(doctype, filters, fieldname)] = out return out - def get_values_from_single(self, fields, filters, doctype, as_dict=False, debug=False, update=None): + def get_values_from_single(self, fields, filters, doctype, as_dict=False, debug=False, update=None, run=True): """Get values from `tabSingles` (Single DocTypes) (internal). :param fields: List of fields, @@ -449,8 +459,9 @@ class Database(object): r = self.sql("""select field, value from `tabSingles` where field in (%s) and doctype=%s""" % (', '.join(['%s'] * len(fields)), '%s'), - tuple(fields) + (doctype,), as_dict=False, debug=debug) - + tuple(fields) + (doctype,), as_dict=False, debug=debug, run=run) + if not run: + return r if as_dict: if r: r = frappe._dict(r) @@ -528,7 +539,8 @@ class Database(object): """Alias for get_single_value""" return self.get_single_value(*args, **kwargs) - def _get_values_from_table(self, fields, filters, doctype, as_dict, debug, order_by=None, update=None, for_update=False): + def _get_values_from_table(self, fields, filters, doctype, as_dict, debug, order_by=None, + update=None, for_update=False, run=True): field_objects = [] for field in fields: @@ -537,7 +549,9 @@ class Database(object): else: field_objects.append(field) - criterion = self.query.build_conditions(table=doctype, filters=filters, orderby=order_by, for_update=for_update) + criterion = self.query.build_conditions( + table=doctype, filters=filters, orderby=order_by, for_update=for_update + ) if isinstance(fields, (list, tuple)): query = criterion.select(*field_objects) @@ -545,18 +559,17 @@ class Database(object): if fields=="*": query = criterion.select(fields) as_dict = True - r = self.sql(query, as_dict=as_dict, debug=debug, update=update) - + r = self.sql(query, as_dict=as_dict, debug=debug, update=update, run=run) return r - def _get_value_for_many_names(self, doctype, names, field, debug=False): + def _get_value_for_many_names(self, doctype, names, field, debug=False, run=True): names = list(filter(None, names)) if names: return self.get_all(doctype, fields=['name', field], filters=[['name', 'in', names]], - debug=debug, as_list=1) + debug=debug, as_list=1, run=run) else: return {} @@ -601,7 +614,7 @@ class Database(object): for key in to_update: set_values.append('`{0}`=%({0})s'.format(key)) - for name in self.get_values(dt, dn, 'name', for_update=for_update): + for name in self.get_values(dt, dn, 'name', for_update=for_update, debug=debug): values = dict(name=name[0]) values.update(to_update) diff --git a/frappe/database/query.py b/frappe/database/query.py index 7d7de85646..3545efb412 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -156,7 +156,7 @@ class Query: Returns: frappe.qb: condition object """ - condition = self.get_condition(table, **kwargs) + condition = self.add_conditions(self.get_condition(table, **kwargs), **kwargs) return condition.where(criterion) def add_conditions(self, conditions: frappe.qb, **kwargs): diff --git a/frappe/defaults.py b/frappe/defaults.py index 75feabc332..eb98db449f 100644 --- a/frappe/defaults.py +++ b/frappe/defaults.py @@ -4,6 +4,7 @@ import frappe from frappe.desk.notifications import clear_notifications from frappe.cache_manager import clear_defaults_cache, common_default_keys +from frappe.query_builder import DocType # Note: DefaultValue records are identified by parenttype # __default, __global or 'User Permission' @@ -116,14 +117,11 @@ def set_default(key, value, parent, parenttype="__default"): :param value: Default value. :param parent: Usually, **User** to whom the default belongs. :param parenttype: [optional] default is `__default`.""" - if frappe.db.sql(''' - select - defkey - from - `tabDefaultValue` - where - defkey=%s and parent=%s - for update''', (key, parent)): + table = DocType("DefaultValue") + key_exists = frappe.qb.from_(table).where( + (table.defkey == key) & (table.parent == parent) + ).select(table.defkey).for_update().run() + if key_exists: frappe.db.delete("DefaultValue", { "defkey": key, "parent": parent @@ -191,8 +189,12 @@ def get_defaults_for(parent="__default"): if defaults==None: # sort descending because first default must get precedence - res = frappe.db.sql("""select defkey, defvalue from `tabDefaultValue` - where parent = %s order by creation""", (parent,), as_dict=1) + table = DocType("DefaultValue") + res = frappe.qb.from_(table).where( + table.parent == parent + ).select( + table.defkey, table.defvalue + ).orderby("creation").run(as_dict=True) defaults = frappe._dict({}) for d in res: diff --git a/frappe/desk/desktop.py b/frappe/desk/desktop.py index b9b01d0a74..e1789852f1 100644 --- a/frappe/desk/desktop.py +++ b/frappe/desk/desktop.py @@ -32,9 +32,6 @@ class Workspace: 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() @@ -151,21 +148,6 @@ class Workspace: return doc - def get_pages_to_extend(self): - pages = frappe.get_all("Workspace", filters={ - "extends": self.page_name, - 'restrict_to_domain': ['in', frappe.get_active_domains()], - 'for_user': '', - 'module': ['in', self.allowed_modules] - }) - - pages = [frappe.get_cached_doc("Workspace", page['name']) for page in pages] - - for page in pages: - self.extended_links = self.extended_links + page.get_link_groups() - self.extended_charts = self.extended_charts + page.charts - self.extended_shortcuts = self.extended_shortcuts + page.shortcuts - def is_item_allowed(self, name, item_type): if frappe.session.user == "Administrator": return True @@ -187,17 +169,14 @@ class Workspace: def build_workspace(self): self.cards = { - 'label': _(self.doc.cards_label), 'items': self.get_links() } self.charts = { - 'label': _(self.doc.charts_label), 'items': self.get_charts() } self.shortcuts = { - 'label': _(self.doc.shortcuts_label), 'items': self.get_shortcuts() } @@ -249,9 +228,6 @@ class Workspace: if not self.doc.hide_custom: cards = cards + get_custom_reports_and_doctypes(self.doc.module) - if len(self.extended_links): - cards = merge_cards_based_on_label(cards + self.extended_links) - default_country = frappe.db.get_default("country") new_data = [] @@ -289,8 +265,6 @@ class Workspace: all_charts = [] if frappe.has_permission("Dashboard Chart", throw=False): charts = self.doc.charts - if len(self.extended_charts): - charts = charts + self.extended_charts for chart in charts: if frappe.has_permission('Dashboard Chart', doc=chart.chart_name): @@ -311,8 +285,6 @@ class Workspace: items = [] shortcuts = self.doc.shortcuts - if len(self.extended_shortcuts): - shortcuts = shortcuts + self.extended_shortcuts for item in shortcuts: new_item = item.as_dict().copy() @@ -380,8 +352,7 @@ def get_desktop_page(page): 'charts': wspace.charts, 'shortcuts': wspace.shortcuts, 'cards': wspace.cards, - 'onboardings': wspace.onboardings, - 'allow_customization': not wspace.doc.disable_user_customization + 'onboardings': wspace.onboardings } except DoesNotExistError: frappe.log_error(frappe.get_traceback()) @@ -414,7 +385,7 @@ def get_wspace_sidebar_items(): # Filter Page based on Permission for page in all_pages: try: - wspace = Workspace(page) + wspace = Workspace(page, True) if wspace.is_permitted() and wspace.is_page_allowed() or has_access: if page.public: pages.append(page) @@ -461,7 +432,6 @@ def get_custom_doctype_list(module): return out - def get_custom_report_list(module): """Returns list on new style reports for modules.""" reports = frappe.get_all("Report", fields=["name", "ref_doctype", "report_type"], filters= @@ -482,85 +452,6 @@ def get_custom_report_list(module): return out -def get_custom_workspace_for_user(page): - """Get custom page from workspace if exists or create one - - Args: - page (stirng): Page name - - Returns: - Object: Document object - """ - filters = { - 'extends': page, - 'for_user': frappe.session.user, - } - pages = frappe.get_list("Workspace", filters=filters) - if pages: - return frappe.get_doc("Workspace", pages[0]) - doc = frappe.new_doc("Workspace") - doc.extends = 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 - - Args: - page (string): Name of the page to be edited - config (dict): Dictionary config of al widgets - - Returns: - Boolean: Customization saving status - """ - original_page = frappe.get_doc("Workspace", page) - page_doc = get_custom_workspace_for_user(page) - - # Update field values - page_doc.update({ - "icon": original_page.icon, - "charts_label": original_page.charts_label, - "cards_label": original_page.cards_label, - "shortcuts_label": original_page.shortcuts_label, - "module": original_page.module, - "onboarding": original_page.onboarding, - "developer_mode_only": original_page.developer_mode_only, - "category": original_page.category - }) - - config = _dict(loads(config)) - if config.charts: - page_doc.charts = prepare_widget(config.charts, "Workspace Chart", "charts") - if config.shortcuts: - page_doc.shortcuts = prepare_widget(config.shortcuts, "Workspace Shortcut", "shortcuts") - if config.cards: - page_doc.build_links_table_from_cards(config.cards) - - # Set label - page_doc.label = page + '-' + frappe.session.user - - try: - if page_doc.is_new(): - page_doc.insert(ignore_permissions=True) - else: - page_doc.save(ignore_permissions=True) - except (ValidationError, TypeError) as e: - # Create a json string to log - json_config = dumps(config, 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 save_new_widget(doc, page, blocks, new_widgets): widgets = _dict(loads(new_widgets)) @@ -593,6 +484,7 @@ def save_new_widget(doc, page, blocks, new_widgets): return False return True + def clean_up(original_page, blocks): page_widgets = {} @@ -670,40 +562,14 @@ def prepare_widget(config, doctype, parentfield): prepare_widget_list.append(doc) return prepare_widget_list - @frappe.whitelist() def update_onboarding_step(name, field, value): """Update status of onboaridng step Args: - name (string): Name of the doc - field (string): field to be updated - value: Value to be updated + name (string): Name of the doc + field (string): field to be updated + value: Value to be updated """ frappe.db.set_value("Onboarding Step", name, field, value) - -@frappe.whitelist() -def reset_customization(page): - """Reset workspace customizations for a user - - Args: - page (string): Name of the page to be reset - """ - page_doc = get_custom_workspace_for_user(page) - page_doc.delete() - -def merge_cards_based_on_label(cards): - """Merge cards with common label.""" - cards_dict = {} - for card in cards: - label = card.get('label') - if label in cards_dict: - links = cards_dict[label].links + card.links - cards_dict[label].update(dict(links=links)) - cards_dict[label] = cards_dict.pop(label) - else: - cards_dict[label] = card - - return list(cards_dict.values()) - diff --git a/frappe/desk/doctype/workspace/workspace.js b/frappe/desk/doctype/workspace/workspace.js index 19d429f9f6..5377470343 100644 --- a/frappe/desk/doctype/workspace/workspace.js +++ b/frappe/desk/doctype/workspace/workspace.js @@ -8,14 +8,9 @@ frappe.ui.form.on('Workspace', { refresh: function(frm) { frm.enable_save(); - frm.get_field("is_standard").toggle(frappe.boot.developer_mode); - frm.get_field("developer_mode_only").toggle(frappe.boot.developer_mode); - if (frm.doc.for_user) { - frm.set_df_property("extends", "read_only", true); - } - - if (frm.doc.for_user || (frm.doc.is_standard && !frappe.boot.developer_mode)) { + if (frm.doc.for_user || (frm.doc.public && !frm.has_perm('write') && + !frappe.user.has_role('Workspace Manager'))) { frm.trigger('disable_form'); } }, diff --git a/frappe/desk/doctype/workspace/workspace.json b/frappe/desk/doctype/workspace/workspace.json index 756a40da4b..04975c69e3 100644 --- a/frappe/desk/doctype/workspace/workspace.json +++ b/frappe/desk/doctype/workspace/workspace.json @@ -11,32 +11,19 @@ "title", "sequence_id", "for_user", - "extends", "parent_page", "module", - "category", + "column_break_3", "icon", "restrict_to_domain", - "onboarding", - "column_break_3", - "extends_another_page", - "is_default", - "is_standard", - "developer_mode_only", - "disable_user_customization", - "pin_to_top", - "pin_to_bottom", "hide_custom", "public", "content", "section_break_2", - "charts_label", "charts", "section_break_15", - "shortcuts_label", "shortcuts", "section_break_18", - "cards_label", "links", "roles_section", "roles" @@ -63,7 +50,6 @@ "options": "Workspace Chart" }, { - "depends_on": "eval:!doc.extends_another_page || !doc.is_standard || frappe.boot.developer_mode", "fieldname": "shortcuts", "fieldtype": "Table", "label": "Shortcuts", @@ -74,7 +60,6 @@ "fieldtype": "Link", "label": "Restrict to Domain", "options": "Domain", - "read_only_depends_on": "eval:doc.extends_another_page == 0", "search_index": 1 }, { @@ -89,64 +74,6 @@ "fieldname": "column_break_3", "fieldtype": "Column Break" }, - { - "fieldname": "category", - "fieldtype": "Select", - "label": "Category", - "options": "Modules\nDomains\nPlaces\nAdministration", - "read_only_depends_on": "eval:doc.extends_another_page == 1", - "search_index": 1 - }, - { - "default": "0", - "depends_on": "eval:doc.extends_another_page == 0", - "fieldname": "developer_mode_only", - "fieldtype": "Check", - "label": "Developer Mode Only", - "search_index": 1 - }, - { - "default": "0", - "depends_on": "eval:doc.pin_to_bottom!=1 && doc.extends_another_page == 0", - "fieldname": "pin_to_top", - "fieldtype": "Check", - "label": "Pin To Top", - "search_index": 1 - }, - { - "default": "0", - "depends_on": "eval:doc.extends_another_page == 0", - "fieldname": "disable_user_customization", - "fieldtype": "Check", - "label": "Disable User Customization", - "search_index": 1 - }, - { - "default": "0", - "depends_on": "eval:doc.pin_to_top!=1 && doc.extends_another_page == 0", - "fieldname": "pin_to_bottom", - "fieldtype": "Check", - "label": "Pin To Bottom", - "search_index": 1 - }, - { - "depends_on": "eval:!doc.extends_another_page || !doc.is_standard", - "fieldname": "charts_label", - "fieldtype": "Data", - "label": "Label" - }, - { - "depends_on": "eval:!doc.extends_another_page || !doc.is_standard", - "fieldname": "shortcuts_label", - "fieldtype": "Data", - "label": "Label" - }, - { - "depends_on": "eval:!doc.extends_another_page || !doc.is_standard", - "fieldname": "cards_label", - "fieldtype": "Data", - "label": "Label" - }, { "collapsible": 1, "collapsible_depends_on": "shortcuts", @@ -161,40 +88,12 @@ "fieldtype": "Section Break", "label": "Link Cards" }, - { - "default": "0", - "fieldname": "is_standard", - "fieldtype": "Check", - "label": "Is Standard", - "search_index": 1 - }, - { - "default": "0", - "fieldname": "extends_another_page", - "fieldtype": "Check", - "label": "Extends Another Page", - "search_index": 1 - }, - { - "depends_on": "eval:doc.extends_another_page == 1 || doc.for_user", - "fieldname": "extends", - "fieldtype": "Link", - "label": "Extends", - "options": "Workspace", - "search_index": 1 - }, { "fieldname": "for_user", "fieldtype": "Data", "label": "For User", "read_only": 1 }, - { - "fieldname": "onboarding", - "fieldtype": "Link", - "label": "Onboarding", - "options": "Module Onboarding" - }, { "default": "0", "description": "Checking this will hide custom doctypes and reports cards in Links section", @@ -213,21 +112,14 @@ "label": "Links", "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", "fieldname": "public", "fieldtype": "Check", "in_list_view": 1, "in_standard_filter": 1, - "label": "Public" + "label": "Public", + "search_index": 1 }, { "fieldname": "title", @@ -266,7 +158,7 @@ ], "in_create": 1, "links": [], - "modified": "2021-09-16 12:01:06.450621", + "modified": "2021-09-16 12:01:06.450622", "modified_by": "Administrator", "module": "Desk", "name": "Workspace", diff --git a/frappe/desk/doctype/workspace/workspace.py b/frappe/desk/doctype/workspace/workspace.py index a0a22a43fc..94114e3918 100644 --- a/frappe/desk/doctype/workspace/workspace.py +++ b/frappe/desk/doctype/workspace/workspace.py @@ -13,8 +13,8 @@ from json import loads class Workspace(Document): def validate(self): - if (self.is_standard and not frappe.conf.developer_mode and not disable_saving_as_standard()): - frappe.throw(_("You need to be in developer mode to edit this document")) + if (self.public and not is_workspace_manager() and not disable_saving_as_public()): + frappe.throw(_("You need to be Workspace Manager to edit this document")) validate_route_conflict(self.doctype, self.name) try: @@ -23,15 +23,8 @@ class Workspace(Document): except Exception: frappe.throw(_("Content data shoud be a list")) - duplicate_exists = frappe.db.exists("Workspace", { - "name": ["!=", self.name], 'is_default': 1, 'extends': self.extends - }) - - if self.is_default and self.name and duplicate_exists: - frappe.throw(_("You can only have one default page that extends a particular standard page.")) - def on_update(self): - if disable_saving_as_standard(): + if disable_saving_as_public(): return if frappe.conf.developer_mode and self.module and self.public: @@ -39,12 +32,7 @@ class Workspace(Document): @staticmethod def get_module_page_map(): - filters = { - 'extends_another_page': 0, - 'for_user': '', - } - - pages = frappe.get_all("Workspace", fields=["name", "module"], filters=filters, as_list=1) + pages = frappe.get_all("Workspace", fields=["name", "module"], filters={'for_user': ''}, as_list=1) return { page[1]: page[0] for page in pages if page[1] } @@ -76,35 +64,6 @@ class Workspace(Document): return cards - def build_links_table_from_cards(self, config): - # Empty links table - self.links = [] - order = config.get('order') - widgets = config.get('widgets') - - for idx, name in enumerate(order): - card = widgets[name].copy() - links = loads(card.get('links')) - - self.append('links', { - "label": card.get('label'), - "type": "Card Break", - "icon": card.get('icon'), - "hidden": card.get('hidden') or False - }) - - 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') - }) - def build_links_table_from_card(self, config): for idx, card in enumerate(config): @@ -137,7 +96,7 @@ class Workspace(Document): "idx": self.links[-1].idx + 1 }) -def disable_saving_as_standard(): +def disable_saving_as_public(): return frappe.flags.in_install or \ frappe.flags.in_patch or \ frappe.flags.in_test or \ @@ -212,7 +171,7 @@ def save_page(title, icon, parent, public, sb_public_items, sb_private_items, de def delete_pages(deleted_pages): for page in deleted_pages: - if page.get("public") and "Workspace Manager" not in frappe.get_roles(): + if page.get("public") and not is_workspace_manager(): return {"name": page.get("title"), "public": 1, "label": page.get("label")} if frappe.db.exists("Workspace", page.get("name")): @@ -227,7 +186,7 @@ def sort_pages(sb_public_items, sb_private_items): if sb_private_items: sort_page(wspace_private_pages, sb_private_items) - if sb_public_items and "Workspace Manager" in frappe.get_roles(): + if sb_public_items and is_workspace_manager(): sort_page(wspace_public_pages, sb_public_items) def sort_page(wspace_pages, pages): @@ -242,3 +201,6 @@ def sort_page(wspace_pages, pages): def get_page_list(fields, filters): return frappe.get_list("Workspace", fields=fields, filters=filters, order_by='sequence_id asc') + +def is_workspace_manager(): + return "Workspace Manager" in frappe.get_roles() diff --git a/frappe/desk/form/utils.py b/frappe/desk/form/utils.py index 1c954edff0..291767de10 100644 --- a/frappe/desk/form/utils.py +++ b/frappe/desk/form/utils.py @@ -16,44 +16,6 @@ def remove_attach(): file_name = frappe.form_dict.get('file_name') frappe.delete_doc('File', fid) -@frappe.whitelist() -def validate_link(): - """validate link when updated by user""" - import frappe - import frappe.utils - - value, options, fetch = frappe.form_dict.get('value'), frappe.form_dict.get('options'), frappe.form_dict.get('fetch') - - # no options, don't validate - if not options or options=='null' or options=='undefined': - frappe.response['message'] = 'Ok' - return - - valid_value = frappe.db.get_all(options, filters=dict(name=value), as_list=1, limit=1) - - if valid_value: - valid_value = valid_value[0][0] - - # get fetch values - if fetch: - # escape with "`" - fetch = ", ".join(("`{0}`".format(f.strip()) for f in fetch.split(","))) - fetch_value = None - try: - fetch_value = frappe.db.sql("select %s from `tab%s` where name=%s" - % (fetch, options, '%s'), (value,))[0] - except Exception as e: - error_message = str(e).split("Unknown column '") - fieldname = None if len(error_message)<=1 else error_message[1].split("'")[0] - frappe.msgprint(_("Wrong fieldname {0} in add_fetch configuration of custom client script").format(fieldname)) - frappe.errprint(frappe.get_traceback()) - - if fetch_value: - frappe.response['fetch_values'] = [frappe.utils.parse_val(c) for c in fetch_value] - - frappe.response['valid_value'] = valid_value - frappe.response['message'] = 'Ok' - @frappe.whitelist() def add_comment(reference_doctype, reference_name, content, comment_email, comment_by): diff --git a/frappe/desk/listview.py b/frappe/desk/listview.py index e733adf868..43ad104f0d 100644 --- a/frappe/desk/listview.py +++ b/frappe/desk/listview.py @@ -26,7 +26,7 @@ def get_group_by_count(doctype, current_filters, field): current_filters = frappe.parse_json(current_filters) subquery_condition = '' - subquery = frappe.get_all(doctype, filters=current_filters, return_query = True) + subquery = frappe.get_all(doctype, filters=current_filters, run=False) if field == 'assigned_to': subquery_condition = ' and `tabToDo`.reference_name in ({subquery})'.format(subquery = subquery) return frappe.db.sql("""select `tabToDo`.owner as name, count(*) as count diff --git a/frappe/integrations/workspace/integrations/integrations.json b/frappe/integrations/workspace/integrations/integrations.json index 4167858db2..b85056e3ef 100644 --- a/frappe/integrations/workspace/integrations/integrations.json +++ b/frappe/integrations/workspace/integrations/integrations.json @@ -1,20 +1,13 @@ { - "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": 0, "docstatus": 0, "doctype": "Workspace", - "extends": "", - "extends_another_page": 0, "for_user": "", "hide_custom": 0, "icon": "integration", "idx": 0, - "is_default": 0, - "is_standard": 0, "label": "Integrations", "links": [ { @@ -267,15 +260,12 @@ "type": "Link" } ], - "modified": "2021-08-05 12:16:00.355267", + "modified": "2021-08-05 12:16:00.355268", "modified_by": "Administrator", "module": "Integrations", "name": "Integrations", - "onboarding": "", "owner": "Administrator", "parent_page": "", - "pin_to_bottom": 0, - "pin_to_top": 0, "public": 1, "restrict_to_domain": "", "roles": [], diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index 8f0e0aaefc..44f1398cc7 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -35,7 +35,7 @@ class DatabaseQuery(object): join='left join', distinct=False, start=None, page_length=None, limit=None, ignore_ifnull=False, save_user_settings=False, save_user_settings_fields=False, update=None, add_total_row=None, user_settings=None, reference_doctype=None, - return_query=False, strict=True, pluck=None, ignore_ddl=False) -> List: + run=True, strict=True, pluck=None, ignore_ddl=False) -> List: if not ignore_permissions and \ not frappe.has_permission(self.doctype, "select", user=user) and \ not frappe.has_permission(self.doctype, "read", user=user): @@ -87,7 +87,7 @@ class DatabaseQuery(object): self.user = user or frappe.session.user self.update = update self.user_settings_fields = copy.deepcopy(self.fields) - self.return_query = return_query + self.run = run self.strict = strict self.ignore_ddl = ignore_ddl @@ -104,8 +104,6 @@ class DatabaseQuery(object): if not self.columns: return [] result = self.build_and_run() - if return_query: - return result if with_comment_count and not as_list and self.doctype: self.add_comment_count(result) @@ -137,11 +135,8 @@ class DatabaseQuery(object): %(order_by)s %(limit)s""" % args - if self.return_query: - return query - else: - return frappe.db.sql(query, as_dict=not self.as_list, debug=self.debug, - update=self.update, ignore_ddl=self.ignore_ddl) + return frappe.db.sql(query, as_dict=not self.as_list, debug=self.debug, + update=self.update, ignore_ddl=self.ignore_ddl, run=self.run) def prepare_args(self): self.parse_args() diff --git a/frappe/model/rename_doc.py b/frappe/model/rename_doc.py index 14f1dbf2b0..de83b24cd8 100644 --- a/frappe/model/rename_doc.py +++ b/frappe/model/rename_doc.py @@ -458,7 +458,7 @@ def bulk_rename(doctype, rows=None, via_console = False): """Bulk rename documents :param doctype: DocType to be renamed - :param rows: list of documents as `((oldname, newname), ..)`""" + :param rows: list of documents as `((oldname, newname, merge(optional)), ..)`""" if not rows: frappe.throw(_("Please select a valid csv file with data")) @@ -471,8 +471,9 @@ def bulk_rename(doctype, rows=None, via_console = False): for row in rows: # if row has some content if len(row) > 1 and row[0] and row[1]: + merge = len(row) > 2 and (row[2] == "1" or row[2].lower() == "true") try: - if rename_doc(doctype, row[0], row[1]): + if rename_doc(doctype, row[0], row[1], merge=merge): msg = _("Successful: {0} to {1}").format(row[0], row[1]) frappe.db.commit() else: diff --git a/frappe/modules/export_file.py b/frappe/modules/export_file.py index 17e84ee488..ab6ffd4985 100644 --- a/frappe/modules/export_file.py +++ b/frappe/modules/export_file.py @@ -24,7 +24,7 @@ def write_document_file(doc, record_module=None, create_init=True, folder_name=N doc_export = doc.as_dict(no_nulls=True) doc.run_method("before_export", doc_export) - strip_default_fields(doc, doc_export) + doc_export = strip_default_fields(doc, doc_export) module = record_module or get_module_name(doc) # create folder @@ -42,12 +42,17 @@ def write_document_file(doc, record_module=None, create_init=True, folder_name=N def strip_default_fields(doc, doc_export): # strip out default fields from children + if doc.doctype == "DocType" and doc.migration_hash: + del doc_export["migration_hash"] + for df in doc.meta.get_table_fields(): for d in doc_export.get(df.fieldname): for fieldname in frappe.model.default_fields: if fieldname in d: del d[fieldname] + return doc_export + def write_code_files(folder, fname, doc, doc_export): '''Export code files and strip from values''' if hasattr(doc, 'get_code_fields'): @@ -59,8 +64,6 @@ def write_code_files(folder, fname, doc, doc_export): # remove from exporting del doc_export[key] - - def get_module_name(doc): if doc.doctype == 'Module Def': module = doc.name diff --git a/frappe/patches.txt b/frappe/patches.txt index 41ca1a1724..85df031073 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -182,4 +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 # 25.08.2021 +frappe.patches.v14_0.update_workspace2 # 20.09.2021 diff --git a/frappe/patches/v11_0/remove_skip_for_doctype.py b/frappe/patches/v11_0/remove_skip_for_doctype.py index 638a5a0fd7..1bbe74bb6d 100644 --- a/frappe/patches/v11_0/remove_skip_for_doctype.py +++ b/frappe/patches/v11_0/remove_skip_for_doctype.py @@ -2,6 +2,7 @@ import frappe from frappe.desk.form.linked_with import get_linked_doctypes from frappe.patches.v11_0.replicate_old_user_permissions import get_doctypes_to_skip +from frappe.query_builder import Field # `skip_for_doctype` was a un-normalized way of storing for which # doctypes the user permission was applicable. @@ -72,16 +73,12 @@ def execute(): frappe.db.set_value('User Permission', user_permission.name, 'apply_to_all_doctypes', 1) if new_user_permissions_list: - frappe.db.sql(''' - INSERT INTO `tabUser Permission` - (`name`, `user`, `allow`, `for_value`, `applicable_for`, `apply_to_all_doctypes`, `creation`, `modified`) - VALUES {} - '''.format( # nosec - ', '.join(['%s'] * len(new_user_permissions_list)) - ), tuple(new_user_permissions_list)) + frappe.qb.into("User Permission").columns( + "name", "user", "allow", "for_value", "applicable_for", "apply_to_all_doctypes", "creation", "modified" + ).insert(*new_user_permissions_list).run() if user_permissions_to_delete: - frappe.db.sql('DELETE FROM `tabUser Permission` WHERE `name` in ({})' # nosec - .format(','.join(['%s'] * len(user_permissions_to_delete))), - tuple(user_permissions_to_delete) + frappe.db.delete( + "User Permission", + filters=(Field("name").isin(tuple(user_permissions_to_delete))) ) diff --git a/frappe/patches/v14_0/update_workspace2.py b/frappe/patches/v14_0/update_workspace2.py index c212faee76..82076c4328 100644 --- a/frappe/patches/v14_0/update_workspace2.py +++ b/frappe/patches/v14_0/update_workspace2.py @@ -4,8 +4,8 @@ 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)): + + for seq, wspace in enumerate(frappe.get_all('Workspace', order_by='name asc')): doc = frappe.get_doc('Workspace', wspace.name) content = create_content(doc) update_wspace(doc, seq, content) @@ -53,7 +53,7 @@ def update_wspace(doc, seq, content): if not doc.title and not doc.content and not doc.is_standard and not doc.public: doc.sequence_id = seq + 1 doc.content = json.dumps(content) - doc.public = 0 + doc.public = 0 if doc.for_user else 1 doc.title = doc.extends or doc.label doc.extends = '' doc.category = '' diff --git a/frappe/permissions.py b/frappe/permissions.py index a086c73920..29651b4145 100644 --- a/frappe/permissions.py +++ b/frappe/permissions.py @@ -6,7 +6,7 @@ import frappe import frappe.share from frappe import _, msgprint from frappe.utils import cint - +from frappe.query_builder import DocType rights = ("select", "read", "write", "create", "delete", "submit", "cancel", "amend", "print", "email", "report", "import", "export", "set_user_permissions", "share") @@ -330,8 +330,7 @@ def get_all_perms(role): '''Returns valid permissions for a given role''' perms = frappe.get_all('DocPerm', fields='*', filters=dict(role=role)) custom_perms = frappe.get_all('Custom DocPerm', fields='*', filters=dict(role=role)) - doctypes_with_custom_perms = frappe.db.sql_list("""select distinct parent - from `tabCustom DocPerm`""") + doctypes_with_custom_perms = frappe.get_all("Custom DocPerm", pluck="parent", distinct=True) for p in perms: if p.parent not in doctypes_with_custom_perms: @@ -348,10 +347,13 @@ def get_roles(user=None, with_standard=True): def get(): if user == 'Administrator': - return [r[0] for r in frappe.db.sql("select name from `tabRole`")] # return all available roles + return frappe.get_all("Role", pluck="name") # return all available roles else: - return [r[0] for r in frappe.db.sql("""select role from `tabHas Role` - where parent=%s and role not in ('All', 'Guest')""", (user,))] + ['All', 'Guest'] + table = DocType("Has Role") + roles = frappe.qb.from_(table).where( + (table.parent == user) & (table.role.notin(["All", "Guest"])) + ).select(table.role).run(pluck=True) + return roles + ['All', 'Guest'] roles = frappe.cache().hget("roles", user, get) @@ -460,10 +462,9 @@ def update_permission_property(doctype, role, permlevel, ptype, value=None, vali name = frappe.get_value('Custom DocPerm', dict(parent=doctype, role=role, permlevel=permlevel)) + table = DocType("Custom DocPerm") + frappe.qb.update(table).set(ptype, value).where(table.name == name).run() - frappe.db.sql(""" - update `tabCustom DocPerm` - set `{0}`=%s where name=%s""".format(ptype), (value, name)) if validate: validate_permissions_for_doctype(doctype) diff --git a/frappe/printing/doctype/letter_head/letter_head.json b/frappe/printing/doctype/letter_head/letter_head.json index f6c9def567..f723a6b489 100644 --- a/frappe/printing/doctype/letter_head/letter_head.json +++ b/frappe/printing/doctype/letter_head/letter_head.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_rename": 1, "autoname": "field:letter_head_name", "creation": "2012-11-22 17:45:46", @@ -13,6 +14,9 @@ "is_default", "letter_head_image_section", "image", + "image_height", + "image_width", + "align", "header_section", "content", "footer_section", @@ -100,15 +104,34 @@ "fieldname": "footer", "fieldtype": "HTML Editor", "label": "Footer HTML" + }, + { + "default": "Left", + "fieldname": "align", + "fieldtype": "Select", + "label": "Align", + "options": "Left\nRight\nCenter" + }, + { + "fieldname": "image_height", + "fieldtype": "Float", + "label": "Image Height" + }, + { + "fieldname": "image_width", + "fieldtype": "Float", + "label": "Image Width" } ], "icon": "fa fa-font", "idx": 1, + "links": [], "max_attachments": 3, - "modified": "2019-11-11 18:46:43.375120", + "modified": "2021-10-03 14:37:58.314696", "modified_by": "Administrator", "module": "Printing", "name": "Letter Head", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { diff --git a/frappe/printing/doctype/letter_head/letter_head.py b/frappe/printing/doctype/letter_head/letter_head.py index eeaef28393..67c0d236e0 100644 --- a/frappe/printing/doctype/letter_head/letter_head.py +++ b/frappe/printing/doctype/letter_head/letter_head.py @@ -2,7 +2,7 @@ # License: MIT. See LICENSE import frappe -from frappe.utils import is_image +from frappe.utils import is_image, flt from frappe.model.document import Document from frappe import _ @@ -26,7 +26,15 @@ class LetterHead(Document): def set_image(self): if self.source=='Image': if self.image and is_image(self.image): - self.content = ''.format(self.image) + self.image_width = flt(self.image_width) + self.image_height = flt(self.image_height) + dimension = 'width' if self.image_width > self.image_height else 'height' + dimension_value = self.get('image_' + dimension) + self.content = f''' +
+ {self.name} +
+ ''' frappe.msgprint(frappe._('Header HTML set from attachment {0}').format(self.image), alert = True) else: frappe.msgprint(frappe._('Please attach an image file to set HTML'), alert = True, indicator = 'orange') diff --git a/frappe/printing/doctype/print_format/print_format.js b/frappe/printing/doctype/print_format/print_format.js index 7b7009dbaf..3fd1d9d148 100644 --- a/frappe/printing/doctype/print_format/print_format.js +++ b/frappe/printing/doctype/print_format/print_format.js @@ -30,7 +30,11 @@ frappe.ui.form.on("Print Format", { frappe.msgprint(__("Please select DocType first")); return; } - frappe.set_route("print-format-builder", frm.doc.name); + if (frm.doc.print_format_builder_beta) { + frappe.set_route("print-format-builder-beta", frm.doc.name); + } else { + frappe.set_route("print-format-builder", frm.doc.name); + } }); } else if (frm.doc.custom_format && !frm.doc.raw_printing) { diff --git a/frappe/printing/doctype/print_format/print_format.json b/frappe/printing/doctype/print_format/print_format.json index 4032cef209..75ec0fa7fd 100644 --- a/frappe/printing/doctype/print_format/print_format.json +++ b/frappe/printing/doctype/print_format/print_format.json @@ -19,19 +19,26 @@ "html", "raw_commands", "section_break_9", + "margin_top", + "margin_bottom", + "margin_left", + "margin_right", "align_labels_right", "show_section_headings", "line_breaks", "absolute_value", "column_break_11", + "font_size", "font", + "page_number", "css_section", "css", "custom_html_help", "section_break_13", "print_format_help", "format_data", - "print_format_builder" + "print_format_builder", + "print_format_builder_beta" ], "fields": [ { @@ -149,12 +156,10 @@ "options": "Language" }, { - "default": "Default", "depends_on": "eval:!doc.custom_format", "fieldname": "font", - "fieldtype": "Select", - "label": "Font", - "options": "Default\nHelvetica Neue\nArial\nHelvetica\nVerdana\nMonospace" + "fieldtype": "Data", + "label": "Google Font" }, { "depends_on": "eval:!doc.raw_printing", @@ -205,16 +210,60 @@ "fieldname": "absolute_value", "fieldtype": "Check", "label": "Show Absolute Values" + }, + { + "default": "0", + "fieldname": "print_format_builder_beta", + "fieldtype": "Check", + "label": "Print Format Builder Beta" + }, + { + "default": "15", + "fieldname": "margin_top", + "fieldtype": "Float", + "label": "Margin Top" + }, + { + "default": "15", + "fieldname": "margin_bottom", + "fieldtype": "Float", + "label": "Margin Bottom" + }, + { + "default": "15", + "fieldname": "margin_left", + "fieldtype": "Float", + "label": "Margin Left" + }, + { + "default": "15", + "fieldname": "margin_right", + "fieldtype": "Float", + "label": "Margin Right" + }, + { + "default": "14", + "fieldname": "font_size", + "fieldtype": "Int", + "label": "Font Size" + }, + { + "default": "Hide", + "fieldname": "page_number", + "fieldtype": "Select", + "label": "Page Number", + "options": "Hide\nTop Left\nTop Center\nTop Right\nBottom Left\nBottom Center\nBottom Right" } ], "icon": "fa fa-print", "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2021-03-01 15:25:46.578863", + "modified": "2021-10-12 17:52:41.167107", "modified_by": "Administrator", "module": "Printing", "name": "Print Format", + "naming_rule": "Set by user", "owner": "Administrator", "permissions": [ { diff --git a/frappe/printing/doctype/print_format/print_format.py b/frappe/printing/doctype/print_format/print_format.py index 878a864b38..f19c0af9bf 100644 --- a/frappe/printing/doctype/print_format/print_format.py +++ b/frappe/printing/doctype/print_format/print_format.py @@ -7,10 +7,24 @@ import frappe.utils import json from frappe import _ from frappe.utils.jinja import validate_template - +from frappe.utils.weasyprint import get_html, download_pdf from frappe.model.document import Document class PrintFormat(Document): + def onload(self): + templates = frappe.db.get_all( + "Print Format Field Template", + fields=["template", "field", "name"], + filters={"document_type": self.doc_type}, + ) + self.set_onload("print_templates", templates) + + def get_html(self, docname, letterhead=None): + return get_html(self.doc_type, docname, self.name, letterhead) + + def download_pdf(self, docname, letterhead=None): + return download_pdf(self.doc_type, docname, self.name, letterhead) + def validate(self): if (self.standard=="Yes" and not frappe.local.conf.get("developer_mode") @@ -38,6 +52,10 @@ class PrintFormat(Document): def extract_images(self): from frappe.core.doctype.file.file import extract_images_from_html + + if self.print_format_builder_beta: + return + if self.format_data: data = json.loads(self.format_data) for df in data: diff --git a/frappe/printing/doctype/print_format_field_template/__init__.py b/frappe/printing/doctype/print_format_field_template/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/printing/doctype/print_format_field_template/print_format_field_template.js b/frappe/printing/doctype/print_format_field_template/print_format_field_template.js new file mode 100644 index 0000000000..7fbb0d7359 --- /dev/null +++ b/frappe/printing/doctype/print_format_field_template/print_format_field_template.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Print Format Field Template', { + // refresh: function(frm) { + + // } +}); diff --git a/frappe/printing/doctype/print_format_field_template/print_format_field_template.json b/frappe/printing/doctype/print_format_field_template/print_format_field_template.json new file mode 100644 index 0000000000..3b79aae7e8 --- /dev/null +++ b/frappe/printing/doctype/print_format_field_template/print_format_field_template.json @@ -0,0 +1,101 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "Prompt", + "creation": "2021-10-05 14:23:56.508499", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "document_type", + "field", + "template_file", + "column_break_3", + "module", + "standard", + "section_break_5", + "template" + ], + "fields": [ + { + "depends_on": "eval:!doc.multiple", + "fieldname": "document_type", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Document Type", + "mandatory_depends_on": "eval:!doc.multiple", + "options": "DocType", + "reqd": 1 + }, + { + "fieldname": "field", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Default Template For Field" + }, + { + "depends_on": "eval:!doc.standard", + "fieldname": "template", + "fieldtype": "Code", + "label": "Template", + "mandatory_depends_on": "eval:!doc.standard", + "options": "HTML" + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_5", + "fieldtype": "Section Break", + "hide_border": 1 + }, + { + "depends_on": "standard", + "fieldname": "module", + "fieldtype": "Link", + "label": "Module", + "options": "Module Def" + }, + { + "default": "0", + "fieldname": "standard", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Standard" + }, + { + "depends_on": "eval:doc.standard", + "fieldname": "template_file", + "fieldtype": "Data", + "label": "Template File", + "mandatory_depends_on": "eval:doc.standard" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-10-19 17:47:59.577949", + "modified_by": "Administrator", + "module": "Printing", + "name": "Print Format Field Template", + "naming_rule": "Set by user", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/printing/doctype/print_format_field_template/print_format_field_template.py b/frappe/printing/doctype/print_format_field_template/print_format_field_template.py new file mode 100644 index 0000000000..b66afdb6b1 --- /dev/null +++ b/frappe/printing/doctype/print_format_field_template/print_format_field_template.py @@ -0,0 +1,43 @@ +# Copyright (c) 2021, Frappe Technologies and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document +from frappe import _ + + +class PrintFormatFieldTemplate(Document): + def validate(self): + if self.standard and not (frappe.conf.developer_mode or frappe.flags.in_patch): + frappe.throw(_("Enable developer mode to create a standard Print Template")) + + def before_insert(self): + self.validate_duplicate() + + def on_update(self): + self.validate_duplicate() + self.export_doc() + + def validate_duplicate(self): + if not self.standard: + return + if not self.field: + return + + filters = {"document_type": self.document_type, "field": self.field} + if not self.is_new(): + filters.update({"name": ("!=", self.name)}) + result = frappe.db.get_all("Print Format Field Template", filters=filters, limit=1) + if result: + frappe.throw( + _("A template already exists for field {0} of {1}").format( + frappe.bold(self.field), frappe.bold(self.document_type) + ), + frappe.DuplicateEntryError, + title=_("Duplicate Entry"), + ) + + def export_doc(self): + from frappe.modules.utils import export_module_json + + export_module_json(self, self.standard, self.module) diff --git a/frappe/printing/doctype/print_format_field_template/test_print_format_field_template.py b/frappe/printing/doctype/print_format_field_template/test_print_format_field_template.py new file mode 100644 index 0000000000..f0b1329763 --- /dev/null +++ b/frappe/printing/doctype/print_format_field_template/test_print_format_field_template.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies and Contributors +# See license.txt + +# import frappe +import unittest + +class TestPrintFormatFieldTemplate(unittest.TestCase): + pass diff --git a/frappe/printing/page/print/print.js b/frappe/printing/page/print/print.js index 7f40fd3127..f10c703589 100644 --- a/frappe/printing/page/print/print.js +++ b/frappe/printing/page/print/print.js @@ -41,7 +41,11 @@ frappe.ui.form.PrintView = class {
- ` + +
+ +
+ ` ); this.print_settings = frappe.model.get_doc( @@ -72,7 +76,7 @@ frappe.ui.form.PrintView = class { this.page.add_button( __('PDF'), - () => this.render_page('/api/method/frappe.utils.print_format.download_pdf?'), + () => this.render_pdf(), { icon: 'small-file' } ); @@ -190,6 +194,13 @@ frappe.ui.form.PrintView = class { this.set_breadcrumbs(); this.setup_customize_dialog(); + // print format builder beta + this.page.add_inner_message(` + + ${__('Try the new Print Format Builder')} + + `); + let tasks = [ this.refresh_print_options, this.set_default_print_language, @@ -233,7 +244,7 @@ frappe.ui.form.PrintView = class { let print_format = this.get_print_format(); let is_custom_format = print_format.name && - print_format.print_format_builder && + (print_format.print_format_builder || print_format.print_format_builder_beta) && print_format.standard === 'No'; let is_standard_but_editable = print_format.name && print_format.custom_format; @@ -243,7 +254,11 @@ frappe.ui.form.PrintView = class { return; } if (is_custom_format) { - frappe.set_route('print-format-builder', print_format.name); + if (print_format.print_format_builder_beta) { + frappe.set_route('print-format-builder-beta', print_format.name); + } else { + frappe.set_route('print-format-builder', print_format.name); + } return; } // start a new print format @@ -261,6 +276,11 @@ frappe.ui.form.PrintView = class { fieldtype: 'Read Only', default: print_format.name || 'Standard', }, + { + label: __('Use the new Print Format Builder'), + fieldname: 'beta', + fieldtype: 'Check' + }, ], (data) => { frappe.route_options = { @@ -268,6 +288,7 @@ frappe.ui.form.PrintView = class { doctype: this.frm.doctype, name: data.print_format_name, based_on: data.based_on, + beta: data.beta }; frappe.set_route('print-format-builder'); this.print_sel.val(data.print_format_name); @@ -380,6 +401,17 @@ frappe.ui.form.PrintView = class { } preview() { + let print_format = this.get_print_format(); + if (print_format.print_format_builder_beta) { + this.print_wrapper.find('.print-preview-wrapper').hide(); + this.print_wrapper.find('.preview-beta-wrapper').show(); + this.preview_beta(); + return; + } + + this.print_wrapper.find('.preview-beta-wrapper').hide(); + this.print_wrapper.find('.print-preview-wrapper').show(); + const $print_format = this.print_wrapper.find('iframe'); this.$print_format_body = $print_format.contents(); this.get_print_html((out) => { @@ -403,6 +435,21 @@ frappe.ui.form.PrintView = class { }); } + preview_beta() { + let print_format = this.get_print_format(); + const iframe = this.print_wrapper.find('.preview-beta-wrapper iframe'); + let params = new URLSearchParams({ + doctype: this.frm.doc.doctype, + name: this.frm.doc.name, + print_format: print_format.name + }); + let letterhead = this.get_letterhead(); + if (letterhead) { + params.append("letterhead", letterhead); + } + iframe.prop('src', `/printpreview?${params.toString()}`); + } + setup_print_format_dom(out, $print_format) { this.print_wrapper.find('.print-format-skeleton').remove(); let base_url = frappe.urllib.get_base_url(); @@ -565,6 +612,26 @@ frappe.ui.form.PrintView = class { }, }); } + + render_pdf() { + let print_format = this.get_print_format(); + if (print_format.print_format_builder_beta) { + let params = new URLSearchParams({ + doctype: this.frm.doc.doctype, + name: this.frm.doc.name, + print_format: print_format.name, + letterhead: this.get_letterhead() + }); + let w = window.open(`/api/method/frappe.utils.weasyprint.download_pdf?${params}`); + if (!w) { + frappe.msgprint(__('Please enable pop-ups')); + return; + } + } else { + this.render_page('/api/method/frappe.utils.print_format.download_pdf?'); + } + } + render_page(method, printit = false) { let w = window.open( frappe.urllib.get_full_url( diff --git a/frappe/printing/page/print_format_builder/print_format_builder.js b/frappe/printing/page/print_format_builder/print_format_builder.js index b73ff31d32..313e8da539 100644 --- a/frappe/printing/page/print_format_builder/print_format_builder.js +++ b/frappe/printing/page/print_format_builder/print_format_builder.js @@ -12,9 +12,9 @@ frappe.pages['print-format-builder'].on_page_show = function(wrapper) { }); } else if(frappe.route_options) { if(frappe.route_options.make_new) { - let { doctype, name, based_on } = frappe.route_options; + let { doctype, name, based_on, beta } = frappe.route_options; frappe.route_options = null; - frappe.print_format_builder.setup_new_print_format(doctype, name, based_on); + frappe.print_format_builder.setup_new_print_format(doctype, name, based_on, beta); } else { frappe.print_format_builder.print_format = frappe.route_options.doc; frappe.route_options = null; @@ -126,18 +126,22 @@ frappe.PrintFormatBuilder = class PrintFormatBuilder { }); } - setup_new_print_format(doctype, name, based_on) { + setup_new_print_format(doctype, name, based_on, beta) { frappe.call({ method: 'frappe.printing.page.print_format_builder.print_format_builder.create_custom_format', args: { doctype: doctype, name: name, - based_on: based_on + based_on: based_on, + beta: Boolean(beta) }, callback: (r) => { - if(!r.exc) { - if(r.message) { - this.print_format = r.message; + if (r.message) { + let print_format = r.message; + if (print_format.print_format_builder_beta) { + frappe.set_route('print-format-builder-beta', print_format.name); + } else { + this.print_format = print_format; this.refresh(); } } diff --git a/frappe/printing/page/print_format_builder/print_format_builder.py b/frappe/printing/page/print_format_builder/print_format_builder.py index d9f57762b0..fae564d3c3 100644 --- a/frappe/printing/page/print_format_builder/print_format_builder.py +++ b/frappe/printing/page/print_format_builder/print_format_builder.py @@ -1,11 +1,16 @@ import frappe @frappe.whitelist() -def create_custom_format(doctype, name, based_on='Standard'): +def create_custom_format(doctype, name, based_on='Standard', beta=False): doc = frappe.new_doc('Print Format') doc.doc_type = doctype doc.name = name - doc.print_format_builder = 1 + beta = frappe.parse_json(beta) + + if beta: + doc.print_format_builder_beta = 1 + else: + doc.print_format_builder = 1 doc.format_data = frappe.db.get_value('Print Format', based_on, 'format_data') \ if based_on != 'Standard' else None doc.insert() diff --git a/frappe/printing/page/print_format_builder_beta/__init__.py b/frappe/printing/page/print_format_builder_beta/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/printing/page/print_format_builder_beta/print_format_builder_beta.css b/frappe/printing/page/print_format_builder_beta/print_format_builder_beta.css new file mode 100644 index 0000000000..0bd8d9c0f3 --- /dev/null +++ b/frappe/printing/page/print_format_builder_beta/print_format_builder_beta.css @@ -0,0 +1,3 @@ +.layout-main-section-wrapper { + margin-bottom: 0; +} diff --git a/frappe/printing/page/print_format_builder_beta/print_format_builder_beta.js b/frappe/printing/page/print_format_builder_beta/print_format_builder_beta.js new file mode 100644 index 0000000000..e923bbcb00 --- /dev/null +++ b/frappe/printing/page/print_format_builder_beta/print_format_builder_beta.js @@ -0,0 +1,122 @@ +frappe.pages["print-format-builder-beta"].on_page_load = function(wrapper) { + frappe.ui.make_app_page({ + parent: wrapper, + title: __("Print Format Builder"), + single_column: true + }); + + // hot reload in development + if (frappe.boot.developer_mode) { + frappe.hot_update = frappe.hot_update || []; + frappe.hot_update.push(() => load_print_format_builder_beta(wrapper)); + } +}; + +frappe.pages["print-format-builder-beta"].on_page_show = function(wrapper) { + load_print_format_builder_beta(wrapper); +}; + +function load_print_format_builder_beta(wrapper) { + let route = frappe.get_route(); + let $parent = $(wrapper).find(".layout-main-section"); + $parent.empty(); + + if (route.length > 1) { + frappe.require("print_format_builder.bundle.js").then(() => { + frappe.print_format_builder = new frappe.ui.PrintFormatBuilder({ + wrapper: $parent, + page: wrapper.page, + print_format: route[1] + }); + }); + } else { + let d = new frappe.ui.Dialog({ + title: __("Create or Edit Print Format"), + fields: [ + { + label: __("Action"), + fieldname: "action", + fieldtype: "Select", + options: [ + { label: __("Create New"), value: "Create" }, + { label: __("Edit Existing"), value: "Edit" } + ], + change() { + let action = d.get_value("action"); + d.get_primary_btn().text( + action === "Create" ? __("Create") : __("Edit") + ); + } + }, + { + label: __("Select Document Type"), + fieldname: "doctype", + fieldtype: "Link", + options: "DocType", + filters: { + istable: 0 + }, + reqd: 1, + default: frappe.route_options + ? frappe.route_options.doctype + : null + }, + { + label: __("Print Format Name"), + fieldname: "print_format_name", + fieldtype: "Data", + depends_on: doc => doc.action === "Create", + mandatory_depends_on: doc => doc.action === "Create" + }, + { + label: __("Select Print Format"), + fieldname: "print_format", + fieldtype: "Link", + options: "Print Format", + only_select: 1, + depends_on: doc => doc.action === "Edit", + get_query() { + return { + filters: { + doc_type: d.get_value("doctype"), + print_format_builder_beta: 1 + } + }; + }, + mandatory_depends_on: doc => doc.action === "Edit" + } + ], + primary_action_label: __("Edit"), + primary_action({ + action, + doctype, + print_format, + print_format_name + }) { + if (action === "Edit") { + frappe.set_route("print-format-builder-beta", print_format); + } else if (action === "Create") { + d.get_primary_btn().prop("disabled", true); + frappe.db + .insert({ + doctype: "Print Format", + name: print_format_name, + doc_type: doctype, + print_format_builder_beta: 1 + }) + .then(doc => { + frappe.set_route( + "print-format-builder-beta", + doc.name + ); + }) + .finally(() => { + d.get_primary_btn().prop("disabled", false); + }); + } + } + }); + d.set_value("action", "Create"); + d.show(); + } +} diff --git a/frappe/printing/page/print_format_builder_beta/print_format_builder_beta.json b/frappe/printing/page/print_format_builder_beta/print_format_builder_beta.json new file mode 100644 index 0000000000..a5b1288bc0 --- /dev/null +++ b/frappe/printing/page/print_format_builder_beta/print_format_builder_beta.json @@ -0,0 +1,22 @@ +{ + "content": null, + "creation": "2021-07-10 12:22:16.138485", + "docstatus": 0, + "doctype": "Page", + "idx": 0, + "modified": "2021-07-10 12:22:16.138485", + "modified_by": "Administrator", + "module": "Printing", + "name": "print-format-builder-beta", + "owner": "Administrator", + "page_name": "Print Format Builder Beta", + "roles": [ + { + "role": "System Manager" + } + ], + "script": null, + "standard": "Yes", + "style": null, + "system_page": 0 +} \ No newline at end of file diff --git a/frappe/printing/page/print_format_builder_beta/print_format_builder_beta.py b/frappe/printing/page/print_format_builder_beta/print_format_builder_beta.py new file mode 100644 index 0000000000..e13412cd07 --- /dev/null +++ b/frappe/printing/page/print_format_builder_beta/print_format_builder_beta.py @@ -0,0 +1,17 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +from __future__ import unicode_literals +import frappe +import functools + + +@frappe.whitelist() +def get_google_fonts(): + return _get_google_fonts() + + +@functools.lru_cache() +def _get_google_fonts(): + file_path = frappe.get_app_path("frappe", "data", "google_fonts.json") + return frappe.parse_json(frappe.read_file(file_path)) diff --git a/frappe/public/js/frappe/build_events/build_events.bundle.js b/frappe/public/js/frappe/build_events/build_events.bundle.js index 13b9c7a334..21960f0b00 100644 --- a/frappe/public/js/frappe/build_events/build_events.bundle.js +++ b/frappe/public/js/frappe/build_events/build_events.bundle.js @@ -7,6 +7,34 @@ let error = null; frappe.realtime.on("build_event", data => { if (data.success) { + // remove executed cache for rebuilt files + let changed_files = data.changed_files; + if (Array.isArray(changed_files)) { + for (let file of changed_files) { + if (file.includes(".bundle.")) { + let parts = file.split(".bundle."); + if (parts.length === 2) { + let filename = parts[0].split("/").slice(-1)[0]; + + frappe.assets.executed_ = frappe.assets.executed_.filter( + asset => !asset.includes(`${filename}.bundle`) + ); + } + } + } + } + // update assets json + frappe.call("frappe.sessions.get_boot_assets_json").then(r => { + if (r.message) { + frappe.boot.assets_json = r.message; + + if (frappe.hot_update) { + frappe.hot_update.forEach(callback => { + callback(); + }); + } + } + }); show_build_success(data); } else if (data.error) { show_build_error(data); diff --git a/frappe/public/js/frappe/desk.js b/frappe/public/js/frappe/desk.js index a4dc1a6709..a53368d67a 100644 --- a/frappe/public/js/frappe/desk.js +++ b/frappe/public/js/frappe/desk.js @@ -283,7 +283,7 @@ frappe.Application = class Application { frappe.workspaces = {}; for (let page of frappe.boot.allowed_workspaces || []) { frappe.modules[page.module]=page; - frappe.workspaces[frappe.router.slug(page.title)] = page; + frappe.workspaces[frappe.router.slug(page.name)] = page; } } diff --git a/frappe/public/js/frappe/form/controls/barcode.js b/frappe/public/js/frappe/form/controls/barcode.js index 04bdb3f19e..3a678b6a97 100644 --- a/frappe/public/js/frappe/form/controls/barcode.js +++ b/frappe/public/js/frappe/form/controls/barcode.js @@ -56,9 +56,6 @@ frappe.ui.form.ControlBarcode = class ControlBarcode extends frappe.ui.form.Cont get_options(value) { // get JsBarcode options let options = {}; - options.background = "var(--control-bg)"; - options.lineColor = "var(--text-color)"; - options.font = "var(--font-stack)"; options.fontSize = "16"; options.width = "3"; options.height = "50"; diff --git a/frappe/public/js/frappe/form/controls/link.js b/frappe/public/js/frappe/form/controls/link.js index 83f3f8dd70..e7339372b3 100644 --- a/frappe/public/js/frappe/form/controls/link.js +++ b/frappe/public/js/frappe/form/controls/link.js @@ -451,51 +451,55 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat return this.validate_link_and_fetch(this.df, this.get_options(), this.docname, value); } - validate_link_and_fetch(df, doctype, docname, value) { - if(value) { - return new Promise((resolve) => { - var fetch = ''; - if(this.frm && this.frm.fetch_dict[df.fieldname]) { - fetch = this.frm.fetch_dict[df.fieldname].columns.join(', '); - } - // if default and no fetch, no need to validate - if (!fetch && df.__default_value && df.__default_value===value) { - resolve(value); - } + validate_link_and_fetch(df, options, docname, value) { + if (!value) return; - this.fetch_and_validate_link(resolve, df, doctype, docname, value, fetch); - }); - } - } + return new Promise((resolve) => { + const fetch_map = this.fetch_map; - fetch_and_validate_link(resolve, df, doctype, docname, value, fetch) { - frappe.call({ - method: 'frappe.desk.form.utils.validate_link', - type: "GET", - args: { - 'value': value, - 'options': doctype, - 'fetch': fetch - }, - no_spinner: true, - callback: (r) => { - if (r.message=='Ok') { - if (r.fetch_values && docname) { - this.set_fetch_values(df, docname, r.fetch_values); - } - resolve(r.valid_value); - } else { - resolve(""); - } + // if default and no fetch, no need to validate + if ($.isEmptyObject(fetch_map) && df.__default_value === value) { + return resolve(value); } + + frappe.db.get_value( + options, + value, + ["name", ...Object.values(fetch_map)], + (response) => { + if (!response.name) { + return resolve(""); + } + + if (docname) { + for (const [target_field, source_field] of Object.entries(fetch_map)) { + frappe.model.set_value( + df.parent, + docname, + target_field, + response[source_field], + df.fieldtype, + ); + } + } + + return resolve(response.name); + } + ) }); } - set_fetch_values(df, docname, fetch_values) { - var fl = this.frm.fetch_dict[df.fieldname].fields; - for(var i=0; i < fl.length; i++) { - frappe.model.set_value(df.parent, docname, fl[i], fetch_values[i], df.fieldtype); + get fetch_map() { + const fetch_map = {}; + if (!this.frm) return fetch_map; + + for (const key of ["*", this.df.parent]) { + if (this.frm.fetch_dict[key] && this.frm.fetch_dict[key][this.df.fieldname]) { + Object.assign(fetch_map, this.frm.fetch_dict[key][this.df.fieldname]); + } } + + return fetch_map; } }; diff --git a/frappe/public/js/frappe/form/footer/form_timeline.js b/frappe/public/js/frappe/form/footer/form_timeline.js index 128bd355ad..b9ec9a5438 100644 --- a/frappe/public/js/frappe/form/footer/form_timeline.js +++ b/frappe/public/js/frappe/form/footer/form_timeline.js @@ -302,7 +302,7 @@ class FormTimeline extends BaseTimeline { (this.doc_info.info_logs || []).forEach(info_log => { info_timeline_contents.push({ creation: info_log.creation, - content: `${this.get_user_link(info_log.comment_email)} ${info_log.content}`, + content: `${this.get_user_link(info_log.owner)} ${info_log.content}`, }); }); return info_timeline_contents; diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index a095956dfe..75d68b12db 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -1112,12 +1112,24 @@ frappe.ui.form.Form = class FrappeForm { } // UTILITIES - add_fetch(link_field, src_field, tar_field) { - if(!this.fetch_dict[link_field]) { - this.fetch_dict[link_field] = {'columns':[], 'fields':[]}; - } - this.fetch_dict[link_field].columns.push(src_field); - this.fetch_dict[link_field].fields.push(tar_field); + add_fetch(link_field, source_field, target_field, target_doctype) { + /* + Example fetch dict to get sender_email from email_id field in sender: + { + "Notification": { + "sender": { + "sender_email": "email_id" + } + } + } + */ + + if (!target_doctype) target_doctype = "*"; + + // Target field kept as key because source field could be non-unique + this.fetch_dict + .setDefault(target_doctype, {}) + .setDefault(link_field, {})[target_field] = source_field; } has_perm(ptype) { diff --git a/frappe/public/js/frappe/form/grid_row.js b/frappe/public/js/frappe/form/grid_row.js index de174cf37f..311a5b7a1e 100644 --- a/frappe/public/js/frappe/form/grid_row.js +++ b/frappe/public/js/frappe/form/grid_row.js @@ -320,7 +320,7 @@ export default class GridRow { @@ -397,7 +397,7 @@ export default class GridRow { ${frappe.utils.icon('drag', 'xs')}
- ${docfield.label} + ${__(docfield.label)}
1) { - // me.global.get_awesome_bar_options(txt.toLowerCase(), me); - // } - var $this = $(this); - clearTimeout($this.data('timeout')); + me.options = []; - $this.data('timeout', setTimeout(function(){ - me.options = []; - if(txt && txt.length > 1) { - if(last_space !== -1) { - me.set_specifics(txt.slice(0,last_space), txt.slice(last_space+1)); - } - me.add_defaults(txt); - me.options = me.options.concat(me.build_options(txt)); - me.options = me.options.concat(me.global_results); - } else { - me.options = me.options.concat( - me.deduplicate(frappe.search.utils.get_recent_pages(txt || ""))); - me.options = me.options.concat(frappe.search.utils.get_frequent_links()); + if (txt && txt.length > 1) { + if (last_space !== -1) { + me.set_specifics(txt.slice(0, last_space), txt.slice(last_space+1)); } - me.add_help(); + me.add_defaults(txt); + me.options = me.options.concat(me.build_options(txt)); + me.options = me.options.concat(me.global_results); + } else { + me.options = me.options.concat( + me.deduplicate(frappe.search.utils.get_recent_pages(txt || ""))); + me.options = me.options.concat(frappe.search.utils.get_frequent_links()); + } + me.add_help(); - awesomplete.list = me.deduplicate(me.options); - }, 100)); + awesomplete.list = me.deduplicate(me.options); - }); + }, 500)); var open_recent = function() { if (!this.autocomplete_open) { diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js index b49dfa0280..2baff996c6 100644 --- a/frappe/public/js/frappe/utils/utils.js +++ b/frappe/public/js/frappe/utils/utils.js @@ -23,6 +23,14 @@ if (!Array.prototype.uniqBy) { }); } +// Python's dict.setdefault ported for JS objects +Object.defineProperty(Object.prototype, "setDefault", { + value: function(key, default_value) { + if (!(key in this)) this[key] = default_value; + return this[key]; + } +}); + // Pluralize String.prototype.plural = function(revert) { const plural = { diff --git a/frappe/public/js/frappe/views/reports/query_report.js b/frappe/public/js/frappe/views/reports/query_report.js index 4a3a953a01..04cc1b9880 100644 --- a/frappe/public/js/frappe/views/reports/query_report.js +++ b/frappe/public/js/frappe/views/reports/query_report.js @@ -517,9 +517,6 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { } else { this.page.show_form(); } - - this.page.body[0].style.setProperty('--report-filter-height', this.page.page_form.css('height')); - this.page.body.parent().css('margin-bottom', 'unset'); } set_filters(filters) { @@ -834,7 +831,6 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { if (this.raw_data.add_total_row) { data = data.slice(); data.splice(-1, 1); - this.$page.find('.layout-main-section')[0].style.setProperty('--report-total-height', '310px'); } this.$report.show(); diff --git a/frappe/public/js/frappe/views/reports/report_view.js b/frappe/public/js/frappe/views/reports/report_view.js index 2a92d93e30..8866a4b2af 100644 --- a/frappe/public/js/frappe/views/reports/report_view.js +++ b/frappe/public/js/frappe/views/reports/report_view.js @@ -50,8 +50,6 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView { this.setup_columns(); super.setup_new_doc_event(); this.page.main.addClass('report-view'); - this.page.body[0].style.setProperty('--report-filter-height', this.page.page_form.css('height')); - this.page.body.parent().css('margin-bottom', 'unset'); } toggle_side_bar() { diff --git a/frappe/public/js/frappe/views/workspace/blocks/card.js b/frappe/public/js/frappe/views/workspace/blocks/card.js index 15e27fed40..9b4a2ed14f 100644 --- a/frappe/public/js/frappe/views/workspace/blocks/card.js +++ b/frappe/public/js/frappe/views/workspace/blocks/card.js @@ -30,7 +30,7 @@ export default class Card extends Block { this.new('card', 'links'); if (this.data && this.data.card_name) { - let has_data = this.make('card', this.data.card_name, 'links'); + let has_data = this.make('card', __(this.data.card_name), 'links'); if (!has_data) return; } diff --git a/frappe/public/js/frappe/views/workspace/blocks/chart.js b/frappe/public/js/frappe/views/workspace/blocks/chart.js index e41063e6fc..02e6a66e6f 100644 --- a/frappe/public/js/frappe/views/workspace/blocks/chart.js +++ b/frappe/public/js/frappe/views/workspace/blocks/chart.js @@ -30,7 +30,7 @@ export default class Chart extends Block { this.new('chart'); if (this.data && this.data.chart_name) { - let has_data = this.make('chart', this.data.chart_name); + let has_data = this.make('chart', __(this.data.chart_name)); if (!has_data) return; } diff --git a/frappe/public/js/frappe/views/workspace/blocks/header.js b/frappe/public/js/frappe/views/workspace/blocks/header.js index 356f9c3244..d88bc42af9 100644 --- a/frappe/public/js/frappe/views/workspace/blocks/header.js +++ b/frappe/public/js/frappe/views/workspace/blocks/header.js @@ -27,7 +27,7 @@ export default class Header extends Block { data = {}; } - newData.text = data.text || ''; + newData.text = (data.text && __(data.text.replace(/(\n|\t)/gm, ""))) || ''; newData.level = parseInt(data.level) || this.defaultLevel.number; newData.col = parseInt(data.col) || 12; diff --git a/frappe/public/js/frappe/views/workspace/blocks/paragraph.js b/frappe/public/js/frappe/views/workspace/blocks/paragraph.js index 26afa65d51..9e5dfb68ff 100644 --- a/frappe/public/js/frappe/views/workspace/blocks/paragraph.js +++ b/frappe/public/js/frappe/views/workspace/blocks/paragraph.js @@ -177,7 +177,7 @@ export default class Paragraph extends Block { set data(data) { this._data = data || {}; - this._element.innerHTML = this._data.text || ''; + this._element.innerHTML = __(this._data.text) || ''; } static get pasteConfig() { diff --git a/frappe/public/js/frappe/views/workspace/blocks/shortcut.js b/frappe/public/js/frappe/views/workspace/blocks/shortcut.js index f7482a06f3..96b8f47484 100644 --- a/frappe/public/js/frappe/views/workspace/blocks/shortcut.js +++ b/frappe/public/js/frappe/views/workspace/blocks/shortcut.js @@ -29,7 +29,7 @@ export default class Shortcut extends Block { this.new('shortcut'); if (this.data && this.data.shortcut_name) { - let has_data = this.make('shortcut', this.data.shortcut_name); + let has_data = this.make('shortcut', __(this.data.shortcut_name)); if (!has_data) return; } diff --git a/frappe/public/js/frappe/views/workspace/workspace.js b/frappe/public/js/frappe/views/workspace/workspace.js index 8989814349..e6248f66cf 100644 --- a/frappe/public/js/frappe/views/workspace/workspace.js +++ b/frappe/public/js/frappe/views/workspace/workspace.js @@ -51,38 +51,37 @@ frappe.views.Workspace = class Workspace { this.body = this.wrapper.find(".layout-main-section"); } - setup_pages(reload) { - this.get_pages().then(pages => { - this.all_pages = pages.pages; - this.has_access = pages.has_access; + async setup_pages(reload) { + this.sidebar_pages = !this.discard ? await this.get_pages() : this.sidebar_pages; + this.all_pages = this.sidebar_pages.pages; + this.has_access = this.sidebar_pages.has_access; - this.all_pages.forEach(page => { - page.is_editable = !page.public || pages.has_access; - }); - - this.public_pages = this.all_pages.filter(page => page.public); - this.private_pages = this.all_pages.filter(page => !page.public); - - if (this.all_pages) { - frappe.workspaces = {}; - for (let page of this.all_pages) { - frappe.workspaces[frappe.router.slug(page.title)] = {title: page.title}; - } - if (this.new_page && this.new_page.name) { - if (!frappe.workspaces[frappe.router.slug(this.new_page.name)]) { - this.new_page = { name: this.all_pages[0].title, public: this.all_pages[0].public }; - } - if (this.new_page.public) { - frappe.set_route(`${frappe.router.slug(this.new_page.name)}`); - } else { - frappe.set_route(`private/${frappe.router.slug(this.new_page.name)}`); - } - this.new_page = null; - } - this.make_sidebar(); - reload && this.show(); - } + this.all_pages.forEach(page => { + page.is_editable = !page.public || this.has_access; }); + + this.public_pages = this.all_pages.filter(page => page.public); + this.private_pages = this.all_pages.filter(page => !page.public); + + if (this.all_pages) { + frappe.workspaces = {}; + for (let page of this.all_pages) { + frappe.workspaces[frappe.router.slug(page.name)] = {title: page.title}; + } + if (this.new_page && this.new_page.name) { + if (!frappe.workspaces[frappe.router.slug(this.new_page.label)]) { + this.new_page = { name: this.all_pages[0].title, public: this.all_pages[0].public }; + } + if (this.new_page.public) { + frappe.set_route(`${frappe.router.slug(this.new_page.name)}`); + } else { + frappe.set_route(`private/${frappe.router.slug(this.new_page.name)}`); + } + this.new_page = null; + } + this.make_sidebar(); + reload && this.show(); + } } get_pages() { @@ -95,10 +94,10 @@ frappe.views.Workspace = class Workspace {
${frappe.utils.icon(item.icon || "folder-normal", "md")} - ${item.title} + ${__(item.title)}
@@ -152,8 +151,8 @@ frappe.views.Workspace = class Workspace { append_item(item, container) { let is_current_page = frappe.router.slug(item.title) == frappe.router.slug(this.get_page_to_show().name) && item.public == this.get_page_to_show().public; + item.selected = is_current_page; if (is_current_page) { - item.selected = true; this.current_page = { name: item.title, public: item.public }; } @@ -219,14 +218,14 @@ frappe.views.Workspace = class Workspace { if (!this.page_data || Object.keys(this.page_data).length === 0) return; + if (this.page_data.charts && this.page_data.charts.items.length === 0) return; + return frappe.dashboard_utils.get_dashboard_settings().then(settings => { if (settings) { let chart_config = settings.chart_config ? JSON.parse(settings.chart_config) : {}; - if (this.page_data.charts && this.page_data.charts.items) { - this.page_data.charts.items.map(chart => { - chart.chart_settings = chart_config[chart.chart_name] || {}; - }); - } + this.page_data.charts.items.map(chart => { + chart.chart_settings = chart_config[chart.chart_name] || {}; + }); this.pages[page.name] = this.page_data; } }); @@ -272,8 +271,7 @@ frappe.views.Workspace = class Workspace {
`).appendTo(this.body); } - this.$page.prepend(frappe.render_template('workspace_loading_skeleton')); - this.$page.find('.codex-editor').addClass('hidden'); + this.create_skeleton(); if (this.all_pages) { let pages = page.public ? this.public_pages : this.private_pages; @@ -293,8 +291,7 @@ frappe.views.Workspace = class Workspace { this.prepare_editorjs(); $('.item-anchor').removeClass('disable-click'); - this.$page.find('.codex-editor').removeClass('hidden'); - this.$page.find('.workspace-skeleton').remove(); + this.remove_skeleton(); } } @@ -336,10 +333,10 @@ frappe.views.Workspace = class Workspace { this.page.clear_secondary_action(); this.page.clear_inner_toolbar(); - current_page.is_editable && this.page.set_secondary_action(__("Edit"), () => { + current_page.is_editable && this.page.set_secondary_action(__("Edit"), async () => { if (!this.editor || !this.editor.readOnly) return; this.is_read_only = false; - this.editor.readOnly.toggle(); + await this.editor.readOnly.toggle(); this.editor.isReady.then(() => { this.initialize_editorjs_undo(); this.setup_customization_buttons(current_page); @@ -383,13 +380,13 @@ frappe.views.Workspace = class Workspace { this.page.set_secondary_action( __("Discard"), - () => { + async () => { + this.discard = true; this.page.clear_primary_action(); this.page.clear_secondary_action(); this.page.clear_inner_toolbar(); - this.editor.readOnly.toggle(); + await this.editor.readOnly.toggle(); this.is_read_only = true; - this.deleted_sidebar_items = []; this.reload(); frappe.show_alert({ message: __("Customizations Discarded"), indicator: "info" }); } @@ -568,10 +565,10 @@ frappe.views.Workspace = class Workspace { } } ] - }).then(() => { + }).then(async () => { if (this.editor.configuration.readOnly) { this.is_read_only = false; - this.editor.readOnly.toggle(); + await this.editor.readOnly.toggle(); } this.add_page_to_sidebar(values); this.show_sidebar_actions(); @@ -646,7 +643,10 @@ frappe.views.Workspace = class Workspace { this.tools = { header: { class: this.blocks['header'], - inlineToolbar: true + inlineToolbar: true, + config: { + defaultLevel: 4 + } }, paragraph: { class: this.blocks['paragraph'], @@ -693,6 +693,7 @@ frappe.views.Workspace = class Workspace { save_page() { frappe.dom.freeze(); + this.create_skeleton(); let save = true; if (!this.title && this.current_page) { let pages = this.current_page.public ? this.public_pages : this.private_pages; @@ -740,13 +741,6 @@ frappe.views.Workspace = class Workspace { if (res.message) { me.new_page = res.message; me.pages[res.message.label] && delete me.pages[res.message.label]; - me.title = ''; - me.icon = ''; - me.parent = ''; - me.public = false; - me.sorted_public_items = []; - me.sorted_private_items = []; - me.deleted_sidebar_items = []; me.reload(); frappe.show_alert({ message: __("Page Saved Successfully"), indicator: "green" }); } @@ -759,9 +753,26 @@ frappe.views.Workspace = class Workspace { } reload() { - this.$page.prepend(frappe.render_template('workspace_loading_skeleton')); - this.$page.find('.codex-editor').addClass('hidden'); + this.title = ''; + this.icon = ''; + this.parent = ''; + this.public = false; + this.sorted_public_items = []; + this.sorted_private_items = []; + this.deleted_sidebar_items = []; + this.create_skeleton(); this.setup_pages(true); + this.discard = false; this.undo.readOnly = true; } + + create_skeleton() { + this.$page.prepend(frappe.render_template('workspace_loading_skeleton')); + this.$page.find('.codex-editor').addClass('hidden'); + } + + remove_skeleton() { + this.$page.find('.codex-editor').removeClass('hidden'); + this.$page.find('.workspace-skeleton').remove(); + } }; diff --git a/frappe/public/js/frappe/widgets/widget_group.js b/frappe/public/js/frappe/widgets/widget_group.js index d8f92edc5d..cb3fc58ea3 100644 --- a/frappe/public/js/frappe/widgets/widget_group.js +++ b/frappe/public/js/frappe/widgets/widget_group.js @@ -191,7 +191,6 @@ export class SingleWidgetGroup { Object.assign(this, opts); this.widgets_list = []; this.widgets_dict = {}; - this.widget_order = []; this.make(); } diff --git a/frappe/public/js/print_format_builder/ConfigureColumns.vue b/frappe/public/js/print_format_builder/ConfigureColumns.vue new file mode 100644 index 0000000000..da10f99e40 --- /dev/null +++ b/frappe/public/js/print_format_builder/ConfigureColumns.vue @@ -0,0 +1,111 @@ + + + diff --git a/frappe/public/js/print_format_builder/Field.vue b/frappe/public/js/print_format_builder/Field.vue new file mode 100644 index 0000000000..ca53402083 --- /dev/null +++ b/frappe/public/js/print_format_builder/Field.vue @@ -0,0 +1,360 @@ + + + diff --git a/frappe/public/js/print_format_builder/HTMLEditor.vue b/frappe/public/js/print_format_builder/HTMLEditor.vue new file mode 100644 index 0000000000..17024da503 --- /dev/null +++ b/frappe/public/js/print_format_builder/HTMLEditor.vue @@ -0,0 +1,68 @@ + + + diff --git a/frappe/public/js/print_format_builder/LetterHeadEditor.vue b/frappe/public/js/print_format_builder/LetterHeadEditor.vue new file mode 100644 index 0000000000..1eae56f81a --- /dev/null +++ b/frappe/public/js/print_format_builder/LetterHeadEditor.vue @@ -0,0 +1,341 @@ + + + diff --git a/frappe/public/js/print_format_builder/Preview.vue b/frappe/public/js/print_format_builder/Preview.vue new file mode 100644 index 0000000000..35105dee6c --- /dev/null +++ b/frappe/public/js/print_format_builder/Preview.vue @@ -0,0 +1,132 @@ + + + diff --git a/frappe/public/js/print_format_builder/PrintFormat.vue b/frappe/public/js/print_format_builder/PrintFormat.vue new file mode 100644 index 0000000000..1857bae47e --- /dev/null +++ b/frappe/public/js/print_format_builder/PrintFormat.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/frappe/public/js/print_format_builder/PrintFormatBuilder.vue b/frappe/public/js/print_format_builder/PrintFormatBuilder.vue new file mode 100644 index 0000000000..bcc3f8300f --- /dev/null +++ b/frappe/public/js/print_format_builder/PrintFormatBuilder.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/frappe/public/js/print_format_builder/PrintFormatControls.vue b/frappe/public/js/print_format_builder/PrintFormatControls.vue new file mode 100644 index 0000000000..2eefc22409 --- /dev/null +++ b/frappe/public/js/print_format_builder/PrintFormatControls.vue @@ -0,0 +1,336 @@ + + + + + diff --git a/frappe/public/js/print_format_builder/PrintFormatSection.vue b/frappe/public/js/print_format_builder/PrintFormatSection.vue new file mode 100644 index 0000000000..9a065e5e26 --- /dev/null +++ b/frappe/public/js/print_format_builder/PrintFormatSection.vue @@ -0,0 +1,245 @@ + + + + + diff --git a/frappe/public/js/print_format_builder/print_format_builder.bundle.js b/frappe/public/js/print_format_builder/print_format_builder.bundle.js new file mode 100644 index 0000000000..b2d3372daf --- /dev/null +++ b/frappe/public/js/print_format_builder/print_format_builder.bundle.js @@ -0,0 +1,64 @@ +import PrintFormatBuilderComponent from "./PrintFormatBuilder.vue"; +import { getStore } from "./store"; + +class PrintFormatBuilder { + constructor({ wrapper, page, print_format }) { + this.$wrapper = $(wrapper); + this.page = page; + this.print_format = print_format; + + this.page.clear_actions(); + this.page.clear_icons(); + this.page.clear_custom_actions(); + + this.page.set_title(__("Editing {0}", [this.print_format])); + this.page.set_primary_action(__("Save"), () => { + this.$component.$store.save_changes(); + }); + let $toggle_preview_btn = this.page.add_button( + __("Show Preview"), + () => { + this.$component.toggle_preview(); + } + ); + this.page.add_button(__("Reset Changes"), () => + this.$component.$store.reset_changes() + ); + this.page.add_menu_item(__("Edit Print Format"), () => { + frappe.set_route("Form", "Print Format", this.print_format); + }); + this.page.add_menu_item(__("Change Print Format"), () => { + frappe.set_route("print-format-builder-beta"); + }); + + let $vm = new Vue({ + el: this.$wrapper.get(0), + render: h => + h(PrintFormatBuilderComponent, { + props: { + print_format_name: print_format + } + }) + }); + this.$component = $vm.$children[0]; + let store = getStore(print_format); + store.$watch("dirty", value => { + if (value) { + this.page.set_indicator("Not Saved", "orange"); + $toggle_preview_btn.hide(); + } else { + this.page.clear_indicator(); + $toggle_preview_btn.show(); + } + }); + this.$component.$watch("show_preview", value => { + $toggle_preview_btn.text( + value ? __("Hide Preview") : __("Show Preview") + ); + }); + } +} + +frappe.provide("frappe.ui"); +frappe.ui.PrintFormatBuilder = PrintFormatBuilder; +export default PrintFormatBuilder; diff --git a/frappe/public/js/print_format_builder/store.js b/frappe/public/js/print_format_builder/store.js new file mode 100644 index 0000000000..f531a4a7e0 --- /dev/null +++ b/frappe/public/js/print_format_builder/store.js @@ -0,0 +1,177 @@ +import { create_default_layout, pluck } from "./utils"; + +let stores = {}; + +export function getStore(print_format_name) { + if (stores[print_format_name]) { + return stores[print_format_name]; + } + + let options = { + data() { + return { + print_format_name, + letterhead_name: null, + print_format: null, + letterhead: null, + doctype: null, + meta: null, + layout: null, + dirty: false, + edit_letterhead: false + }; + }, + watch: { + layout: { + deep: true, + handler() { + this.dirty = true; + } + }, + print_format: { + deep: true, + handler() { + this.dirty = true; + } + } + }, + methods: { + fetch() { + return new Promise(resolve => { + frappe.model.clear_doc( + "Print Format", + this.print_format_name + ); + frappe.model.with_doc( + "Print Format", + this.print_format_name, + () => { + let print_format = frappe.get_doc( + "Print Format", + this.print_format_name + ); + frappe.model.with_doctype( + print_format.doc_type, + () => { + this.meta = frappe.get_meta( + print_format.doc_type + ); + this.print_format = print_format; + this.layout = this.get_layout(); + this.$nextTick(() => (this.dirty = false)); + this.edit_letterhead = false; + resolve(); + } + ); + } + ); + }); + }, + update({ fieldname, value }) { + this.$set(this.print_format, fieldname, value); + }, + save_changes() { + frappe.dom.freeze(__("Saving...")); + + this.layout.sections = this.layout.sections + .filter(section => !section.remove) + .map(section => { + section.columns = section.columns.map(column => { + column.fields = column.fields + .filter(df => !df.remove) + .map(df => { + if (df.table_columns) { + df.table_columns = df.table_columns.map( + tf => { + return pluck(tf, [ + "label", + "fieldname", + "fieldtype", + "options", + "width", + "field_template" + ]); + } + ); + } + return pluck(df, [ + "label", + "fieldname", + "fieldtype", + "options", + "table_columns", + "html", + "field_template" + ]); + }); + return column; + }); + return section; + }); + + this.print_format.format_data = JSON.stringify(this.layout); + + frappe + .call("frappe.client.save", { + doc: this.print_format + }) + .then(() => { + if (this.letterhead && this.letterhead._dirty) { + return frappe + .call("frappe.client.save", { + doc: this.letterhead + }) + .then(r => (this.letterhead = r.message)); + } + }) + .then(() => this.fetch()) + .always(() => { + frappe.dom.unfreeze(); + this.$emit("after_save"); + }); + }, + reset_changes() { + this.fetch(); + }, + get_layout() { + if (this.print_format) { + if (typeof this.print_format.format_data == "string") { + return JSON.parse(this.print_format.format_data); + } + return this.print_format.format_data; + } + return null; + }, + get_default_layout() { + return create_default_layout(this.meta, this.print_format); + }, + change_letterhead(letterhead) { + return frappe.db + .get_doc("Letter Head", letterhead) + .then(doc => { + this.letterhead = doc; + }); + } + } + }; + stores[print_format_name] = new Vue(options); + return stores[print_format_name]; +} + +export let storeMixin = { + inject: ["$store"], + computed: { + print_format() { + return this.$store.print_format; + }, + layout() { + return this.$store.layout; + }, + letterhead() { + return this.$store.letterhead; + }, + meta() { + return this.$store.meta; + } + } +}; diff --git a/frappe/public/js/print_format_builder/utils.js b/frappe/public/js/print_format_builder/utils.js new file mode 100644 index 0000000000..879fe9efd2 --- /dev/null +++ b/frappe/public/js/print_format_builder/utils.js @@ -0,0 +1,159 @@ +export function create_default_layout(meta, print_format) { + let layout = { + header: get_default_header(meta), + sections: [] + }; + + let section = null, + column = null; + + function set_column(df) { + if (!section) { + set_section(); + } + column = get_new_column(df); + section.columns.push(column); + } + + function set_section(df) { + section = get_new_section(df); + column = null; + layout.sections.push(section); + } + + function get_new_section(df) { + if (!df) { + df = { label: "" }; + } + return { + label: df.label || "", + columns: [] + }; + } + + function get_new_column(df) { + if (!df) { + df = { label: "" }; + } + return { + label: df.label || "", + fields: [] + }; + } + + for (let df of meta.fields) { + if (df.fieldname) { + // make a copy to avoid mutation bugs + df = JSON.parse(JSON.stringify(df)); + } else { + continue; + } + + if (df.fieldtype === "Section Break") { + set_section(df); + } else if (df.fieldtype === "Column Break") { + set_column(df); + } else if (df.label) { + if (!column) set_column(); + + if (!df.print_hide) { + let field = { + label: df.label, + fieldname: df.fieldname, + fieldtype: df.fieldtype, + options: df.options + }; + + let field_template = get_field_template( + print_format, + df.fieldname + ); + if (field_template) { + field.label = `${__(df.label)} (${__("Field Template")})`; + field.fieldtype = "Field Template"; + field.field_template = field_template.name; + field.fieldname = df.fieldname = "_template"; + } + + if (df.fieldtype === "Table") { + field.table_columns = get_table_columns(df); + } + + column.fields.push(field); + section.has_fields = true; + } + } + } + + // remove empty sections + layout.sections = layout.sections.filter(section => section.has_fields); + + return layout; +} + +export function get_table_columns(df) { + let table_columns = []; + let table_fields = frappe.get_meta(df.options).fields; + let total_width = 0; + for (let tf of table_fields) { + if ( + !in_list(["Section Break", "Column Break"], tf.fieldtype) && + !tf.print_hide && + df.label && + total_width < 100 + ) { + let width = + typeof tf.width == "number" && tf.width < 100 + ? tf.width + : tf.width + ? 20 + : 10; + table_columns.push({ + label: tf.label, + fieldname: tf.fieldname, + fieldtype: tf.fieldtype, + options: tf.options, + width + }); + total_width += width; + } + } + return table_columns; +} + +function get_field_template(print_format, fieldname) { + let templates = print_format.__onload.print_templates || {}; + for (let template of templates) { + if (template.field === fieldname) { + return template; + } + } + return null; +} + +function get_default_header(meta) { + return `
+

${meta.name}

+

{{ doc.name }}

+
`; +} + +export function pluck(object, keys) { + let out = {}; + for (let key of keys) { + if (key in object) { + out[key] = object[key]; + } + } + return out; +} + +export function get_image_dimensions(src) { + return new Promise(resolve => { + let img = new Image(); + img.onload = function() { + resolve({ width: this.width, height: this.height }); + }; + img.src = src; + }); +} diff --git a/frappe/public/scss/common/controls.scss b/frappe/public/scss/common/controls.scss index a10cd454a6..954916c911 100644 --- a/frappe/public/scss/common/controls.scss +++ b/frappe/public/scss/common/controls.scss @@ -231,6 +231,13 @@ textarea.form-control { background-color: var(--control-bg); border-radius: var(--border-radius); padding: var(--padding-md); + + svg > rect { + fill: var(--control-bg) !important; + } + svg > g { + fill: var(--text-color) !important; + } } @media (min-width: 768px) { diff --git a/frappe/public/scss/desk/print_preview.scss b/frappe/public/scss/desk/print_preview.scss index 3c0acc68b8..468b37fe5a 100644 --- a/frappe/public/scss/desk/print_preview.scss +++ b/frappe/public/scss/desk/print_preview.scss @@ -14,6 +14,11 @@ } } +.preview-beta-wrapper { + border-radius: var(--border-radius); + overflow: hidden; +} + .print-toolbar { margin: 0px; padding: var(--padding-md) 0; diff --git a/frappe/public/scss/desk/report.scss b/frappe/public/scss/desk/report.scss index 2389a4f8f6..f8666602ff 100644 --- a/frappe/public/scss/desk/report.scss +++ b/frappe/public/scss/desk/report.scss @@ -84,39 +84,17 @@ margin-bottom: 10px; } -.layout-main-section { - --report-filter-height: 0px; - --report-total-height: 275px; -} - .report-wrapper { overflow: auto; - - .datatable { - height: calc(100vh - var(--report-filter-height) - 205px); - - .dt-scrollable { - height: calc(100vh - var(--report-filter-height) - var(--report-total-height)); - } - } } .report-view { .result { - min-height: 50vh !important; .dt-row:last-child:not(.dt-row-filter) { .dt-cell { border-bottom: 1px solid var(--border-color); } } - - .datatable { - height: calc(100vh - var(--report-filter-height) - 225px); - - .dt-scrollable { - height: calc(100vh - var(--report-filter-height) - 295px); - } - } } } diff --git a/frappe/public/scss/print_format.bundle.scss b/frappe/public/scss/print_format.bundle.scss new file mode 100644 index 0000000000..b01e669d71 --- /dev/null +++ b/frappe/public/scss/print_format.bundle.scss @@ -0,0 +1,5 @@ +@import "./desk/variables.scss"; +@import "./common/mixins.scss"; +@import "./common/global.scss"; +@import "./common/icons.scss"; +@import "~bootstrap/scss/bootstrap"; diff --git a/frappe/search/full_text_search.py b/frappe/search/full_text_search.py index 560ad55bf3..1d4f3fef32 100644 --- a/frappe/search/full_text_search.py +++ b/frappe/search/full_text_search.py @@ -7,7 +7,7 @@ from frappe.utils import update_progress_bar from whoosh.index import create_in, open_dir, EmptyIndexError from whoosh.fields import TEXT, ID, Schema from whoosh.qparser import MultifieldParser, FieldsPlugin, WildcardPlugin -from whoosh.query import Prefix +from whoosh.query import Prefix, FuzzyTerm from whoosh.writing import AsyncWriter @@ -23,6 +23,9 @@ class FullTextSearch: def get_schema(self): return Schema(name=ID(stored=True), content=TEXT(stored=True)) + def get_fields_to_search(self): + return ["name", "content"] + def get_id(self): return "name" @@ -120,8 +123,15 @@ class FullTextSearch: results = None out = [] + search_fields = self.get_fields_to_search() + fieldboosts = {} + + # apply reducing boost on fields based on order. 1.0, 0.5, 0.33 and so on + for idx, field in enumerate(search_fields, start=1): + fieldboosts[field] = 1.0 / idx + with ix.searcher() as searcher: - parser = MultifieldParser(["title", "content"], ix.schema) + parser = MultifieldParser(search_fields, ix.schema, termclass=FuzzyTermExtended, fieldboosts=fieldboosts) parser.remove_plugin_class(FieldsPlugin) parser.remove_plugin_class(WildcardPlugin) query = parser.parse(text) @@ -136,5 +146,13 @@ class FullTextSearch: return out + +class FuzzyTermExtended(FuzzyTerm): + def __init__(self, fieldname, text, boost=1.0, maxdist=2, prefixlength=1, + constantscore=True): + super().__init__(fieldname, text, boost=boost, maxdist=maxdist, + prefixlength=prefixlength, constantscore=constantscore) + + def get_index_path(index_name): return frappe.get_site_path("indexes", index_name) diff --git a/frappe/search/test_full_text_search.py b/frappe/search/test_full_text_search.py index 348a0ec72a..0dbc7e775b 100644 --- a/frappe/search/test_full_text_search.py +++ b/frappe/search/test_full_text_search.py @@ -125,4 +125,4 @@ def get_documents(): deploy business applications with Rich Admin Interface. CommonSearchTerm""" }) - return docs \ No newline at end of file + return docs diff --git a/frappe/search/website_search.py b/frappe/search/website_search.py index 0bc06d1a9b..30eadae6f1 100644 --- a/frappe/search/website_search.py +++ b/frappe/search/website_search.py @@ -21,6 +21,9 @@ class WebsiteSearch(FullTextSearch): title=TEXT(stored=True), path=ID(stored=True), content=TEXT(stored=True) ) + def get_fields_to_search(self): + return ["title", "content"] + def get_id(self): return "path" diff --git a/frappe/sessions.py b/frappe/sessions.py index bdf18f8d82..9a0f19df80 100644 --- a/frappe/sessions.py +++ b/frappe/sessions.py @@ -16,6 +16,8 @@ import frappe.translate import redis from urllib.parse import unquote from frappe.cache_manager import clear_user_cache +from frappe.query_builder import Order, DocType + @frappe.whitelist() def clear(): @@ -61,18 +63,14 @@ def get_sessions_to_clear(user=None, keep_current=False, device=None): simultaneous_sessions = frappe.db.get_value('User', user, 'simultaneous_sessions') or 1 offset = simultaneous_sessions - 1 - condition = '' + session = DocType("Sessions") + session_id = frappe.qb.from_(session).where((session.user == user) & (session.device.isin(device))) if keep_current: - condition = ' AND sid != {0}'.format(frappe.db.escape(frappe.session.sid)) + session_id = session_id.where(session.sid != frappe.db.escape(frappe.session.sid)) - return frappe.db.sql_list(""" - SELECT `sid` FROM `tabSessions` - WHERE `tabSessions`.user=%(user)s - AND device in %(device)s - {condition} - ORDER BY `lastupdate` DESC - LIMIT 100 OFFSET {offset}""".format(condition=condition, offset=offset), - {"user": user, "device": device}) + query = session_id.select(session.sid).offset(offset).limit(100).orderby(session.lastupdate, order=Order.desc) + + return query.run(pluck=True) def delete_session(sid=None, user=None, reason="Session Expired"): from frappe.core.doctype.activity_log.feed import logout_feed @@ -80,7 +78,10 @@ def delete_session(sid=None, user=None, reason="Session Expired"): frappe.cache().hdel("session", sid) frappe.cache().hdel("last_db_session_update", sid) if sid and not user: - user_details = frappe.db.sql("""select user from tabSessions where sid=%s""", sid, as_dict=True) + table = DocType("Sessions") + user_details = frappe.qb.from_(table).where( + table.sid == sid + ).select(table.user).run(as_dict=True) if user_details: user = user_details[0].get("user") logout_feed(user, reason) @@ -91,7 +92,7 @@ def clear_all_sessions(reason=None): """This effectively logs out all users""" frappe.only_for("Administrator") if not reason: reason = "Deleted All Active Session" - for sid in frappe.db.sql_list("select sid from `tabSessions`"): + for sid in frappe.qb.from_("Sessions").select("sid").run(pluck=True): delete_session(sid, reason=reason) def get_expired_sessions(): @@ -159,6 +160,10 @@ def get(): return bootinfo +@frappe.whitelist() +def get_boot_assets_json(): + return get_assets_json() + def get_csrf_token(): if not frappe.local.session.data.csrf_token: generate_csrf_token() diff --git a/frappe/share.py b/frappe/share.py index 030feea8fa..9b33198c9b 100644 --- a/frappe/share.py +++ b/frappe/share.py @@ -128,8 +128,11 @@ def get_shared_doctypes(user=None): """Return list of doctypes in which documents are shared for the given user.""" if not user: user = frappe.session.user - - return frappe.db.sql_list("select distinct share_doctype from tabDocShare where (user=%s or everyone=1)", user) + table = frappe.qb.DocType("DocShare") + query = frappe.qb.from_(table).where( + (table.user == user) | (table.everyone == 1) + ).select(table.share_doctype).distinct() + return query.run(pluck=True) def get_share_name(doctype, name, user, everyone): if cint(everyone): diff --git a/frappe/templates/print_format/macros.html b/frappe/templates/print_format/macros.html new file mode 100644 index 0000000000..ace992e88d --- /dev/null +++ b/frappe/templates/print_format/macros.html @@ -0,0 +1,13 @@ +{% macro render_field(df, doc) %} +{%- set value = doc.get(df.fieldname) -%} +{% include ['templates/print_format/macros/' + df.renderer + '.html', 'templates/print_format/macros/Data.html'] ignore missing %} +{% endmacro %} + +{% macro field_attributes(df) %} +{%- if df.fieldname -%} +data-fieldname="{{ df.fieldname }}" +{%- endif %} +{% if df.fieldtype -%} +data-fieldtype="{{ df.fieldtype }}" +{%- endif -%} +{% endmacro %} diff --git a/frappe/templates/print_format/macros/Attach.html b/frappe/templates/print_format/macros/Attach.html new file mode 100644 index 0000000000..523d9e057a --- /dev/null +++ b/frappe/templates/print_format/macros/Attach.html @@ -0,0 +1,7 @@ +{% extends "templates/print_format/macros/Data.html" %} + +{%- block value -%} +
+ {{ value.rsplit('/', 1)[1] }} +
+{%- endblock -%} diff --git a/frappe/templates/print_format/macros/AttachImage.html b/frappe/templates/print_format/macros/AttachImage.html new file mode 100644 index 0000000000..796662f67a --- /dev/null +++ b/frappe/templates/print_format/macros/AttachImage.html @@ -0,0 +1,7 @@ +{% extends "templates/print_format/macros/Data.html" %} + +{%- block value -%} +
+ {{ df.label }} +
+{%- endblock -%} diff --git a/frappe/templates/print_format/macros/Check.html b/frappe/templates/print_format/macros/Check.html new file mode 100644 index 0000000000..fbc43608a5 --- /dev/null +++ b/frappe/templates/print_format/macros/Check.html @@ -0,0 +1,9 @@ +{% extends "templates/print_format/macros/Data.html" %} + +{%- block value -%} +
+ + + +
+{%- endblock -%} diff --git a/frappe/templates/print_format/macros/Code.html b/frappe/templates/print_format/macros/Code.html new file mode 100644 index 0000000000..e83457808a --- /dev/null +++ b/frappe/templates/print_format/macros/Code.html @@ -0,0 +1,7 @@ +{% extends "templates/print_format/macros/Data.html" %} + +{%- block value -%} +
+
{{ value }}
+
+{%- endblock -%} diff --git a/frappe/templates/print_format/macros/Color.html b/frappe/templates/print_format/macros/Color.html new file mode 100644 index 0000000000..ef7a2226c6 --- /dev/null +++ b/frappe/templates/print_format/macros/Color.html @@ -0,0 +1,8 @@ +{% extends "templates/print_format/macros/Data.html" %} + +{%- block value -%} +
+
+ {{ value }} +
+{%- endblock -%} diff --git a/frappe/templates/print_format/macros/Data.html b/frappe/templates/print_format/macros/Data.html new file mode 100644 index 0000000000..722c42ce1a --- /dev/null +++ b/frappe/templates/print_format/macros/Data.html @@ -0,0 +1,10 @@ +{% if value %} +
+ {%- block label -%} +
{{ df.label }}
+ {%- endblock -%} + {%- block value -%} +
{{ doc.get_formatted(df.fieldname) }}
+ {%- endblock -%} +
+{% endif %} diff --git a/frappe/templates/print_format/macros/Divider.html b/frappe/templates/print_format/macros/Divider.html new file mode 100644 index 0000000000..49fdf3f547 --- /dev/null +++ b/frappe/templates/print_format/macros/Divider.html @@ -0,0 +1,2 @@ +
+
diff --git a/frappe/templates/print_format/macros/FieldTemplate.html b/frappe/templates/print_format/macros/FieldTemplate.html new file mode 100644 index 0000000000..9ea7fabb22 --- /dev/null +++ b/frappe/templates/print_format/macros/FieldTemplate.html @@ -0,0 +1,4 @@ +
+ {% set template = frappe.db.get_value('Print Format Field Template', df.field_template, ['template', 'template_file', 'standard'], as_dict=1) %} + {{ frappe.render_template(template.template_file if template.standard else template.template, {'doc': doc}) }} +
diff --git a/frappe/templates/print_format/macros/HTML.html b/frappe/templates/print_format/macros/HTML.html new file mode 100644 index 0000000000..6bd3659902 --- /dev/null +++ b/frappe/templates/print_format/macros/HTML.html @@ -0,0 +1,3 @@ +
+ {{ frappe.render_template(df.html, {'doc': doc}) }} +
diff --git a/frappe/templates/print_format/macros/Markdown.html b/frappe/templates/print_format/macros/Markdown.html new file mode 100644 index 0000000000..b692283fa0 --- /dev/null +++ b/frappe/templates/print_format/macros/Markdown.html @@ -0,0 +1,9 @@ +{% extends "templates/print_format/macros/Data.html" %} + +{%- block value -%} +
+ {{ frappe.utils.md_to_html(doc.get(df.fieldname)) }} +
+{%- endblock -%} + + diff --git a/frappe/templates/print_format/macros/Rating.html b/frappe/templates/print_format/macros/Rating.html new file mode 100644 index 0000000000..2e001fb58f --- /dev/null +++ b/frappe/templates/print_format/macros/Rating.html @@ -0,0 +1,22 @@ +{% extends "templates/print_format/macros/Data.html" %} + +{% macro star(is_active=false) %} + + {%- set color = '#f6c35e' if is_active else '#dce0e3' -%} + + +{% endmacro %} + +{%- block value -%} +
+ {%- for i in range(value) -%} + {{ star(true) }} + {%- endfor -%} + {%- for i in range(5 - value) -%} + {{ star() }} + {%- endfor -%} +
+{%- endblock -%} diff --git a/frappe/templates/print_format/macros/Signature.html b/frappe/templates/print_format/macros/Signature.html new file mode 100644 index 0000000000..128ff2a927 --- /dev/null +++ b/frappe/templates/print_format/macros/Signature.html @@ -0,0 +1,7 @@ +{% extends "templates/print_format/macros/Data.html" %} + +{%- block value -%} +
+ {{ df.label }} +
+{%- endblock -%} diff --git a/frappe/templates/print_format/macros/Spacer.html b/frappe/templates/print_format/macros/Spacer.html new file mode 100644 index 0000000000..1a7336e17b --- /dev/null +++ b/frappe/templates/print_format/macros/Spacer.html @@ -0,0 +1,2 @@ +
+
diff --git a/frappe/templates/print_format/macros/Table.html b/frappe/templates/print_format/macros/Table.html new file mode 100644 index 0000000000..27c0be961c --- /dev/null +++ b/frappe/templates/print_format/macros/Table.html @@ -0,0 +1,30 @@ +{% if doc.get(df.fieldname) %} +
+
+ {{ df.label }} +
+ + {% set columns = df.table_columns %} + + + {% for column in columns %} + + {% endfor %} + + + + {% for row in doc.get(df.fieldname) %} + + {% for column in columns %} + + {% endfor %} + + {% endfor %} + +
+ {{ column.label }} +
+ {{ row.get_formatted(column.fieldname) }} +
+
+{% endif %} diff --git a/frappe/templates/print_format/print_footer.html b/frappe/templates/print_format/print_footer.html new file mode 100644 index 0000000000..bd64c0b1b2 --- /dev/null +++ b/frappe/templates/print_format/print_footer.html @@ -0,0 +1,24 @@ + + diff --git a/frappe/templates/print_format/print_format.css b/frappe/templates/print_format/print_format.css new file mode 100644 index 0000000000..480cd19439 --- /dev/null +++ b/frappe/templates/print_format/print_format.css @@ -0,0 +1,131 @@ +{% include "templates/print_format/print_format_font.css" %} + +{% macro render_margin_text(position, content) %} +@{{ position.replace('_', '-') }} { + content: {{ content }} +} +{% endmacro %} + +@page { + size: {{ print_settings.pdf_page_size or 'A4' }} portrait; + margin-top: {{ print_format.margin_top | int }}mm; + margin-bottom: {{ print_format.margin_bottom | int }}mm; + margin-left: {{ print_format.margin_left | int }}mm; + margin-right: {{ print_format.margin_right | int }}mm; + padding-top: {{ (header_height or 0) + 8 }}px; + padding-bottom: {{ (footer_height or 0) + 8 }}px; + + /* page number */ + {% set page_number_position = print_format.page_number.lower().replace(' ', '_') %} + {% if page_number_position in ['top_left', 'top_center', 'top_right', 'bottom_left', 'bottom_center', 'bottom_right'] %} + {{ render_margin_text(page_number_position, 'counter(page) " of " counter(pages)') }} + {% endif %} +} + +html, body { + font-size: {{ print_format.font_size }}px; +} + +body { + min-width: {{ body_width | int }}mm !important; + max-width: {{ body_width | int }}mm !important; +} + +/* CSS rules to fix bootstrap column rendering in PDF + https://github.com/Kozea/WeasyPrint/issues/697#issuecomment-542338732 +*/ +@media print { + .col, *[class^="col-"] { + max-width: none !important; + } +} + +@media screen { + html { + background-color: var(--gray-200); + } + body { + background-color: white; + box-shadow: var(--shadow-md); + margin: 2rem auto; + min-height: 297mm; + height: min-content; + min-width: {{ body_width | int }}mm !important; + max-width: {{ body_width | int }}mm !important; + padding-top: {{ print_format.margin_top | int }}mm; + padding-right: {{ print_format.margin_right | int }}mm; + padding-left: {{ print_format.margin_left | int }}mm; + padding-bottom: {{ print_format.margin_bottom | int }}mm; + } +} + +.section:not(:first-child) { + margin-top: 1rem; +} + +.section-label { + font-size: 1.2rem; + font-weight: 600; +} + +.field + .field { + margin-top: 0.5rem; +} + +.field .label { + font-weight: bold; +} + +.field.left-right { + display: flex; +} + +.field.left-right .label { + width: 50%; +} + +.field.left-right .value { + width: 50%; +} + +.child-table [data-fieldtype="Currency"] { + text-align: right; +} + +.table-row { + page-break-inside: avoid; +} + +.page-break { + page-break-after: always; +} + +.document-header { + border-bottom-width: 1px; + border-bottom-style: solid; + border-bottom-color: var(--gray-300); +} + +.field[data-fieldtype="Rating"] .rating-star { + width: 1.5rem; +} + +.field[data-fieldtype="Long Text"] .value, .field[data-fieldtype="Text"] .value { + white-space: pre-line; +} + +.field[data-fieldtype="Color"] .value { + display: flex; + align-items: center; +} + +.field[data-fieldtype="Color"] .color-square { + width: 1rem; + height: 1rem; + margin-right: 0.3rem; + border-radius: var(--border-radius); +} + +.field[data-fieldtype="Check"] #icon-tick { + width: 1rem; +} diff --git a/frappe/templates/print_format/print_format.html b/frappe/templates/print_format/print_format.html new file mode 100644 index 0000000000..b9fb95a9d3 --- /dev/null +++ b/frappe/templates/print_format/print_format.html @@ -0,0 +1,40 @@ +{% import "templates/print_format/macros.html" as macros %} + + + + + + + + {{ doc.doctype }}: {{ doc.name }} + {{ include_style('print_format.bundle.css') }} + + {%- if print_style and print_style.css -%} + + {%- endif -%} + {%- if print_format.css -%} + + {%- endif -%} + + + {{ header or '' }} + {% for section in layout.sections %} +
+ {% if section.label %} + + {% endif %} + +
+ {% for column in section.columns %} +
+ {% for df in column.fields %} + {{ macros.render_field(df, doc) }} + {% endfor %} +
+ {% endfor %} +
+
+ {% endfor %} + {{ footer or '' }} + + diff --git a/frappe/templates/print_format/print_format_font.css b/frappe/templates/print_format/print_format_font.css new file mode 100644 index 0000000000..6103dbf5a4 --- /dev/null +++ b/frappe/templates/print_format/print_format_font.css @@ -0,0 +1,9 @@ +@charset "UTF-8"; +{% if print_format.font %} +{% set font_family = print_format.font.replace(' ', '+') %} +@import url("https://fonts.googleapis.com/css?family={{ font_family }}:400,500,600,700"); +{% endif %} + +html, body { + font-family: {{ print_format.font or 'Inter' }}, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; +} diff --git a/frappe/templates/print_format/print_header.html b/frappe/templates/print_format/print_header.html new file mode 100644 index 0000000000..9b1357e08c --- /dev/null +++ b/frappe/templates/print_format/print_header.html @@ -0,0 +1,24 @@ + +
+ {%- if letterhead -%} + {{ frappe.render_template(letterhead.content, {'doc': doc}) }} + {%- endif -%} + + {%- if layout.header -%} + {{ frappe.render_template(layout.header, {'doc': doc}) }} + {%- endif -%} +
diff --git a/frappe/test_runner.py b/frappe/test_runner.py index ecacaa1a89..b6a8145cfb 100644 --- a/frappe/test_runner.py +++ b/frappe/test_runner.py @@ -3,7 +3,6 @@ import frappe import unittest, json, sys, os import time -import xmlrunner import importlib from frappe.modules import load_doctype_module, get_module_name import frappe.utils.scheduler @@ -17,6 +16,13 @@ SLOW_TEST_THRESHOLD = 2 def xmlrunner_wrapper(output): """Convenience wrapper to keep method signature unchanged for XMLTestRunner and TextTestRunner""" + try: + import xmlrunner + except ImportError: + print("Development dependencies are required to execute this command. To install run:") + print("$ bench setup requirements --dev") + raise + def _runner(*args, **kwargs): kwargs['output'] = output return xmlrunner.XMLTestRunner(*args, **kwargs) diff --git a/frappe/tests/test_db.py b/frappe/tests/test_db.py index 0d32a72756..978e3f4f7f 100644 --- a/frappe/tests/test_db.py +++ b/frappe/tests/test_db.py @@ -11,6 +11,7 @@ import frappe from frappe.custom.doctype.custom_field.custom_field import create_custom_field from frappe.utils import random_string from frappe.utils.testutils import clear_custom_fields +from frappe.query_builder import Field from .test_query_builder import run_only_if, db_type_is @@ -24,6 +25,7 @@ class TestDB(unittest.TestCase): self.assertEqual(frappe.db.get_value("User", {"name": ["<=", "Administrator"]}), "Administrator") self.assertEqual(frappe.db.get_value("User", {}, ["Max(name)"]), frappe.db.sql("SELECT Max(name) FROM tabUser")[0][0]) self.assertEqual(frappe.db.get_value("User", {}, "Min(name)"), frappe.db.sql("SELECT Min(name) FROM tabUser")[0][0]) + self.assertIn("for update", frappe.db.get_value("User", Field("name") == "Administrator", for_update=True, run=False).lower()) self.assertEqual(frappe.db.sql("""SELECT name FROM `tabUser` WHERE name > 's' ORDER BY MODIFIED DESC""")[0][0], frappe.db.get_value("User", {"name": [">", "s"]})) diff --git a/frappe/tests/test_global_search.py b/frappe/tests/test_global_search.py index 41d6427b77..9a86baa4e5 100644 --- a/frappe/tests/test_global_search.py +++ b/frappe/tests/test_global_search.py @@ -88,13 +88,13 @@ class TestGlobalSearch(unittest.TestCase): event = frappe.get_doc('Event', event_name) test_subject = event.subject results = global_search.search(test_subject) - self.assertEqual(len(results), 1) + self.assertTrue(any(r["name"] == event_name for r in results), msg="Failed to search document by exact name") frappe.delete_doc('Event', event_name) global_search.sync_global_search() results = global_search.search(test_subject) - self.assertEqual(len(results), 0) + self.assertTrue(all(r["name"] != event_name for r in results), msg="Deleted documents appearing in global search.") def test_insert_child_table(self): frappe.db.delete("Event") diff --git a/frappe/tests/test_website.py b/frappe/tests/test_website.py index 688679a80f..25aa7b31ce 100644 --- a/frappe/tests/test_website.py +++ b/frappe/tests/test_website.py @@ -4,14 +4,13 @@ import frappe from frappe.utils import set_request from frappe.website.serve import get_response, get_response_content from frappe.website.utils import (build_response, clear_website_cache, get_home_page) -from tenacity import retry, stop_after_attempt, retry_if_exception_type - class TestWebsite(unittest.TestCase): def setUp(self): frappe.set_user('Guest') def tearDown(self): + frappe.db.value_cache = {} frappe.set_user('Administrator') def test_home_page(self): @@ -197,13 +196,8 @@ class TestWebsite(unittest.TestCase): delattr(frappe.hooks, 'page_renderer') frappe.cache().delete_key('app_hooks') - # TODO: Get rid of this retry logic - # Added since test is flaky and we can't figure out why at this point - @retry( - stop=stop_after_attempt(5), retry=retry_if_exception_type(AssertionError), - ) def test_printview_page(self): - content = get_response_content('/Language/en') + content = get_response_content('/Language/ru') self.assertIn('