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/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/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/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 e98cc22f41..df5ad6dfda 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. @@ -178,6 +180,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) @@ -233,7 +238,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: @@ -241,7 +246,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 @@ -262,7 +267,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: @@ -324,7 +329,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. @@ -351,12 +356,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. @@ -382,7 +390,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 @@ -395,26 +403,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, @@ -443,8 +453,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) @@ -522,7 +533,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: @@ -531,7 +543,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) @@ -539,18 +553,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 {} @@ -595,7 +608,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/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/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/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/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/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/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/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/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/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('