diff --git a/.codacy.yml b/.codacy.yml deleted file mode 100644 index 4754a63e7e..0000000000 --- a/.codacy.yml +++ /dev/null @@ -1,2 +0,0 @@ -exclude_paths: - - '**.sql' \ No newline at end of file diff --git a/.deepsource.toml b/.deepsource.toml deleted file mode 100644 index f5edb47a13..0000000000 --- a/.deepsource.toml +++ /dev/null @@ -1,17 +0,0 @@ -version = 1 - -test_patterns = [ - "**/test_*.py" -] - -exclude_patterns = [ - "frappe/patches/**", - "*.min.js" -] - -[[analyzers]] -name = "python" -enabled = true - - [analyzers.meta] - runtime_version = "3.x.x" diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..24f122a8d4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +# Root editor config file +root = true + +# Common settings +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 + +# python, js indentation settings +[{*.py,*.js}] +indent_style = tab +indent_size = 4 diff --git a/.eslintignore b/.eslintignore index 2fd65d307d..baf9bb2cc5 100644 --- a/.eslintignore +++ b/.eslintignore @@ -5,5 +5,4 @@ frappe/core/doctype/doctype/boilerplate/* frappe/core/doctype/report/boilerplate/* frappe/public/js/frappe/class.js frappe/templates/includes/* -frappe/tests/testcafe/* -frappe/www/website_script.js \ No newline at end of file +frappe/www/website_script.js diff --git a/.eslintrc b/.eslintrc index 69c731b079..d123023a68 100644 --- a/.eslintrc +++ b/.eslintrc @@ -87,6 +87,7 @@ "open_url_post": true, "toTitle": true, "lstrip": true, + "rstrip": true, "strip": true, "strip_html": true, "replace_all": true, @@ -146,6 +147,7 @@ "context": true, "before": true, "beforeEach": true, - "qz": true + "qz": true, + "localforage": true } } diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index e1f16970fe..5be3a87884 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -15,7 +15,7 @@ If your issue is not clear or does not meet the guidelines, then it will be clos ### General Issue Guidelines 1. **Search existing Issues:** Before raising a Issue, search if it has been raised before. Maybe add a 👍 or give additional help by creating a mockup if it is not already created. -2. **Report each issue separately:** Don't club multiple, unreleated issues in one note. +2. **Report each issue separately:** Don't club multiple, unrelated issues in one note. 3. **Brief:** Please don't include long explanations. Use screenshots and bullet points instead of descriptive paragraphs. ### Bug Report Guidelines diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..26bb7ab280 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Community Forum + url: https://discuss.erpnext.com/ + about: For general QnA, discussions and community help. diff --git a/.github/helper/documentation.py b/.github/helper/documentation.py index b0a3a16c76..08d1d1aa9c 100644 --- a/.github/helper/documentation.py +++ b/.github/helper/documentation.py @@ -4,7 +4,7 @@ from urllib.parse import urlparse docs_repos = [ - "frappe_docs" + "frappe_docs", "erpnext_documentation", "erpnext_com", "frappe_io", @@ -17,12 +17,12 @@ def uri_validator(x): def docs_link_exists(body): for line in body.splitlines(): - for word in line: + for word in line.split(): if word.startswith('http') and uri_validator(word): parsed_url = urlparse(word) if parsed_url.netloc == "github.com": - _, org, repo, _type, ref = parsed_url.path.split('/') - if org == "frappe" and repo in docs_repos: + parts = parsed_url.path.split('/') + if len(parts) == 5 and parts[1] == "frappe" and parts[2] in docs_repos: return True diff --git a/.github/helper/translation.py b/.github/helper/translation.py index 5d33355a1b..9146b3b32b 100644 --- a/.github/helper/translation.py +++ b/.github/helper/translation.py @@ -2,8 +2,11 @@ import re import sys errors_encounter = 0 -pattern = re.compile(r"_\(([\"']{,3})(?P((?!\1).)*)\1(\s*,\s*context\s*=\s*([\"'])(?P((?!\5).)*)\5)*(\s*,\s*(.)*?\s*(,\s*([\"'])(?P((?!\11).)*)\11)*)*\)") -start_pattern = re.compile(r"_{1,2}\([\"']{1,3}") +pattern = re.compile(r"_\(([\"']{,3})(?P((?!\1).)*)\1(\s*,\s*context\s*=\s*([\"'])(?P((?!\5).)*)\5)*(\s*,(\s*?.*?\n*?)*(,\s*([\"'])(?P((?!\11).)*)\11)*)*\)") +words_pattern = re.compile(r"_{1,2}\([\"'`]{1,3}.*?[a-zA-Z]") +start_pattern = re.compile(r"_{1,2}\([f\"'`]{1,3}") +f_string_pattern = re.compile(r"_\(f[\"']") +starts_with_f_pattern = re.compile(r"_\(f") # skip first argument files = sys.argv[1:] @@ -14,21 +17,44 @@ for _file in files_to_scan: print(f'Checking: {_file}') file_lines = f.readlines() for line_number, line in enumerate(file_lines, 1): + if 'frappe-lint: disable-translate' in line: + continue + start_matches = start_pattern.search(line) if start_matches: + starts_with_f = starts_with_f_pattern.search(line) + + if starts_with_f: + has_f_string = f_string_pattern.search(line) + if has_f_string: + errors_encounter += 1 + print(f'\nF-strings are not supported for translations at line number {line_number}\n{line.strip()[:100]}') + continue + else: + continue + match = pattern.search(line) - if not match and line.endswith(',\n'): + error_found = False + + if not match and line.endswith((',\n', '[\n')): # concat remaining text to validate multiline pattern line = "".join(file_lines[line_number - 1:]) line = line[start_matches.start() + 1:] match = pattern.match(line) if not match: + error_found = True + print(f'\nTranslation syntax error at line number {line_number}\n{line.strip()[:100]}') + + if not error_found and not words_pattern.search(line): + error_found = True + print(f'\nTranslation is useless because it has no words at line number {line_number}\n{line.strip()[:100]}') + + if error_found: errors_encounter += 1 - print(f'\nTranslation syntax error at line number: {line_number + 1}\n{line.strip()[:100]}') if errors_encounter > 0: - print('\nYou can visit "https://frappeframework.com/docs/user/en/translations" to resolve this error.') + print('\nVisit "https://frappeframework.com/docs/user/en/translations" to learn about valid translation strings.') sys.exit(1) else: print('\nGood To Go!') diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 4b1147e79f..510e7c7678 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -1,9 +1,10 @@ -name: Trigger Docker build on release +name: 'Trigger Docker build on release' on: release: types: [released] jobs: curl: + name: 'Trigger Docker build on release' runs-on: ubuntu-latest container: image: alpine:latest diff --git a/.github/workflows/docs-checker.yml b/.github/workflows/docs-checker.yml index cdf676dd67..90453cd1b4 100644 --- a/.github/workflows/docs-checker.yml +++ b/.github/workflows/docs-checker.yml @@ -1,10 +1,11 @@ -name: 'Documentation Required' +name: 'Documentation Check' on: pull_request: types: [ opened, synchronize, reopened, edited ] jobs: - build: + docs-required: + name: 'Documentation Required' runs-on: ubuntu-latest steps: diff --git a/.github/workflows/publish-assets-develop.yml b/.github/workflows/publish-assets-develop.yml index ee633ef039..2a934a6795 100644 --- a/.github/workflows/publish-assets-develop.yml +++ b/.github/workflows/publish-assets-develop.yml @@ -1,11 +1,12 @@ -name: Build and Publish Assets for Development +name: 'Frappe Assets' on: push: branches: [ develop ] jobs: - build: + build-dev-and-publish: + name: 'Build and Publish Assets for Development' runs-on: ubuntu-latest steps: diff --git a/.github/workflows/publish-assets-releases.yml b/.github/workflows/publish-assets-releases.yml index 5c412ea1b0..e86f884f35 100644 --- a/.github/workflows/publish-assets-releases.yml +++ b/.github/workflows/publish-assets-releases.yml @@ -1,4 +1,4 @@ -name: Build and Publish Assets built for Releases +name: 'Frappe Assets' on: release: @@ -8,7 +8,8 @@ env: GITHUB_TOKEN: ${{ github.token }} jobs: - build: + build-release-and-publish: + name: 'Build and Publish Assets built for Releases' runs-on: ubuntu-latest steps: @@ -44,4 +45,3 @@ jobs: asset_path: build/assets.tar.gz asset_name: assets.tar.gz asset_content_type: application/octet-stream - diff --git a/.gitignore b/.gitignore index 900ae1c7b7..766288fe2e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ locale *.swp *.egg-info dist/ -build/ +# build/ frappe/docs/current .vscode node_modules @@ -28,7 +28,7 @@ __pycache__/ # Distribution / packaging .Python -build/ +# build/ develop-eggs/ dist/ downloads/ diff --git a/.mergify.yml b/.mergify.yml index 582bbc2ee5..eae959b8a0 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -5,7 +5,7 @@ pull_request_rules: - status-success=Semantic Pull Request - status-success=Travis CI - Pull Request - status-success=security/snyk (frappe) - - label!=don't-merge + - label!=dont-merge - label!=squash - "#approved-reviews-by>=1" actions: @@ -14,10 +14,9 @@ pull_request_rules: - name: Automatic squash on CI success and review conditions: - status-success=Sider - - status-success=Semantic Pull Request - status-success=Travis CI - Pull Request - status-success=security/snyk (frappe) - - label!=don't-merge + - label!=dont-merge - label=squash - "#approved-reviews-by>=1" actions: diff --git a/.stylelintrc b/.stylelintrc new file mode 100644 index 0000000000..1e05d1fb41 --- /dev/null +++ b/.stylelintrc @@ -0,0 +1,9 @@ +{ + "extends": ["stylelint-config-recommended"], + "plugins": ["stylelint-scss"], + "rules": { + "at-rule-no-unknown": null, + "scss/at-rule-no-unknown": true, + "no-descending-specificity": null + } +} \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 2331217363..53ad56a948 100644 --- a/.travis.yml +++ b/.travis.yml @@ -43,7 +43,6 @@ matrix: env: DB=mariadb TYPE=ui before_script: - bench --site test_site execute frappe.utils.install.complete_setup_wizard - - bench --site test_site_producer execute frappe.utils.install.complete_setup_wizard script: bench --site test_site run-ui-tests frappe --headless before_install: @@ -75,8 +74,10 @@ install: - mkdir ~/frappe-bench/sites/test_site - cp $TRAVIS_BUILD_DIR/.travis/consumer_db/$DB.json ~/frappe-bench/sites/test_site/site_config.json - - mkdir ~/frappe-bench/sites/test_site_producer - - cp $TRAVIS_BUILD_DIR/.travis/producer_db/$DB.json ~/frappe-bench/sites/test_site_producer/site_config.json + - if [ $TYPE == "server" ]; then + mkdir ~/frappe-bench/sites/test_site_producer; + cp $TRAVIS_BUILD_DIR/.travis/producer_db/$DB.json ~/frappe-bench/sites/test_site_producer/site_config.json; + fi - if [ $DB == "mariadb" ];then mysql -u root -e "SET GLOBAL character_set_server = 'utf8mb4'"; @@ -104,11 +105,11 @@ install: - cd ./frappe-bench - - sed -i 's/watch:/# watch:/g' Procfile - - sed -i 's/schedule:/# schedule:/g' Procfile + - sed -i 's/^watch:/# watch:/g' Procfile + - sed -i 's/^schedule:/# schedule:/g' Procfile - - if [ $TYPE == "server" ]; then sed -i 's/socketio:/# socketio:/g' Procfile; fi - - if [ $TYPE == "server" ]; then sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile; fi + - if [ $TYPE == "server" ]; then sed -i 's/^socketio:/# socketio:/g' Procfile; fi + - if [ $TYPE == "server" ]; then sed -i 's/^redis_socketio:/# redis_socketio:/g' Procfile; fi - if [ $TYPE == "ui" ]; then bench setup requirements --node; fi @@ -119,7 +120,7 @@ install: - bench start & - bench --site test_site reinstall --yes - - bench --site test_site_producer reinstall --yes + - if [ $TYPE == "server" ]; then bench --site test_site_producer reinstall --yes; fi - bench build --app frappe after_script: diff --git a/CODEOWNERS b/CODEOWNERS index b23f98b034..1afa3f72e3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -4,14 +4,14 @@ # the repo. Unless a later match takes precedence, * @frappe/frappe-review-team -website/ @scmmishra -web_form/ @scmmishra -templates/ @scmmishra -www/ @scmmishra -integrations/ @Mangesh-Khairnar -patches/ @sahil28297 +website/ @prssanna +web_form/ @prssanna +templates/ @surajshetty3416 +www/ @surajshetty3416 +integrations/ @nextchamp-saqib +patches/ @surajshetty3416 dashboard/ @prssanna -email/ @Thunderbottom +email/ @saurabh6790 event_streaming/ @ruchamahabal data_import* @netchampfaris core/ @surajshetty3416 diff --git a/README.md b/README.md index 57d4ca243d..f99988ae79 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Full-stack web application framework that uses Python and MariaDB on the server ## Contributing -1. [Pull Request Requirements](https://github.com/frappe/erpnext/wiki/Pull-Request-Guidelines) +1. [Contribution Guidelines](https://github.com/frappe/erpnext/wiki/Contribution-Guidelines) 1. [Translations](https://translate.erpnext.com) ### Website diff --git a/ci/fix-mariadb.sh b/ci/fix-mariadb.sh deleted file mode 100755 index 886ec5e0d0..0000000000 --- a/ci/fix-mariadb.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -# stolen from http://cgit.drupalcode.org/octopus/commit/?id=db4f837 -includedir=`mysql_config --variable=pkgincludedir` -thiscwd=`pwd` -_THIS_DB_VERSION=`mysql -V 2>&1 | tr -d "\n" | cut -d" " -f6 | awk '{ print $1}' | cut -d"-" -f1 | awk '{ print $1}' | sed "s/[\,']//g"` -if [ "$_THIS_DB_VERSION" = "5.5.40" ] && [ ! -e "$includedir-$_THIS_DB_VERSION-fixed.log" ] ; then - cd $includedir - sudo patch -p1 < $thiscwd/ci/my_config.h.patch &> /dev/null - sudo touch $includedir-$_THIS_DB_VERSION-fixed.log -fi diff --git a/ci/my_config.h.patch b/ci/my_config.h.patch deleted file mode 100644 index 5247b5b39b..0000000000 --- a/ci/my_config.h.patch +++ /dev/null @@ -1,22 +0,0 @@ -diff -burp a/my_config.h b/my_config.h ---- a/my_config.h 2014-10-09 19:32:46.000000000 -0400 -+++ b/my_config.h 2014-10-09 19:35:12.000000000 -0400 -@@ -641,17 +641,4 @@ - #define SIZEOF_TIME_T 8 - /* #undef TIME_T_UNSIGNED */ - --/* -- stat structure (from ) is conditionally defined -- to have different layout and size depending on the defined macros. -- The correct macro is defined in my_config.h, which means it MUST be -- included first (or at least before - so, practically, -- before including any system headers). -- -- __GLIBC__ is defined in --*/ --#ifdef __GLIBC__ --#error MUST be included first! --#endif -- - #endif - diff --git a/cypress.json b/cypress.json index 97ac41bb61..f2508ca66e 100644 --- a/cypress.json +++ b/cypress.json @@ -3,5 +3,9 @@ "projectId": "92odwv", "adminPassword": "admin", "defaultCommandTimeout": 20000, - "pageLoadTimeout": 15000 + "pageLoadTimeout": 15000, + "retries": { + "runMode": 2, + "openMode": 2 + } } diff --git a/cypress/integration/api.js b/cypress/integration/api.js index 2279dc399d..7a5b1611b0 100644 --- a/cypress/integration/api.js +++ b/cypress/integration/api.js @@ -2,12 +2,12 @@ context('API Resources', () => { before(() => { cy.visit('/login'); cy.login(); - cy.visit('/desk#workspace/Website'); + cy.visit('/app/website'); }); it('Creates two Comments', () => { - cy.insert_doc('Comment', {comment_type: 'Comment', content: "hello"}); - cy.insert_doc('Comment', {comment_type: 'Comment', content: "world"}); + cy.insert_doc('Comment', { comment_type: 'Comment', content: "hello" }); + cy.insert_doc('Comment', { comment_type: 'Comment', content: "world" }); }); it('Lists the Comments', () => { diff --git a/cypress/integration/awesome_bar.js b/cypress/integration/awesome_bar.js index 15e85976fc..3e12101532 100644 --- a/cypress/integration/awesome_bar.js +++ b/cypress/integration/awesome_bar.js @@ -2,11 +2,11 @@ context('Awesome Bar', () => { before(() => { cy.visit('/login'); cy.login(); - cy.visit('/desk#workspace/Website'); + cy.visit('/app/website'); }); beforeEach(() => { - cy.get('.navbar-header .navbar-home').click(); + cy.get('.navbar .navbar-home').click(); }); it('navigates to doctype list', () => { @@ -14,16 +14,16 @@ context('Awesome Bar', () => { cy.get('#navbar-search + ul').should('be.visible'); cy.get('#navbar-search').type('{downarrow}{enter}', { delay: 100 }); - cy.get('h1').should('contain', 'To Do'); + cy.get('.title-text').should('contain', 'To Do'); - cy.location('hash').should('eq', '#List/ToDo/List'); + cy.location('pathname').should('eq', '/app/todo'); }); it('find text in doctype list', () => { cy.get('#navbar-search') .type('test in todo{downarrow}{enter}', { delay: 200 }); - cy.get('h1').should('contain', 'To Do'); + cy.get('.title-text').should('contain', 'To Do'); cy.get('[data-original-title="Name"] > .input-with-feedback') .should('have.value', '%test%'); @@ -33,7 +33,7 @@ context('Awesome Bar', () => { cy.get('#navbar-search') .type('new blog post{downarrow}{enter}', { delay: 200 }); - cy.get('.title-text:visible').should('have.text', 'New Blog Post 1'); + cy.get('.title-text:visible').should('have.text', 'New Blog Post'); }); it('calculates math expressions', () => { diff --git a/cypress/integration/control_barcode.js b/cypress/integration/control_barcode.js index 4e05d864e6..1df5e64f0e 100644 --- a/cypress/integration/control_barcode.js +++ b/cypress/integration/control_barcode.js @@ -1,7 +1,7 @@ context('Control Barcode', () => { beforeEach(() => { cy.login(); - cy.visit('/desk#workspace/Website'); + cy.visit('/app/website'); }); function get_dialog_with_barcode() { diff --git a/cypress/integration/control_duration.js b/cypress/integration/control_duration.js index edad759216..266d421e70 100644 --- a/cypress/integration/control_duration.js +++ b/cypress/integration/control_duration.js @@ -1,10 +1,10 @@ context('Control Duration', () => { before(() => { cy.login(); - cy.visit('/desk#workspace/Website'); + cy.visit('/app/website'); }); - function get_dialog_with_duration(hide_days=0, hide_seconds=0) { + function get_dialog_with_duration(hide_days = 0, hide_seconds = 0) { return cy.dialog({ title: 'Duration', fields: [{ @@ -22,11 +22,11 @@ context('Control Duration', () => { .first() .click(); cy.get('.duration-input[data-duration=days]') - .type(45, {force: true}) - .blur({force: true}); + .type(45, { force: true }) + .blur({ force: true }); cy.get('.duration-input[data-duration=minutes]') .type(30) - .blur({force: true}); + .blur({ force: true }); cy.get('.frappe-control[data-fieldname=duration] input').first().should('have.value', '45d 30m'); cy.get('.frappe-control[data-fieldname=duration] input').first().blur(); cy.get('.duration-picker').should('not.be.visible'); diff --git a/cypress/integration/control_link.js b/cypress/integration/control_link.js index 0dc7d5b88e..8f9257e9c4 100644 --- a/cypress/integration/control_link.js +++ b/cypress/integration/control_link.js @@ -1,11 +1,11 @@ context('Control Link', () => { before(() => { cy.login(); - cy.visit('/desk#workspace/Website'); + cy.visit('/app/website'); }); beforeEach(() => { - cy.visit('/desk#workspace/Website'); + cy.visit('/app/website'); cy.create_records({ doctype: 'ToDo', description: 'this is a test todo for link' @@ -29,8 +29,7 @@ context('Control Link', () => { it('should set the valid value', () => { get_dialog_with_link().as('dialog'); - cy.server(); - cy.route('POST', '/api/method/frappe.desk.search.search_link').as('search_link'); + cy.intercept('POST', '/api/method/frappe.desk.search.search_link').as('search_link'); cy.get('.frappe-control[data-fieldname=link] input').focus().as('input'); cy.wait('@search_link'); @@ -50,8 +49,7 @@ context('Control Link', () => { it('should unset invalid value', () => { get_dialog_with_link().as('dialog'); - cy.server(); - cy.route('GET', '/api/method/frappe.desk.form.utils.validate_link*').as('validate_link'); + cy.intercept('GET', '/api/method/frappe.desk.form.utils.validate_link*').as('validate_link'); cy.get('.frappe-control[data-fieldname=link] input') .type('invalid value', { delay: 100 }) @@ -63,9 +61,8 @@ context('Control Link', () => { it('should route to form on arrow click', () => { get_dialog_with_link().as('dialog'); - cy.server(); - cy.route('GET', '/api/method/frappe.desk.form.utils.validate_link*').as('validate_link'); - cy.route('POST', '/api/method/frappe.desk.search.search_link').as('search_link'); + cy.intercept('GET', '/api/method/frappe.desk.form.utils.validate_link*').as('validate_link'); + cy.intercept('POST', '/api/method/frappe.desk.search.search_link').as('search_link'); cy.get('@todos').then(todos => { cy.get('.frappe-control[data-fieldname=link] input').as('input'); @@ -77,7 +74,7 @@ context('Control Link', () => { cy.get('.frappe-control[data-fieldname=link] .link-btn') .should('be.visible') .click(); - cy.location('hash').should('eq', `#Form/ToDo/${todos[0]}`); + cy.location('pathname').should('eq', `/app/todo/${todos[0]}`); }); }); }); diff --git a/cypress/integration/control_rating.js b/cypress/integration/control_rating.js index e89ab2d3be..592ed87004 100644 --- a/cypress/integration/control_rating.js +++ b/cypress/integration/control_rating.js @@ -1,7 +1,7 @@ context('Control Rating', () => { before(() => { cy.login(); - cy.visit('/desk#workspace/Website'); + cy.visit('/app/website'); }); function get_dialog_with_rating() { @@ -18,7 +18,7 @@ context('Control Rating', () => { get_dialog_with_rating().as('dialog'); cy.get('div.rating') - .children('i.fa') + .children('svg') .first() .click() .should('have.class', 'star-click'); @@ -33,11 +33,11 @@ context('Control Rating', () => { get_dialog_with_rating(); cy.get('div.rating') - .children('i.fa') + .children('svg') .first() .invoke('trigger', 'mouseenter') .should('have.class', 'star-hover') .invoke('trigger', 'mouseleave') .should('not.have.class', 'star-hover'); }); -}); \ No newline at end of file +}); diff --git a/cypress/integration/control_select.js b/cypress/integration/control_select.js new file mode 100644 index 0000000000..0bc719b4a7 --- /dev/null +++ b/cypress/integration/control_select.js @@ -0,0 +1,36 @@ +context('Control Select', () => { + before(() => { + cy.login(); + cy.visit('/app/website'); + }); + + function get_dialog_with_select() { + return cy.dialog({ + title: 'Select', + fields: [{ + 'fieldname': 'select_control', + 'fieldtype': 'Select', + 'placeholder': 'Select an Option', + 'options': ['', 'Option 1', 'Option 2', 'Option 2'], + }] + }); + } + + it('toggles placholder on clicking an option', () => { + get_dialog_with_select().as('dialog'); + + cy.get('.frappe-control[data-fieldname=select_control] .control-input').as('control'); + cy.get('.frappe-control[data-fieldname=select_control] .control-input select').as('select'); + cy.get('@control').get('.select-icon').should('exist'); + cy.get('@control').get('.placeholder').should('have.css', 'display', 'block'); + cy.get('@select').select('Option 1'); + cy.get('@control').get('.placeholder').should('have.css', 'display', 'none'); + cy.get('@select').invoke('val', ''); + cy.get('@control').get('.placeholder').should('have.css', 'display', 'block'); + + + cy.get('@dialog').then(dialog => { + dialog.hide(); + }); + }); +}); diff --git a/cypress/integration/datetime.js b/cypress/integration/datetime.js index 9bf01632bf..b310526c7c 100644 --- a/cypress/integration/datetime.js +++ b/cypress/integration/datetime.js @@ -4,7 +4,7 @@ const doctype_name = datetime_doctype.name; context('Control Date, Time and DateTime', () => { before(() => { cy.login(); - cy.visit('/desk#workspace/Website'); + cy.visit('/app/website'); return cy.insert_doc('DocType', datetime_doctype, true); }); @@ -42,7 +42,7 @@ context('Control Date, Time and DateTime', () => { .should('be.visible'); cy.get( '.datepickers-container .datepicker.active .datepicker--cell-day.-current-' - ).click(); + ).click({ force: true }); cy.window() .its('cur_frm') diff --git a/cypress/integration/depends_on.js b/cypress/integration/depends_on.js index 93417014c5..d33babb134 100644 --- a/cypress/integration/depends_on.js +++ b/cypress/integration/depends_on.js @@ -1,9 +1,33 @@ context('Depends On', () => { before(() => { cy.login(); - cy.visit('/desk#workspace/Website'); + cy.visit('/app/website'); return cy.window().its('frappe').then(frappe => { - return frappe.call('frappe.tests.ui_test_helpers.create_doctype', { + return frappe.xcall('frappe.tests.ui_test_helpers.create_child_doctype', { + name: 'Child Test Depends On', + fields: [ + { + "label": "Child Test Field", + "fieldname": "child_test_field", + "fieldtype": "Data", + "in_list_view": 1, + }, + { + "label": "Child Dependant Field", + "fieldname": "child_dependant_field", + "fieldtype": "Data", + "in_list_view": 1, + }, + { + "label": "Child Display Dependant Field", + "fieldname": "child_display_dependant_field", + "fieldtype": "Data", + "in_list_view": 1, + }, + ] + }); + }).then(frappe => { + return frappe.xcall('frappe.tests.ui_test_helpers.create_doctype', { name: 'Test Depends On', fields: [ { @@ -24,6 +48,13 @@ context('Depends On', () => { "fieldtype": "Data", 'depends_on': "eval:doc.test_field=='Value'" }, + { + "label": "Child Test Depends On Field", + "fieldname": "child_test_depends_on_field", + "fieldtype": "Table", + 'read_only_depends_on': "eval:doc.test_field=='Some Other Value'", + 'options': "Child Test Depends On" + }, ] }); }); @@ -33,7 +64,7 @@ context('Depends On', () => { cy.fill_field('test_field', 'Some Value'); cy.get('button.primary-action').contains('Save').click(); cy.get('.msgprint-dialog .modal-title').contains('Missing Fields').should('be.visible'); - cy.get('body').click(); + cy.hide_dialog(); cy.fill_field('test_field', 'Random value'); cy.get('button.primary-action').contains('Save').click(); cy.get('.msgprint-dialog .modal-title').contains('Missing Fields').should('not.be.visible'); @@ -48,6 +79,30 @@ context('Depends On', () => { cy.get('body').click(); cy.get('.control-input [data-fieldname="dependant_field"]').should('not.be.disabled'); }); + it('should set the table and its fields as read only depending on other fields value', () => { + cy.new_form('Test Depends On'); + cy.fill_field('dependant_field', 'Some Value'); + //cy.fill_field('test_field', 'Some Other Value'); + cy.get('.frappe-control[data-fieldname="child_test_depends_on_field"]').as('table'); + cy.get('@table').find('button.grid-add-row').click(); + cy.get('@table').find('[data-idx="1"]').as('row1'); + cy.get('@row1').find('.btn-open-row').click(); + cy.get('@row1').find('.form-in-grid').as('row1-form_in_grid'); + //cy.get('@row1-form_in_grid').find('') + cy.fill_table_field('child_test_depends_on_field', '1', 'child_test_field', 'Some Value'); + cy.fill_table_field('child_test_depends_on_field', '1', 'child_dependant_field', 'Some Other Value'); + + cy.get('@row1-form_in_grid').find('.grid-collapse-row').click(); + + // set the table to read-only + cy.fill_field('test_field', 'Some Other Value'); + + // grid row form fields should be read-only + cy.get('@row1').find('.btn-open-row').click(); + + cy.get('@row1-form_in_grid').find('.control-input [data-fieldname="child_test_field"]').should('be.disabled'); + cy.get('@row1-form_in_grid').find('.control-input [data-fieldname="child_dependant_field"]').should('be.disabled'); + }); it('should display the field depending on other fields value', () => { cy.new_form('Test Depends On'); cy.get('.control-input [data-fieldname="display_dependant_field"]').should('not.be.visible'); diff --git a/cypress/integration/file_uploader.js b/cypress/integration/file_uploader.js index 5e9a264189..2f457983de 100644 --- a/cypress/integration/file_uploader.js +++ b/cypress/integration/file_uploader.js @@ -1,7 +1,7 @@ context('FileUploader', () => { before(() => { cy.login(); - cy.visit('/desk#workspace/Website'); + cy.visit('/app'); }); function open_upload_dialog() { @@ -19,40 +19,36 @@ context('FileUploader', () => { it('should accept dropped files', () => { open_upload_dialog(); - cy.fixture('example.json').then(fileContent => { - cy.get_open_dialog().find('.file-upload-area').upload( - { fileContent, fileName: 'example.json', mimeType: 'application/json' }, - { subjectType: 'drag-n-drop' }, - ); - cy.get_open_dialog().find('.file-info').should('contain', 'example.json'); - cy.server(); - cy.route('POST', '/api/method/upload_file').as('upload_file'); - cy.get_open_dialog().find('.btn-primary').click(); - cy.wait('@upload_file').its('status').should('be', 200); - cy.get('.modal:visible').should('not.exist'); + cy.get_open_dialog().find('.file-upload-area').attachFile('example.json', { + subjectType: 'drag-n-drop', }); + + cy.get_open_dialog().find('.file-name').should('contain', 'example.json'); + cy.intercept('POST', '/api/method/upload_file').as('upload_file'); + cy.get_open_dialog().find('.btn-modal-primary').click(); + cy.wait('@upload_file').its('response.statusCode').should('eq', 200); + cy.get('.modal:visible').should('not.exist'); }); it('should accept uploaded files', () => { open_upload_dialog(); - cy.get_open_dialog().find('a:contains("uploaded file")').click(); + cy.get_open_dialog().find('.btn-file-upload div:contains("Library")').click(); + cy.get('.file-filter').type('example.json'); cy.get_open_dialog().find('.tree-label:contains("example.json")').first().click(); - cy.server(); - cy.route('POST', '/api/method/upload_file').as('upload_file'); + cy.intercept('POST', '/api/method/upload_file').as('upload_file'); cy.get_open_dialog().find('.btn-primary').click(); cy.wait('@upload_file').its('response.body.message') - .should('have.property', 'file_url', '/private/files/example.json'); + .should('have.property', 'file_name', 'example.json'); cy.get('.modal:visible').should('not.exist'); }); it('should accept web links', () => { open_upload_dialog(); - cy.get_open_dialog().find('a:contains("web link")').click(); + cy.get_open_dialog().find('.btn-file-upload div:contains("Link")').click(); cy.get_open_dialog().find('.file-web-link input').type('https://github.com', { delay: 100, force: true }); - cy.server(); - cy.route('POST', '/api/method/upload_file').as('upload_file'); + cy.intercept('POST', '/api/method/upload_file').as('upload_file'); cy.get_open_dialog().find('.btn-primary').click(); cy.wait('@upload_file').its('response.body.message') .should('have.property', 'file_url', 'https://github.com'); diff --git a/cypress/integration/form.js b/cypress/integration/form.js index ef89a18e7d..9c63fe4e8b 100644 --- a/cypress/integration/form.js +++ b/cypress/integration/form.js @@ -1,56 +1,50 @@ context('Form', () => { before(() => { cy.login(); - cy.visit('/desk#workspace/Website'); + cy.visit('/app/website'); return cy.window().its('frappe').then(frappe => { return frappe.call("frappe.tests.ui_test_helpers.create_contact_records"); }); }); it('create a new form', () => { - cy.visit('/desk#Form/ToDo/New ToDo 1'); + cy.visit('/app/todo/new'); cy.fill_field('description', 'this is a test todo', 'Text Editor').blur(); cy.wait(300); cy.get('.page-title').should('contain', 'Not Saved'); - cy.server(); - cy.route({ + cy.intercept({ method: 'POST', url: 'api/method/frappe.desk.form.save.savedocs' }).as('form_save'); cy.get('.primary-action').click(); - cy.wait('@form_save').its('status').should('eq', 200); - cy.visit('/desk#List/ToDo'); - cy.location('hash').should('eq', '#List/ToDo/List'); - cy.get('h1').should('be.visible').and('contain', 'To Do'); + cy.wait('@form_save').its('response.statusCode').should('eq', 200); + cy.visit('/app/todo'); + cy.get('.title-text').should('be.visible').and('contain', 'To Do'); cy.get('.list-row').should('contain', 'this is a test todo'); }); it('navigates between documents with child table list filters applied', () => { - cy.visit('/desk#List/Contact'); - cy.location('hash').should('eq', '#List/Contact/List'); - cy.get('.tag-filters-area .btn:contains("Add Filter")').click(); - cy.get('.fieldname-select-area').should('exist'); - cy.get('.fieldname-select-area input').type('Number{enter}', { force: true }); + cy.visit('/app/contact'); + cy.add_filter(); cy.get('.filter-field .input-with-feedback.form-control').type('123', { force: true }); - cy.get('.filter-box .btn:contains("Apply")').click({ force: true }); - cy.visit('/desk#Form/Contact/Test Form Contact 3'); + cy.get('.filter-popover .apply-filters').click({ force: true }); + cy.visit('/app/contact/Test Form Contact 3'); cy.get('.prev-doc').should('be.visible').click(); cy.get('.msgprint-dialog .modal-body').contains('No further records').should('be.visible'); - cy.get('.btn-modal-close:visible').click(); + cy.hide_dialog(); cy.get('.next-doc').click(); cy.wait(200); + cy.hide_dialog(); cy.contains('Test Form Contact 2').should('not.exist'); - cy.get('.page-title .title-text').should('contain', 'Test Form Contact 1'); + cy.get('.title-text').should('contain', 'Test Form Contact 3'); // clear filters - cy.window().its('frappe').then((frappe) => { - let list_view = frappe.get_list_view('Contact'); - list_view.filter_area.filter_list.clear_filters(); - }); + cy.visit('/app/contact'); + cy.clear_filters(); }); it('validates behaviour of Data options validations in child table', () => { // test email validations for set_invalid controller let website_input = 'website.in'; - let expectBackgroundColor = 'rgb(255, 220, 220)'; + let expectBackgroundColor = 'rgb(255, 245, 245)'; - cy.visit('/desk#Form/Contact/New Contact 1'); + cy.visit('/app/contact/new'); cy.get('.frappe-control[data-fieldname="email_ids"]').as('table'); cy.get('@table').find('button.grid-add-row').click(); cy.get('.grid-body .rows [data-fieldname="email_id"]').click(); diff --git a/cypress/integration/grid_pagination.js b/cypress/integration/grid_pagination.js index b383f30bb8..8f6b79c1f4 100644 --- a/cypress/integration/grid_pagination.js +++ b/cypress/integration/grid_pagination.js @@ -1,24 +1,24 @@ context('Grid Pagination', () => { beforeEach(() => { cy.login(); - cy.visit('/desk#workspace/Website'); + cy.visit('/app/website'); }); before(() => { cy.login(); - cy.visit('/desk#workspace/Website'); + cy.visit('/app/website'); return cy.window().its('frappe').then(frappe => { return frappe.call("frappe.tests.ui_test_helpers.create_contact_phone_nos_records"); }); }); it('creates pages for child table', () => { - cy.visit('/desk#Form/Contact/Test Contact'); + cy.visit('/app/contact/Test Contact'); cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); cy.get('@table').find('.current-page-number').should('contain', '1'); cy.get('@table').find('.total-page-number').should('contain', '20'); cy.get('@table').find('.grid-body .grid-row').should('have.length', 50); }); it('goes to the next and previous page', () => { - cy.visit('/desk#Form/Contact/Test Contact'); + cy.visit('/app/contact/Test Contact'); cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); cy.get('@table').find('.next-page').click(); cy.get('@table').find('.current-page-number').should('contain', '2'); @@ -27,21 +27,21 @@ context('Grid Pagination', () => { cy.get('@table').find('.current-page-number').should('contain', '1'); cy.get('@table').find('.grid-body .grid-row').first().should('have.attr', 'data-idx', '1'); }); - it('adds and deletes rows and changes page', ()=> { - cy.visit('/desk#Form/Contact/Test Contact'); + it('adds and deletes rows and changes page', () => { + cy.visit('/app/contact/Test Contact'); cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); cy.get('@table').find('button.grid-add-row').click(); cy.get('@table').find('.grid-body .row-index').should('contain', 1001); cy.get('@table').find('.current-page-number').should('contain', '21'); cy.get('@table').find('.total-page-number').should('contain', '21'); - cy.get('@table').find('.grid-body .grid-row .grid-row-check').click({force: true}); + cy.get('@table').find('.grid-body .grid-row .grid-row-check').click({ force: true }); cy.get('@table').find('button.grid-remove-rows').click(); cy.get('@table').find('.grid-body .row-index').last().should('contain', 1000); cy.get('@table').find('.current-page-number').should('contain', '20'); cy.get('@table').find('.total-page-number').should('contain', '20'); }); // it('deletes all rows', ()=> { - // cy.visit('/desk#Form/Contact/Test Contact'); + // cy.visit('/app/contact/Test Contact'); // cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); // cy.get('@table').find('.grid-heading-row .grid-row-check').click({force: true}); // cy.get('@table').find('button.grid-remove-all-rows').click(); diff --git a/cypress/integration/list_view.js b/cypress/integration/list_view.js index 0d26ca90a2..633d1335ab 100644 --- a/cypress/integration/list_view.js +++ b/cypress/integration/list_view.js @@ -1,30 +1,31 @@ context('List View', () => { before(() => { cy.login(); - cy.visit('/desk#workspace/Website'); + cy.visit('/app/website'); return cy.window().its('frappe').then(frappe => { return frappe.xcall("frappe.tests.ui_test_helpers.setup_workflow"); }); }); it('enables "Actions" button', () => { - const actions = ['Approve', 'Reject', 'Edit', 'Assign To', 'Apply Assignment Rule', 'Print', 'Delete']; + const actions = ['Approve', 'Reject', 'Edit', 'Assign To', 'Apply Assignment Rule', 'Add Tags', 'Print', 'Delete']; cy.go_to_list('ToDo'); cy.get('.list-row-container:contains("Pending") .list-row-checkbox').click({ multiple: true, force: true }); - cy.get('.btn.btn-primary.btn-sm.dropdown-toggle').contains('Actions').should('be.visible').click(); - cy.get('.dropdown-menu li:visible').should('have.length', 7).each((el, index) => { + cy.get('.actions-btn-group button').contains('Actions').should('be.visible').click(); + cy.get('.dropdown-menu li:visible .dropdown-item').should('have.length', 8).each((el, index) => { cy.wrap(el).contains(actions[index]); }).then((elements) => { - cy.server(); - cy.route({ + cy.intercept({ method: 'POST', - url:'api/method/frappe.model.workflow.bulk_workflow_approval' + url: 'api/method/frappe.model.workflow.bulk_workflow_approval' }).as('bulk-approval'); - cy.route({ + cy.intercept({ method: 'POST', - url:'api/method/frappe.desk.reportview.get' + url: 'api/method/frappe.desk.reportview.get' }).as('real-time-update'); cy.wrap(elements).contains('Approve').click(); cy.wait(['@bulk-approval', '@real-time-update']); + cy.hide_dialog(); + cy.clear_filters(); cy.get('.list-row-container:visible').should('contain', 'Approved'); }); }); diff --git a/cypress/integration/list_view_settings.js b/cypress/integration/list_view_settings.js index 47f8efe94b..52512b911e 100644 --- a/cypress/integration/list_view_settings.js +++ b/cypress/integration/list_view_settings.js @@ -1,36 +1,36 @@ context('List View Settings', () => { beforeEach(() => { cy.login(); - cy.visit('/desk#workspace/Website'); + cy.visit('/app/website'); }); it('Default settings', () => { - cy.visit('/desk#List/DocType/List'); + cy.visit('/app/List/DocType/List'); cy.get('.list-count').should('contain', "20 of"); - cy.get('.sidebar-stat').should('contain', "Tags"); + cy.get('.list-stats').should('contain', "Tags"); }); it('disable count and sidebar stats then verify', () => { cy.wait(300); - cy.visit('/desk#List/DocType/List'); + cy.visit('/app/List/DocType/List'); cy.wait(300); cy.get('.list-count').should('contain', "20 of"); - cy.get('button').contains('Menu').click(); - cy.get('.dropdown-menu li').filter(':visible').contains('Settings').click(); - cy.get('.modal-dialog').should('contain', 'Settings'); + cy.get('.menu-btn-group button').click(); + cy.get('.dropdown-menu li').filter(':visible').contains('List Settings').click(); + cy.get('.modal-dialog').should('contain', 'DocType Settings'); - cy.get('input[data-fieldname="disable_count"]').check({force: true}); - cy.get('input[data-fieldname="disable_sidebar_stats"]').check({force: true}); + cy.get('input[data-fieldname="disable_count"]').check({ force: true }); + cy.get('input[data-fieldname="disable_sidebar_stats"]').check({ force: true }); cy.get('button').filter(':visible').contains('Save').click(); - cy.reload(); + cy.reload({ force: true }); cy.get('.list-count').should('be.empty'); - cy.get('.list-sidebar .sidebar-stat').should('not.exist'); + cy.get('.list-sidebar .list-tags').should('not.exist'); - cy.get('button').contains('Menu').click({force: true}); - cy.get('.dropdown-menu li').filter(':visible').contains('Settings').click(); - cy.get('.modal-dialog').should('contain', 'Settings'); - cy.get('input[data-fieldname="disable_count"]').uncheck({force: true}); - cy.get('input[data-fieldname="disable_sidebar_stats"]').uncheck({force: true}); + cy.get('.menu-btn-group button').click({ force: true }); + cy.get('.dropdown-menu li').filter(':visible').contains('List Settings').click(); + cy.get('.modal-dialog').should('contain', 'DocType Settings'); + cy.get('input[data-fieldname="disable_count"]').uncheck({ force: true }); + cy.get('input[data-fieldname="disable_sidebar_stats"]').uncheck({ force: true }); cy.get('button').filter(':visible').contains('Save').click(); }); }); diff --git a/cypress/integration/login.js b/cypress/integration/login.js index 861377444c..6b109dd18d 100644 --- a/cypress/integration/login.js +++ b/cypress/integration/login.js @@ -2,7 +2,7 @@ context('Login', () => { beforeEach(() => { cy.request('/api/method/logout'); cy.visit('/login'); - cy.location().should('be', '/login'); + cy.location('pathname').should('eq', '/login'); }); it('greets with login screen', () => { @@ -11,13 +11,13 @@ context('Login', () => { it('validates password', () => { cy.get('#login_email').type('Administrator'); - cy.get('.btn-login').click(); + cy.get('.btn-login:visible').click(); cy.location('pathname').should('eq', '/login'); }); it('validates email', () => { cy.get('#login_password').type('qwe'); - cy.get('.btn-login').click(); + cy.get('.btn-login:visible').click(); cy.location('pathname').should('eq', '/login'); }); @@ -25,8 +25,8 @@ context('Login', () => { cy.get('#login_email').type('Administrator'); cy.get('#login_password').type('qwer'); - cy.get('.btn-login').click(); - cy.get('.page-card-head').contains('Invalid Login. Try again.'); + cy.get('.btn-login:visible').click(); + cy.get('.btn-login:visible').contains('Invalid Login. Try again.'); cy.location('pathname').should('eq', '/login'); }); @@ -34,8 +34,8 @@ context('Login', () => { cy.get('#login_email').type('Administrator'); cy.get('#login_password').type(Cypress.config('adminPassword')); - cy.get('.btn-login').click(); - cy.location('pathname').should('eq', '/desk'); + cy.get('.btn-login:visible').click(); + cy.location('pathname').should('eq', '/app'); cy.window().its('frappe.session.user').should('eq', 'Administrator'); }); @@ -60,7 +60,7 @@ context('Login', () => { cy.get('#login_email').type('Administrator'); cy.get('#login_password').type(Cypress.config('adminPassword')); - cy.get('.btn-login').click(); + cy.get('.btn-login:visible').click(); // verify redirected location and url params after login cy.url().should('include', '/me?' + payload.toString().replace('+', '%20')); diff --git a/cypress/integration/query_report.js b/cypress/integration/query_report.js index 5581a20edc..e2a1c3fc79 100644 --- a/cypress/integration/query_report.js +++ b/cypress/integration/query_report.js @@ -1,33 +1,33 @@ context('Query Report', () => { before(() => { cy.login(); - cy.visit('/desk#workspace/Website'); + cy.visit('/app/website'); }); it('add custom column in report', () => { - cy.visit('/desk#query-report/Permitted Documents For User'); + cy.visit('/app/query-report/Permitted Documents For User'); - cy.get('div[class="page-form flex"]', {timeout: 60000}).should('have.length', 1).then(()=>{ + cy.get('.page-form.flex', { timeout: 60000 }).should('have.length', 1).then(() => { cy.get('#page-query-report input[data-fieldname="user"]').as('input'); - cy.get('@input').focus().type('test@erpnext.com', { delay: 100 }); - + cy.get('@input').focus().type('test@erpnext.com', { delay: 100 }).blur(); + cy.wait(300); cy.get('#page-query-report input[data-fieldname="doctype"]').as('input-test'); cy.get('@input-test').focus().type('Role', { delay: 100 }).blur(); cy.get('.datatable').should('exist'); - cy.get('button').contains('Menu').click({force: true}); - cy.get('.dropdown-menu li').contains('Add Column').click({force: true}); + cy.get('.menu-btn-group button').click({ force: true }); + cy.get('.dropdown-menu li').contains('Add Column').click({ force: true }); cy.get('.modal-dialog').should('contain', 'Add Column'); - cy.get('select[data-fieldname="doctype"]').select("Role", {force: true}); - cy.get('select[data-fieldname="field"]').select("Role Name", {force: true}); - cy.get('select[data-fieldname="insert_after"]').select("Name", {force: true}); - cy.get('button').contains('Submit').click({force: true}); - cy.get('button').contains('Menu').click({force: true}); - cy.get('.dropdown-menu li').contains('Save').click({force: true}); + cy.get('select[data-fieldname="doctype"]').select("Role", { force: true }); + cy.get('select[data-fieldname="field"]').select("Role Name", { force: true }); + cy.get('select[data-fieldname="insert_after"]').select("Name", { force: true }); + cy.get('button').contains('Submit').click({ force: true }); + cy.get('.menu-btn-group button').click({ force: true }); + cy.get('.dropdown-menu li').contains('Save').click({ force: true }); cy.get('.modal-dialog').should('contain', 'Save Report'); - cy.get('input[data-fieldname="report_name"]').type("Test Report", {delay:100, force: true}); - cy.get('button').contains('Submit').click({timeout:1000, force: true}); + cy.get('input[data-fieldname="report_name"]').type("Test Report", { delay: 100, force: true }); + cy.get('button').contains('Submit').click({ timeout: 1000, force: true }); }); }); }); \ No newline at end of file diff --git a/cypress/integration/recorder.js b/cypress/integration/recorder.js index a0f8cc3621..7236200741 100644 --- a/cypress/integration/recorder.js +++ b/cypress/integration/recorder.js @@ -4,17 +4,17 @@ context('Recorder', () => { }); it('Navigate to Recorder', () => { - cy.visit('/desk#workspace/Website'); + cy.visit('/app'); cy.awesomebar('recorder'); - cy.get('h1').should('contain', 'Recorder'); - cy.location('hash').should('eq', '#recorder'); + cy.get('h3').should('contain', 'Recorder'); + cy.url().should('include', '/recorder/detail'); }); it('Recorder Empty State', () => { - cy.visit('/desk#recorder'); + cy.visit('/app/recorder'); cy.get('.title-text').should('contain', 'Recorder'); - cy.get('.indicator').should('contain', 'Inactive').should('have.class', 'red'); + cy.get('.indicator-pill').should('contain', 'Inactive').should('have.class', 'red'); cy.get('.primary-action').should('contain', 'Start'); cy.get('.btn-secondary').should('contain', 'Clear'); @@ -24,53 +24,49 @@ context('Recorder', () => { }); it('Recorder Start', () => { - cy.visit('/desk#recorder'); + cy.visit('/app/recorder'); cy.get('.primary-action').should('contain', 'Start').click(); - cy.get('.indicator').should('contain', 'Active').should('have.class', 'green'); + cy.get('.indicator-pill').should('contain', 'Active').should('have.class', 'green'); cy.get('.msg-box').should('contain', 'No Requests'); - cy.server(); - cy.visit('/desk#List/DocType/List'); - cy.route('POST', '/api/method/frappe.desk.reportview.get').as('list_refresh'); + cy.visit('/app/List/DocType/List'); + cy.intercept('POST', '/api/method/frappe.desk.reportview.get').as('list_refresh'); cy.wait('@list_refresh'); cy.get('.title-text').should('contain', 'DocType'); cy.get('.list-count').should('contain', '20 of '); - cy.visit('/desk#recorder'); + cy.visit('/app/recorder'); cy.get('.title-text').should('contain', 'Recorder'); cy.get('.result-list').should('contain', '/api/method/frappe.desk.reportview.get'); cy.get('#page-recorder .primary-action').should('contain', 'Stop').click(); + cy.wait(500); cy.get('#page-recorder .btn-secondary').should('contain', 'Clear').click(); cy.get('.msg-box').should('contain', 'Inactive'); }); it('Recorder View Request', () => { - cy.visit('/desk#recorder'); + cy.visit('/app/recorder'); cy.get('.primary-action').should('contain', 'Start').click(); - cy.server(); - cy.visit('/desk#List/DocType/List'); - cy.route('POST', '/api/method/frappe.desk.reportview.get').as('list_refresh'); + cy.visit('/app/List/DocType/List'); + cy.intercept('POST', '/api/method/frappe.desk.reportview.get').as('list_refresh'); cy.wait('@list_refresh'); cy.get('.title-text').should('contain', 'DocType'); cy.get('.list-count').should('contain', '20 of '); - // temporarily commenting out theses tests as they seem to be - // randomly failing maybe due a backround event + cy.visit('/app/recorder'); - // cy.visit('/desk#recorder'); + cy.get('.list-row-container span').contains('/api/method/frappe').click(); - // cy.get('.list-row-container span').contains('/api/method/frappe').click(); + cy.url().should('include', '/recorder/request'); + cy.get('form').should('contain', '/api/method/frappe'); - // cy.location('hash').should('contain', '#recorder/request/'); - // cy.get('form').should('contain', '/api/method/frappe'); - - // cy.get('#page-recorder .primary-action').should('contain', 'Stop').click(); - // cy.get('#page-recorder .btn-secondary').should('contain', 'Clear').click(); - // cy.location('hash').should('eq', '#recorder'); + cy.get('#page-recorder .primary-action').should('contain', 'Stop').click(); + cy.wait(200); + cy.get('#page-recorder .btn-secondary').should('contain', 'Clear').click(); }); }); \ No newline at end of file diff --git a/cypress/integration/relative_time_filters.js b/cypress/integration/relative_time_filters.js index ac70c44345..80e6387d99 100644 --- a/cypress/integration/relative_time_filters.js +++ b/cypress/integration/relative_time_filters.js @@ -4,46 +4,44 @@ context('Relative Timeframe', () => { }); before(() => { cy.login(); - cy.visit('/desk#workspace/Website'); + cy.visit('/app/website'); cy.window().its('frappe').then(frappe => { frappe.call("frappe.tests.ui_test_helpers.create_todo_records"); }); }); it('sets relative timespan filter for last week and filters list', () => { - cy.visit('/desk#List/ToDo/List'); + cy.visit('/app/List/ToDo/List'); + cy.clear_filters(); cy.get('.list-row:contains("this is fourth todo")').should('exist'); - cy.get('.tag-filters-area .btn:contains("Add Filter")').click(); + cy.add_filter(); cy.get('.fieldname-select-area').should('exist'); cy.get('.fieldname-select-area input').type("Due Date{enter}", { delay: 100 }); cy.get('select.condition.form-control').select("Timespan"); cy.get('.filter-field select.input-with-feedback.form-control').select("last week"); - cy.server(); - cy.route('POST', '/api/method/frappe.desk.reportview.get').as('list_refresh'); - cy.get('.filter-box .btn:contains("Apply")').click(); + cy.intercept('POST', '/api/method/frappe.desk.reportview.get').as('list_refresh'); + cy.get('.filter-popover .apply-filters').click({ force: true }); cy.wait('@list_refresh'); cy.get('.list-row-container').its('length').should('eq', 1); cy.get('.list-row-container').should('contain', 'this is second todo'); - cy.route('POST', '/api/method/frappe.model.utils.user_settings.save') + cy.intercept('POST', '/api/method/frappe.model.utils.user_settings.save') .as('save_user_settings'); - cy.get('.remove-filter.btn').click(); + cy.clear_filters(); cy.wait('@save_user_settings'); }); it('sets relative timespan filter for next week and filters list', () => { - cy.visit('/desk#List/ToDo/List'); + cy.visit('/app/List/ToDo/List'); + cy.clear_filters(); cy.get('.list-row:contains("this is fourth todo")').should('exist'); - cy.get('.tag-filters-area .btn:contains("Add Filter")').click(); + cy.add_filter(); cy.get('.fieldname-select-area input').type("Due Date{enter}", { delay: 100 }); cy.get('select.condition.form-control').select("Timespan"); cy.get('.filter-field select.input-with-feedback.form-control').select("next week"); - cy.server(); - cy.route('POST', '/api/method/frappe.desk.reportview.get').as('list_refresh'); - cy.get('.filter-box .btn:contains("Apply")').click(); + cy.intercept('POST', '/api/method/frappe.desk.reportview.get').as('list_refresh'); + cy.get('.filter-popover .apply-filters').click({ force: true }); cy.wait('@list_refresh'); - cy.get('.list-row-container').its('length').should('eq', 1); - cy.get('.list-row').should('contain', 'this is first todo'); - cy.route('POST', '/api/method/frappe.model.utils.user_settings.save') + cy.intercept('POST', '/api/method/frappe.model.utils.user_settings.save') .as('save_user_settings'); - cy.get('.remove-filter.btn').click(); + cy.clear_filters(); cy.wait('@save_user_settings'); }); }); diff --git a/cypress/integration/report_view.js b/cypress/integration/report_view.js index deeef6bdd5..ea76246ae2 100644 --- a/cypress/integration/report_view.js +++ b/cypress/integration/report_view.js @@ -4,7 +4,7 @@ const doctype_name = custom_submittable_doctype.name; context('Report View', () => { before(() => { cy.login(); - cy.visit('/desk#workspace/Website'); + cy.visit('/app/website'); cy.insert_doc('DocType', custom_submittable_doctype, true); cy.clear_cache(); cy.insert_doc(doctype_name, { @@ -16,15 +16,14 @@ context('Report View', () => { }, true).as('doc'); }); it('Field with enabled allow_on_submit should be editable.', () => { - cy.server(); - cy.route('POST', 'api/method/frappe.client.set_value').as('value-update'); - cy.visit(`/desk#List/${doctype_name}/Report`); + cy.intercept('POST', 'api/method/frappe.client.set_value').as('value-update'); + cy.visit(`/app/List/${doctype_name}/Report`); // check status column added from docstatus cy.get('.dt-row-0 > .dt-cell--col-3').should('contain', 'Submitted'); let cell = cy.get('.dt-row-0 > .dt-cell--col-4'); // select the cell cell.dblclick(); - cell.find('input[data-fieldname="enabled"]').check({force: true}); + cell.find('input[data-fieldname="enabled"]').check({ force: true }); cy.get('.dt-row-0 > .dt-cell--col-5').click(); cy.wait('@value-update'); cy.get('@doc').then(doc => { diff --git a/cypress/integration/table_multiselect.js b/cypress/integration/table_multiselect.js index e75baf05f1..faa72d63a5 100644 --- a/cypress/integration/table_multiselect.js +++ b/cypress/integration/table_multiselect.js @@ -8,20 +8,19 @@ context('Table MultiSelect', () => { it('select value from multiselect dropdown', () => { cy.new_form('Assignment Rule'); cy.fill_field('__newname', name); - cy.fill_field('document_type', 'ToDo'); + cy.fill_field('document_type', 'Blog Post'); cy.fill_field('assign_condition', 'status=="Open"', 'Code'); cy.get('input[data-fieldname="users"]').focus().as('input'); cy.get('input[data-fieldname="users"] + ul').should('be.visible'); cy.get('@input').type('test{enter}', { delay: 100 }); - cy.get('.frappe-control[data-fieldname="users"] .form-control .tb-selected-value') - .first().as('selected-value'); + cy.get('.frappe-control[data-fieldname="users"] .form-control .tb-selected-value .btn-link-to-form') + .as('selected-value'); cy.get('@selected-value').should('contain', 'test@erpnext.com'); - cy.server(); - cy.route('POST', '/api/method/frappe.desk.form.save.savedocs').as('save_form'); + cy.intercept('POST', '/api/method/frappe.desk.form.save.savedocs').as('save_form'); // trigger save cy.get('.primary-action').click(); - cy.wait('@save_form').its('status').should('eq', 200); + cy.wait('@save_form').its('response.statusCode').should('eq', 200); cy.get('@selected-value').should('contain', 'test@erpnext.com'); }); @@ -46,6 +45,6 @@ context('Table MultiSelect', () => { cy.get(`.list-subject:contains("table multiselect")`).last().find('a').click(); cy.get('.frappe-control[data-fieldname="users"] .form-control .tb-selected-value').as('existing_value'); cy.get('@existing_value').find('.btn-link-to-form').click(); - cy.location('hash').should('contain', 'Form/User/test@erpnext.com'); + cy.location('pathname').should('contain', '/user/test@erpnext.com'); }); }); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 7816d5526f..1964b96d70 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -160,7 +160,7 @@ Cypress.Commands.add('remove_doc', (doctype, name) => { Cypress.Commands.add('create_records', doc => { return cy - .call('frappe.tests.ui_test_helpers.create_if_not_exists', { doc }) + .call('frappe.tests.ui_test_helpers.create_if_not_exists', {doc}) .then(r => r.message); }); @@ -186,7 +186,7 @@ Cypress.Commands.add('fill_field', (fieldname, value, fieldtype = 'Data') => { if (fieldtype === 'Select') { cy.get('@input').select(value); } else { - cy.get('@input').type(value, { waitForAnimations: false, force: true }); + cy.get('@input').type(value, {waitForAnimations: false, force: true}); } return cy.get('@input'); }); @@ -204,19 +204,54 @@ Cypress.Commands.add('get_field', (fieldname, fieldtype = 'Data') => { return cy.get(selector); }); +Cypress.Commands.add('fill_table_field', (tablefieldname, row_idx, fieldname, value, fieldtype = 'Data') => { + cy.get_table_field(tablefieldname, row_idx, fieldname, fieldtype).as('input'); + + if (['Date', 'Time', 'Datetime'].includes(fieldtype)) { + cy.get('@input').click().wait(200); + cy.get('.datepickers-container .datepicker.active').should('exist'); + } + if (fieldtype === 'Time') { + cy.get('@input').clear().wait(200); + } + + if (fieldtype === 'Select') { + cy.get('@input').select(value); + } else { + cy.get('@input').type(value, {waitForAnimations: false, force: true}); + } + return cy.get('@input'); +}); + +Cypress.Commands.add('get_table_field', (tablefieldname, row_idx, fieldname, fieldtype = 'Data') => { + let selector = `.frappe-control[data-fieldname="${tablefieldname}"]`; + selector += ` [data-idx="${row_idx}"]`; + selector += ` .form-in-grid`; + + if (fieldtype === 'Text Editor') { + selector += ` [data-fieldname="${fieldname}"] .ql-editor[contenteditable=true]`; + } else if (fieldtype === 'Code') { + selector += ` [data-fieldname="${fieldname}"] .ace_text-input`; + } else { + selector += ` .form-control[data-fieldname="${fieldname}"]`; + } + + return cy.get(selector); +}); + Cypress.Commands.add('awesomebar', text => { - cy.get('#navbar-search').type(`${text}{downarrow}{enter}`, { delay: 100 }); + cy.get('#navbar-search').type(`${text}{downarrow}{enter}`, {delay: 100}); }); Cypress.Commands.add('new_form', doctype => { - let route = `Form/${doctype}/New ${doctype} 1`; - cy.visit(`/desk#${route}`); - cy.get('body').should('have.attr', 'data-route', route); + let dt_in_route = doctype.toLowerCase().replace(/ /g, '-'); + cy.visit(`/app/${dt_in_route}/new`); + cy.get('body').should('have.attr', 'data-route', `Form/${doctype}/new-${dt_in_route}-1`); cy.get('body').should('have.attr', 'data-ajax-state', 'complete'); }); Cypress.Commands.add('go_to_list', doctype => { - cy.visit(`/desk#List/${doctype}/List`); + cy.visit(`/app/list/${doctype}/list`); }); Cypress.Commands.add('clear_cache', () => { @@ -240,9 +275,8 @@ Cypress.Commands.add('get_open_dialog', () => { }); Cypress.Commands.add('hide_dialog', () => { - cy.get_open_dialog() - .find('.btn-modal-close') - .click(); + cy.wait(300); + cy.get_open_dialog().find('.btn-modal-close').click(); cy.get('.modal:visible').should('not.exist'); }); @@ -272,4 +306,21 @@ Cypress.Commands.add('insert_doc', (doctype, args, ignore_duplicate) => { return res.body.data; }); }); -}); \ No newline at end of file +}); + +Cypress.Commands.add('add_filter', () => { + cy.get('.filter-section .filter-button').click(); + cy.wait(300); + cy.get('.filter-popover').should('exist'); +}); + +Cypress.Commands.add('clear_filters', () => { + cy.get('.filter-section .filter-button').click(); + cy.wait(300); + cy.get('.filter-popover').should('exist'); + cy.get('.filter-popover').find('.clear-filters').click(); + cy.get('.filter-section .filter-button').click(); + cy.window().its('cur_list').then(cur_list => { + cur_list && cur_list.filter_area && cur_list.filter_area.clear(); + }); +}); diff --git a/cypress/support/index.js b/cypress/support/index.js index 8035ef117b..1bee72d2ca 100644 --- a/cypress/support/index.js +++ b/cypress/support/index.js @@ -21,5 +21,5 @@ import './commands'; // require('./commands') Cypress.Cookies.defaults({ - whitelist: 'sid' + preserve: 'sid' }); \ No newline at end of file diff --git a/frappe/__init__.py b/frappe/__init__.py index 554f1f9747..160ed93c50 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -1,8 +1,14 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # MIT License. See license.txt """ -globals attached to frappe module -+ some utility functions that should probably be moved +Frappe - Low Code Open Source Framework in Python and JS + +Frappe, pronounced fra-pay, is a full stack, batteries-included, web +framework written in Python and Javascript with MariaDB as the database. +It is the framework which powers ERPNext. It is pretty generic and can +be used to build database driven apps. + +Read the documentation: https://frappeframework.com/docs """ from __future__ import unicode_literals, print_function @@ -11,7 +17,6 @@ from werkzeug.local import Local, release_local import os, sys, importlib, inspect, json from past.builtins import cmp import click -from faker import Faker # public from .exceptions import * @@ -27,6 +32,7 @@ __version__ = '13.0.0-dev' __title__ = "Frappe Framework" local = Local() +controllers = {} class _dict(dict): """dict like object that exposes keys as attributes""" @@ -148,6 +154,7 @@ def init(site, sites_path=None, new_site=False): "new_site": new_site }) local.rollback_observers = [] + local.before_commit = [] local.test_objects = {} local.site = site @@ -188,17 +195,20 @@ def init(site, sites_path=None, new_site=False): local.initialised = True -def connect(site=None, db_name=None): +def connect(site=None, db_name=None, set_admin_as_user=True): """Connect to site database instance. :param site: If site is given, calls `frappe.init`. - :param db_name: Optional. Will use from `site_config.json`.""" + :param db_name: Optional. Will use from `site_config.json`. + :param set_admin_as_user: Set Administrator as current user. + """ from frappe.database import get_db if site: init(site) local.db = get_db(user=db_name or local.conf.db_name) - set_user("Administrator") + if set_admin_as_user: + set_user("Administrator") def connect_replica(): from frappe.database import get_db @@ -312,7 +322,7 @@ def log(msg): debug_log.append(as_unicode(msg)) -def msgprint(msg, title=None, raise_exception=0, as_table=False, indicator=None, alert=False, primary_action=None, is_minimizable=None, wide=None): +def msgprint(msg, title=None, raise_exception=0, as_table=False, as_list=False, indicator=None, alert=False, primary_action=None, is_minimizable=None, wide=None): """Print a message to the user (via HTTP response). Messages are sent in the `__server_messages` property in the response JSON and shown in a pop-up / modal. @@ -321,11 +331,12 @@ def msgprint(msg, title=None, raise_exception=0, as_table=False, indicator=None, :param title: [optional] Message title. :param raise_exception: [optional] Raise given exception and show message. :param as_table: [optional] If `msg` is a list of lists, render as HTML table. + :param as_list: [optional] If `msg` is a list, render as un-ordered list. :param primary_action: [optional] Bind a primary server/client side action. :param is_minimizable: [optional] Allow users to minimize the modal :param wide: [optional] Show wide modal """ - from frappe.utils import encode + from frappe.utils import strip_html_tags msg = safe_decode(msg) out = _dict(message=msg) @@ -346,19 +357,13 @@ def msgprint(msg, title=None, raise_exception=0, as_table=False, indicator=None, return if as_table and type(msg) in (list, tuple): + out.as_table = 1 - table_rows = '' - for row in msg: - table_row_data = '' - for data in row: - table_row_data += '{}'.format(data) - table_rows += '{}'.format(table_row_data) - - out.message = '''{}
'''.format(table_rows) + if as_list and type(msg) in (list, tuple) and len(msg) > 1: + out.as_list = 1 if flags.print_messages and out.message: - print(f"Message: {repr(out.message).encode('utf-8')}") + print(f"Message: {strip_html_tags(out.message)}") if title: out.title = title @@ -405,12 +410,12 @@ def clear_last_message(): if len(local.message_log) > 0: local.message_log = local.message_log[:-1] -def throw(msg, exc=ValidationError, title=None, is_minimizable=None, wide=None): +def throw(msg, exc=ValidationError, title=None, is_minimizable=None, wide=None, as_list=False): """Throw execption and show message (`msgprint`). :param msg: Message. :param exc: Exception class. Default `frappe.ValidationError`""" - msgprint(msg, raise_exception=exc, title=title, indicator='red', is_minimizable=is_minimizable, wide=wide) + msgprint(msg, raise_exception=exc, title=title, indicator='red', is_minimizable=is_minimizable, wide=wide, as_list=as_list) def emit_js(js, user=False, **kwargs): if user == False: @@ -465,11 +470,11 @@ def get_request_header(key, default=None): def sendmail(recipients=[], sender="", subject="No Subject", message="No Message", as_markdown=False, delayed=True, reference_doctype=None, reference_name=None, - unsubscribe_method=None, unsubscribe_params=None, unsubscribe_message=None, - attachments=None, content=None, doctype=None, name=None, reply_to=None, + unsubscribe_method=None, unsubscribe_params=None, unsubscribe_message=None, add_unsubscribe_link=1, + attachments=None, content=None, doctype=None, name=None, reply_to=None, queue_separately=False, cc=[], bcc=[], message_id=None, in_reply_to=None, send_after=None, expose_recipients=None, send_priority=1, communication=None, retry=1, now=None, read_receipt=None, is_notification=False, - inline_images=None, template=None, args=None, header=None, print_letterhead=False): + inline_images=None, template=None, args=None, header=None, print_letterhead=False, with_container=False): """Send email using user's default **Email Account** or global default **Email Account**. @@ -495,6 +500,7 @@ def sendmail(recipients=[], sender="", subject="No Subject", message="No Message :param template: Name of html template from templates/emails folder :param args: Arguments for rendering the template :param header: Append header in email + :param with_container: Wraps email inside a styled container """ text_content = None if template: @@ -512,12 +518,12 @@ def sendmail(recipients=[], sender="", subject="No Subject", message="No Message from frappe.email import queue queue.send(recipients=recipients, sender=sender, subject=subject, message=message, text_content=text_content, - reference_doctype = doctype or reference_doctype, reference_name = name or reference_name, + reference_doctype = doctype or reference_doctype, reference_name = name or reference_name, add_unsubscribe_link=add_unsubscribe_link, unsubscribe_method=unsubscribe_method, unsubscribe_params=unsubscribe_params, unsubscribe_message=unsubscribe_message, attachments=attachments, reply_to=reply_to, cc=cc, bcc=bcc, message_id=message_id, in_reply_to=in_reply_to, - send_after=send_after, expose_recipients=expose_recipients, send_priority=send_priority, + send_after=send_after, expose_recipients=expose_recipients, send_priority=send_priority, queue_separately=queue_separately, communication=communication, now=now, read_receipt=read_receipt, is_notification=is_notification, - inline_images=inline_images, header=header, print_letterhead=print_letterhead) + inline_images=inline_images, header=header, print_letterhead=print_letterhead, with_container=with_container) whitelisted = [] guest_methods = [] @@ -632,6 +638,21 @@ def clear_cache(user=None, doctype=None): local.role_permissions = {} +def only_has_select_perm(doctype, user=None, ignore_permissions=False): + if ignore_permissions: + return False + + if not user: + user = local.session.user + + import frappe.permissions + permissions = frappe.permissions.get_role_permissions(doctype, user=user) + + if permissions.get('select') and not permissions.get('read'): + return True + else: + return False + def has_permission(doctype=None, ptype="read", doc=None, user=None, verbose=False, throw=False): """Raises `frappe.PermissionError` if not permitted. @@ -801,11 +822,17 @@ def get_doc(*args, **kwargs): return doc -def get_last_doc(doctype): +def get_last_doc(doctype, filters=None, order_by="creation desc"): """Get last created document of this type.""" - d = get_all(doctype, ["name"], order_by="creation desc", limit_page_length=1) + d = get_all( + doctype, + filters=filters, + limit_page_length=1, + order_by=order_by, + pluck="name" + ) if d: - return get_doc(doctype, d[0].name) + return get_doc(doctype, d[0]) else: raise DoesNotExistError @@ -944,7 +971,7 @@ def get_installed_apps(sort=False, frappe_last=False): connect() if not local.all_apps: - local.all_apps = get_all_apps(True) + local.all_apps = cache().get_value('all_apps', get_all_apps) installed = json.loads(db.get_global("installed_apps") or "[]") @@ -1159,6 +1186,7 @@ def make_property_setter(args, ignore_validate=False, validate_fields_for_doctyp 'doctype_or_field': args.doctype_or_field, 'doc_type': doctype, 'field_name': args.fieldname, + 'row_name': args.row_name, 'property': args.property, 'value': args.value, 'property_type': args.property_type or "Data", @@ -1609,7 +1637,7 @@ def log_error(message=None, title=_("Error")): method=title)).insert(ignore_permissions=True) def get_desk_link(doctype, name): - html = '{doctype_local} {name}' + html = '{doctype_local} {name}' return html.format( doctype=doctype, name=name, @@ -1721,6 +1749,8 @@ def parse_json(val): return parse_json(val) def mock(type, size=1, locale='en'): + from faker import Faker + results = [] faker = Faker(locale) if not type in dir(faker): diff --git a/frappe/app.py b/frappe/app.py index c4d6a0235a..607479ad52 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -7,8 +7,8 @@ import os from six import iteritems import logging -from werkzeug.wrappers import Request from werkzeug.local import LocalManager +from werkzeug.wrappers import Request, Response from werkzeug.exceptions import HTTPException, NotFound from werkzeug.middleware.profiler import ProfilerMiddleware from werkzeug.middleware.shared_data import SharedDataMiddleware @@ -57,19 +57,22 @@ def application(request): frappe.monitor.start() frappe.rate_limiter.apply() - if frappe.local.form_dict.cmd: + if request.method == "OPTIONS": + response = Response() + + elif frappe.form_dict.cmd: response = frappe.handler.handle() - elif frappe.request.path.startswith("/api/"): + elif request.path.startswith("/api/"): response = frappe.api.handle() - elif frappe.request.path.startswith('/backups'): + elif request.path.startswith('/backups'): response = frappe.utils.response.download_backup(request.path) - elif frappe.request.path.startswith('/private/files/'): + elif request.path.startswith('/private/files/'): response = frappe.utils.response.download_private_file(request.path) - elif frappe.local.request.method in ('GET', 'HEAD', 'POST'): + elif request.method in ('GET', 'HEAD', 'POST'): response = frappe.website.render.render() else: @@ -88,13 +91,9 @@ def application(request): rollback = after_request(rollback) finally: - if frappe.local.request.method in ("POST", "PUT") and frappe.db and rollback: + if request.method in ("POST", "PUT") and frappe.db and rollback: frappe.db.rollback() - # set cookies - if response and hasattr(frappe.local, 'cookie_manager'): - frappe.local.cookie_manager.flush_cookies(response=response) - frappe.rate_limiter.update() frappe.monitor.stop(response) frappe.recorder.dump() @@ -110,9 +109,7 @@ def application(request): "http_status_code": getattr(response, "status_code", "NOTFOUND") }) - if response and hasattr(frappe.local, 'rate_limiter'): - response.headers.extend(frappe.local.rate_limiter.headers()) - + process_response(response) frappe.destroy() return response @@ -131,10 +128,51 @@ def init_request(request): if frappe.local.conf.get('maintenance_mode'): frappe.connect() raise frappe.SessionStopped('Session Stopped') + else: + frappe.connect(set_admin_as_user=False) make_form_dict(request) - frappe.local.http_request = frappe.auth.HTTPRequest() + if request.method != "OPTIONS": + frappe.local.http_request = frappe.auth.HTTPRequest() + +def process_response(response): + if not response: + return + + # set cookies + if hasattr(frappe.local, 'cookie_manager'): + frappe.local.cookie_manager.flush_cookies(response=response) + + # rate limiter headers + if hasattr(frappe.local, 'rate_limiter'): + response.headers.extend(frappe.local.rate_limiter.headers()) + + # CORS headers + if hasattr(frappe.local, 'conf') and frappe.conf.allow_cors: + set_cors_headers(response) + +def set_cors_headers(response): + origin = frappe.request.headers.get('Origin') + allow_cors = frappe.conf.allow_cors + if not (origin and allow_cors): + return + + if allow_cors != "*": + if not isinstance(allow_cors, list): + allow_cors = [allow_cors] + + if origin not in allow_cors: + return + + response.headers.extend({ + 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Credentials': 'true', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': ('Authorization,DNT,X-Mx-ReqToken,' + 'Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,' + 'Cache-Control,Content-Type') + }) def make_form_dict(request): import json @@ -145,6 +183,9 @@ def make_form_dict(request): else: args = request.form or request.args + if not isinstance(args, dict): + frappe.throw("Invalid request arguments") + try: frappe.local.form_dict = frappe._dict({ k:v[0] if isinstance(v, (list, tuple)) else v \ for k, v in iteritems(args) }) @@ -160,6 +201,10 @@ def handle_exception(e): http_status_code = getattr(e, "http_status_code", 500) return_as_message = False + if frappe.conf.get('developer_mode'): + # don't fail silently + print(frappe.get_traceback()) + if frappe.get_request_header('Accept') and (frappe.local.is_ajax or 'application/json' in frappe.get_request_header('Accept')): # handle ajax responses first # if the request is ajax, send back the trace or error message diff --git a/frappe/auth.py b/frappe/auth.py index 6d51629c58..946a8c52d5 100644 --- a/frappe/auth.py +++ b/frappe/auth.py @@ -173,7 +173,7 @@ class LoginManager: frappe.local.cookie_manager.set_cookie("system_user", "yes") if not resume: frappe.local.response['message'] = 'Logged In' - frappe.local.response["home_page"] = "/desk" + frappe.local.response["home_page"] = "/app" if not resume: frappe.response["full_name"] = self.full_name @@ -207,23 +207,44 @@ class LoginManager: if frappe.session.user != "Guest": clear_sessions(frappe.session.user, keep_current=True) - def authenticate(self, user=None, pwd=None): + def authenticate(self, user: str = None, pwd: str = None): + from frappe.core.doctype.user.user import User + if not (user and pwd): user, pwd = frappe.form_dict.get('usr'), frappe.form_dict.get('pwd') if not (user and pwd): self.fail(_('Incomplete login details'), user=user) - if cint(frappe.db.get_value("System Settings", "System Settings", "allow_login_using_mobile_number")): - user = frappe.db.get_value("User", filters={"mobile_no": user}, fieldname="name") or user + # Ignore password check if tmp_id is set, 2FA takes care of authentication. + validate_password = not bool(frappe.form_dict.get('tmp_id')) + user = User.find_by_credentials(user, pwd, validate_password=validate_password) - if cint(frappe.db.get_value("System Settings", "System Settings", "allow_login_using_user_name")): - user = frappe.db.get_value("User", filters={"username": user}, fieldname="name") or user + if not user: + self.fail('Invalid login credentials') - self.check_if_enabled(user) - if not frappe.form_dict.get('tmp_id'): - self.user = self.check_password(user, pwd) + sys_settings = frappe.get_doc("System Settings") + track_login_attempts = (sys_settings.allow_consecutive_login_attempts >0) + + tracker_kwargs = {} + if track_login_attempts: + tracker_kwargs['lock_interval'] = sys_settings.allow_login_after_fail + tracker_kwargs['max_consecutive_login_attempts'] = sys_settings.allow_consecutive_login_attempts + + tracker = LoginAttemptTracker(user.name, **tracker_kwargs) + + if track_login_attempts and not tracker.is_user_allowed(): + frappe.throw(_("Your account has been locked and will resume after {0} seconds") + .format(sys_settings.allow_login_after_fail), frappe.SecurityException) + + if not user.is_authenticated: + tracker.add_failure_attempt() + self.fail('Invalid login credentials', user=user.name) + elif not (user.name == 'Administrator' or user.enabled): + tracker.add_failure_attempt() + self.fail('User disabled or missing', user=user.name) else: - self.user = user + tracker.add_success_attempt() + self.user = user.name def force_user_to_reset_password(self): if not self.user: @@ -245,23 +266,12 @@ class LoginManager: if last_pwd_reset_days > reset_pwd_after_days: return True - def check_if_enabled(self, user): - """raise exception if user not enabled""" - doc = frappe.get_doc("System Settings") - if cint(doc.allow_consecutive_login_attempts) > 0: - check_consecutive_login_attempts(user, doc) - - if user=='Administrator': return - if not cint(frappe.db.get_value('User', user, 'enabled')): - self.fail('User disabled or missing', user=user) - def check_password(self, user, pwd): """check password""" try: # returns user in correct case return check_password(user, pwd) except frappe.AuthenticationError: - self.update_invalid_login(user) self.fail('Incorrect password', user=user) def fail(self, message, user=None): @@ -272,15 +282,6 @@ class LoginManager: frappe.db.commit() raise frappe.AuthenticationError - def update_invalid_login(self, user): - last_login_tried = get_last_tried_login_data(user) - - failed_count = 0 - if last_login_tried > get_datetime(): - failed_count = get_login_failed_count(user) - - frappe.cache().hset('login_failed_count', user, failed_count + 1) - def run_trigger(self, event='on_login'): for method in frappe.get_hooks().get(event, []): frappe.call(frappe.get_attr(method), login_manager=self) @@ -383,38 +384,6 @@ def clear_cookies(): frappe.session.sid = "" frappe.local.cookie_manager.delete_cookie(["full_name", "user_id", "sid", "user_image", "system_user"]) -def get_last_tried_login_data(user, get_last_login=False): - locked_account_time = frappe.cache().hget('locked_account_time', user) - if get_last_login and locked_account_time: - return locked_account_time - - last_login_tried = frappe.cache().hget('last_login_tried', user) - if not last_login_tried or last_login_tried < get_datetime(): - last_login_tried = get_datetime() + datetime.timedelta(seconds=60) - - frappe.cache().hset('last_login_tried', user, last_login_tried) - - return last_login_tried - -def get_login_failed_count(user): - return cint(frappe.cache().hget('login_failed_count', user)) or 0 - -def check_consecutive_login_attempts(user, doc): - login_failed_count = get_login_failed_count(user) - last_login_tried = (get_last_tried_login_data(user, True) - + datetime.timedelta(seconds=doc.allow_login_after_fail)) - - if login_failed_count >= cint(doc.allow_consecutive_login_attempts): - locked_account_time = frappe.cache().hget('locked_account_time', user) - if not locked_account_time: - frappe.cache().hset('locked_account_time', user, get_datetime()) - - if last_login_tried > get_datetime(): - frappe.throw(_("Your account has been locked and will resume after {0} seconds") - .format(doc.allow_login_after_fail), frappe.SecurityException) - else: - delete_login_failed_cache(user) - def validate_ip_address(user): """check if IP Address is valid""" user = frappe.get_cached_doc("User", user) if not frappe.flags.in_test else frappe.get_doc("User", user) @@ -436,3 +405,87 @@ def validate_ip_address(user): return frappe.throw(_("Access not allowed from this IP Address"), frappe.AuthenticationError) + + +class LoginAttemptTracker(object): + """Track login attemts of a user. + + Lock the account for s number of seconds if there have been n consecutive unsuccessful attempts to log in. + """ + def __init__(self, user_name: str, max_consecutive_login_attempts: int=3, lock_interval:int = 5*60): + """ Initialize the tracker. + + :param user_name: Name of the loggedin user + :param max_consecutive_login_attempts: Maximum allowed consecutive failed login attempts + :param lock_interval: Locking interval incase of maximum failed attempts + """ + self.user_name = user_name + self.lock_interval = datetime.timedelta(seconds=lock_interval) + self.max_failed_logins = max_consecutive_login_attempts + + @property + def login_failed_count(self): + return frappe.cache().hget('login_failed_count', self.user_name) + + @login_failed_count.setter + def login_failed_count(self, count): + frappe.cache().hset('login_failed_count', self.user_name, count) + + @login_failed_count.deleter + def login_failed_count(self): + frappe.cache().hdel('login_failed_count', self.user_name) + + @property + def login_failed_time(self): + """First failed login attempt time within lock interval. + + For every user we track only First failed login attempt time within lock interval of time. + """ + return frappe.cache().hget('login_failed_time', self.user_name) + + @login_failed_time.setter + def login_failed_time(self, timestamp): + frappe.cache().hset('login_failed_time', self.user_name, timestamp) + + @login_failed_time.deleter + def login_failed_time(self): + frappe.cache().hdel('login_failed_time', self.user_name) + + def add_failure_attempt(self): + """ Log user failure attempts into the system. + + Increase the failure count if new failure is with in current lock interval time period, if not reset the login failure count. + """ + login_failed_time = self.login_failed_time + login_failed_count = self.login_failed_count # Consecutive login failure count + current_time = get_datetime() + + if not (login_failed_time and login_failed_count): + login_failed_time, login_failed_count = current_time, 0 + + if login_failed_time + self.lock_interval > current_time: + login_failed_count += 1 + else: + login_failed_time, login_failed_count = current_time, 1 + + self.login_failed_time = login_failed_time + self.login_failed_count = login_failed_count + + def add_success_attempt(self): + """Reset login failures. + """ + del self.login_failed_count + del self.login_failed_time + + def is_user_allowed(self) -> bool: + """Is user allowed to login + + User is not allowed to login if login failures are greater than threshold within in lock interval from first login failure. + """ + login_failed_time = self.login_failed_time + login_failed_count = self.login_failed_count or 0 + current_time = get_datetime() + + if login_failed_time and login_failed_time + self.lock_interval > current_time and login_failed_count > self.max_failed_logins: + return False + return True diff --git a/frappe/automation/desk_page/tools/tools.json b/frappe/automation/desk_page/tools/tools.json deleted file mode 100644 index 3fbaf62d02..0000000000 --- a/frappe/automation/desk_page/tools/tools.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "cards": [ - { - "hidden": 0, - "label": "Tools", - "links": "[\n {\n \"description\": \"Documents assigned to you and by you.\",\n \"label\": \"To Do\",\n \"name\": \"ToDo\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Event and other calendars.\",\n \"label\": \"Calendar\",\n \"link\": \"List/Event/Calendar\",\n \"name\": \"Event\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Private and public Notes.\",\n \"label\": \"Note\",\n \"name\": \"Note\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Files\",\n \"name\": \"File\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Activity log of all users.\",\n \"label\": \"Activity\",\n \"name\": \"activity\",\n \"type\": \"page\"\n }\n]" - }, - { - "hidden": 0, - "label": "Email", - "links": "[\n {\n \"description\": \"Newsletters to contacts, leads.\",\n \"label\": \"Newsletter\",\n \"name\": \"Newsletter\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Email Group List\",\n \"label\": \"Email Group\",\n \"name\": \"Email Group\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Automation", - "links": "[\n {\n \"type\": \"doctype\",\n \"name\": \"Assignment Rule\",\n \"description\": \"Set up rules for user assignments.\",\n \"label\": \"Assignment Rule\"\n },\n {\n \"type\": \"doctype\",\n \"name\": \"Milestone\",\n \"description\": \"Tracks milestones on the lifecycle of a document if it undergoes multiple stages.\",\n \"label\": \"Milestone\"\n },\n {\n \"type\": \"doctype\",\n \"name\": \"Auto Repeat\",\n \"description\": \"Automatically generates recurring documents.\",\n \"label\": \"Auto Repeat\"\n }\n]" - }, - { - "hidden": 0, - "label": "Event Streaming", - "links": "[\n {\n \"type\": \"doctype\",\n \"name\": \"Event Producer\",\n \"description\": \"The site you want to subscribe to for consuming events.\",\n \"label\": \"Event Producer\"\n },\n {\n \"type\": \"doctype\",\n \"name\": \"Event Consumer\",\n \"description\": \"The site which is consuming your events.\",\n \"label\": \"Event Consumer\"\n },\n {\n \"type\": \"doctype\",\n \"name\": \"Event Update Log\",\n \"description\": \"Maintains a Log of all inserts, updates and deletions on Event Producer site for documents that have consumers.\",\n \"label\": \"Event Update Log\"\n },\n {\n \"type\": \"doctype\",\n \"name\": \"Event Sync Log\",\n \"description\": \"Maintains a log of every event consumed along with the status of the sync and a Resync button in case sync fails.\",\n \"label\": \"Event Sync Log\"\n },\n {\n \"type\": \"doctype\",\n \"name\": \"Document Type Mapping\",\n \"description\": \"The mapping configuration between two doctypes.\",\n \"label\": \"Document Type Mapping\"\n }\n]" - } - ], - "category": "Administration", - "charts": [], - "creation": "2020-03-02 14:53:24.980279", - "developer_mode_only": 0, - "disable_user_customization": 0, - "docstatus": 0, - "doctype": "Desk Page", - "extends_another_page": 0, - "hide_custom": 0, - "idx": 0, - "is_standard": 1, - "label": "Tools", - "modified": "2020-07-21 19:32:18.480700", - "modified_by": "Administrator", - "module": "Automation", - "name": "Tools", - "owner": "Administrator", - "pin_to_bottom": 0, - "pin_to_top": 0, - "shortcuts": [ - { - "label": "ToDo", - "link_to": "ToDo", - "type": "DocType" - }, - { - "label": "Note", - "link_to": "Note", - "type": "DocType" - }, - { - "label": "File", - "link_to": "File", - "type": "DocType" - }, - { - "label": "Assignment Rule", - "link_to": "Assignment Rule", - "type": "DocType" - }, - { - "label": "Auto Repeat", - "link_to": "Auto Repeat", - "type": "DocType" - } - ] -} \ No newline at end of file diff --git a/frappe/automation/doctype/assignment_rule/assignment_rule.js b/frappe/automation/doctype/assignment_rule/assignment_rule.js index 3e86f6cefa..97bed4f8f3 100644 --- a/frappe/automation/doctype/assignment_rule/assignment_rule.js +++ b/frappe/automation/doctype/assignment_rule/assignment_rule.js @@ -3,14 +3,80 @@ frappe.ui.form.on('Assignment Rule', { refresh: function(frm) { + frm.trigger('setup_assignment_days_buttons'); + frm.trigger('set_options'); // refresh description frm.events.rule(frm); }, + + setup: function(frm) { + frm.set_query("document_type", () => { + return { + filters: { + name: ["!=", "ToDo"] + } + }; + }); + }, + + document_type: function(frm) { + frm.trigger('set_options'); + }, + + setup_assignment_days_buttons: function(frm) { + const labels = ['Weekends', 'Weekdays', 'All Days']; + let get_days = (label) => { + const weekdays = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']; + const weekends = ['Saturday', 'Sunday']; + return { + 'All Days': weekdays.concat(weekends), + 'Weekdays': weekdays, + 'Weekends': weekends, + }[label]; + }; + + let set_days = (e) => { + frm.clear_table('assignment_days'); + const label = $(e.currentTarget).text(); + get_days(label).forEach((day) => + frm.add_child('assignment_days', { day: day }) + ); + frm.refresh_field('assignment_days'); + }; + + labels.forEach(label => + frm.fields_dict['assignment_days'].grid.add_custom_button( + label, + set_days, + 'top' + ) + ); + }, + rule: function(frm) { - if (frm.doc.rule === 'Round Robin') { - frm.get_field('rule').set_description(__('Assign one by one, in sequence')); - } else { - frm.get_field('rule').set_description(__('Assign to the one who has the least assignments')); + const description_map = { + 'Round Robin': __('Assign one by one, in sequence'), + 'Load Balancing': __('Assign to the one who has the least assignments'), + 'Based on Field': __('Assign to the user set in this field'), + }; + frm.get_field('rule').set_description(description_map[frm.doc.rule]); + }, + + set_options(frm) { + const doctype = frm.doc.document_type; + frm.set_fields_as_options( + 'field', + doctype, + (df) => ['Dynamic Link', 'Data'].includes(df.fieldtype) + || (df.fieldtype == 'Link' && df.options == 'User'), + [{ label: 'Owner', value: 'owner' }] + ); + if (doctype) { + frm.set_fields_as_options( + 'due_date_based_on', + doctype, + (df) => ['Date', 'Datetime'].includes(df.fieldtype) + ).then(options => frm.set_df_property('due_date_based_on', 'hidden', !options.length)); } - } + }, }); diff --git a/frappe/automation/doctype/assignment_rule/assignment_rule.json b/frappe/automation/doctype/assignment_rule/assignment_rule.json index eb79b9e3a8..0a57e06da6 100644 --- a/frappe/automation/doctype/assignment_rule/assignment_rule.json +++ b/frappe/automation/doctype/assignment_rule/assignment_rule.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_rename": 1, "autoname": "Prompt", "creation": "2019-02-28 17:12:18.815830", @@ -8,6 +9,7 @@ "engine": "InnoDB", "field_order": [ "document_type", + "due_date_based_on", "priority", "disabled", "column_break_4", @@ -22,6 +24,7 @@ "assignment_days", "assign_to_users_section", "rule", + "field", "users", "last_user" ], @@ -91,15 +94,16 @@ "fieldtype": "Select", "in_list_view": 1, "label": "Rule", - "options": "Round Robin\nLoad Balancing", + "options": "Round Robin\nLoad Balancing\nBased on Field", "reqd": 1 }, { + "depends_on": "eval: doc.rule !== 'Based on Field'", "fieldname": "users", "fieldtype": "Table MultiSelect", "label": "Users", - "options": "Assignment Rule User", - "reqd": 1 + "mandatory_depends_on": "eval: doc.rule !== 'Based on Field'", + "options": "Assignment Rule User" }, { "fieldname": "last_user", @@ -129,9 +133,25 @@ "label": "Assignment Days", "options": "Assignment Rule Day", "reqd": 1 + }, + { + "depends_on": "document_type", + "description": "Value from this field will be set as the due date in the ToDo", + "fieldname": "due_date_based_on", + "fieldtype": "Select", + "label": "Due Date Based On" + }, + { + "depends_on": "eval: doc.rule == 'Based on Field'", + "fieldname": "field", + "fieldtype": "Select", + "label": "Field", + "mandatory_depends_on": "eval: doc.rule == 'Based on Field'" } ], - "modified": "2019-09-25 14:52:12.214514", + "index_web_pages_for_search": 1, + "links": [], + "modified": "2020-10-20 14:47:20.662954", "modified_by": "Administrator", "module": "Automation", "name": "Assignment Rule", diff --git a/frappe/automation/doctype/assignment_rule/assignment_rule.py b/frappe/automation/doctype/assignment_rule/assignment_rule.py index c4bd49b870..c673d5ceeb 100644 --- a/frappe/automation/doctype/assignment_rule/assignment_rule.py +++ b/frappe/automation/doctype/assignment_rule/assignment_rule.py @@ -18,15 +18,17 @@ class AssignmentRule(Document): if not len(set(assignment_days)) == len(assignment_days): repeated_days = get_repeated(assignment_days) frappe.throw(_("Assignment Day {0} has been repeated.").format(frappe.bold(repeated_days))) + if self.document_type == 'ToDo': + frappe.throw(_('Assignment Rule is not allowed on {0} document type').format(frappe.bold("ToDo"))) - def on_update(self): # pylint: disable=no-self-use - frappe.cache_manager.clear_doctype_map('Assignment Rule', self.document_type) + def on_update(self): + clear_assignment_rule_cache(self) - def after_rename(self, old, new, merge): # pylint: disable=no-self-use - frappe.cache_manager.clear_doctype_map('Assignment Rule', self.document_type) + def after_rename(self, old, new, merge): + clear_assignment_rule_cache(self) - def on_trash(self): # pylint: disable=no-self-use - frappe.cache_manager.clear_doctype_map('Assignment Rule', self.document_type) + def on_trash(self): + clear_assignment_rule_cache(self) def apply_unassign(self, doc, assignments): if (self.unassign_condition and @@ -38,26 +40,30 @@ class AssignmentRule(Document): def apply_assign(self, doc): if self.safe_eval('assign_condition', doc): - self.do_assignment(doc) - return True + return self.do_assignment(doc) def do_assignment(self, doc): # clear existing assignment, to reassign assign_to.clear(doc.get('doctype'), doc.get('name')) - user = self.get_user() + user = self.get_user(doc) - assign_to.add(dict( - assign_to = [user], - doctype = doc.get('doctype'), - name = doc.get('name'), - description = frappe.render_template(self.description, doc), - assignment_rule = self.name, - notify = True - )) + if user: + assign_to.add(dict( + assign_to = [user], + doctype = doc.get('doctype'), + name = doc.get('name'), + description = frappe.render_template(self.description, doc), + assignment_rule = self.name, + notify = True, + date = doc.get(self.due_date_based_on) if self.due_date_based_on else None + )) - # set for reference in round robin - self.db_set('last_user', user) + # set for reference in round robin + self.db_set('last_user', user) + return True + + return False def clear_assignment(self, doc): '''Clear assignments''' @@ -69,7 +75,7 @@ class AssignmentRule(Document): if self.safe_eval('close_condition', doc): return assign_to.close_all_assignments(doc.get('doctype'), doc.get('name')) - def get_user(self): + def get_user(self, doc): ''' Get the next user for assignment ''' @@ -77,6 +83,8 @@ class AssignmentRule(Document): return self.get_user_round_robin() elif self.rule == 'Load Balancing': return self.get_user_load_balancing() + elif self.rule == 'Based on Field': + return self.get_user_based_on_field(doc) def get_user_round_robin(self): ''' @@ -113,6 +121,11 @@ class AssignmentRule(Document): # pick the first user return sorted_counts[0].get('user') + def get_user_based_on_field(self, doc): + val = doc.get(self.field) + if frappe.db.exists('User', val): + return val + def safe_eval(self, fieldname, doc): try: if self.get(fieldname): @@ -188,7 +201,7 @@ def apply(doc, method=None, doctype=None, name=None): # multiple auto assigns for d in assignment_rules: - assignment_rule_docs.append(frappe.get_doc('Assignment Rule', d.get('name'))) + assignment_rule_docs.append(frappe.get_cached_doc('Assignment Rule', d.get('name'))) if not assignment_rule_docs: return @@ -237,6 +250,40 @@ def apply(doc, method=None, doctype=None, name=None): break assignment_rule.close_assignments(doc) +def update_due_date(doc, state=None): + # called from hook + if (frappe.flags.in_patch + or frappe.flags.in_install + or frappe.flags.in_migrate + or frappe.flags.in_import + or frappe.flags.in_setup_wizard): + return + assignment_rules = frappe.cache_manager.get_doctype_map('Assignment Rule', 'due_date_rules_for_' + doc.doctype, dict( + document_type = doc.doctype, + disabled = 0, + due_date_based_on = ['is', 'set'] + )) + for rule in assignment_rules: + rule_doc = frappe.get_cached_doc('Assignment Rule', rule.get('name')) + due_date_field = rule_doc.due_date_based_on + if doc.meta.has_field(due_date_field) and \ + doc.has_value_changed(due_date_field) and rule.get('name'): + assignment_todos = frappe.get_all('ToDo', { + 'assignment_rule': rule.get('name'), + 'status': 'Open', + 'reference_type': doc.doctype, + 'reference_name': doc.name + }) + for todo in assignment_todos: + todo_doc = frappe.get_doc('ToDo', todo.name) + todo_doc.date = doc.get(due_date_field) + todo_doc.flags.updater_reference = { + 'doctype': 'Assignment Rule', + 'docname': rule.get('name'), + 'label': _('via Assignment Rule') + } + todo_doc.save(ignore_permissions=True) + def get_assignment_rules(): return [d.document_type for d in frappe.db.get_all('Assignment Rule', fields=['document_type'], filters=dict(disabled = 0))] @@ -250,3 +297,7 @@ def get_repeated(values): if value not in diff: diff.append(str(value)) return " ".join(diff) + +def clear_assignment_rule_cache(rule): + frappe.cache_manager.clear_doctype_map('Assignment Rule', rule.document_type) + frappe.cache_manager.clear_doctype_map('Assignment Rule', 'due_date_rules_for_' + rule.document_type) diff --git a/frappe/automation/doctype/assignment_rule/test_assignment_rule.py b/frappe/automation/doctype/assignment_rule/test_assignment_rule.py index 7c68e63d95..cb1e0ff8f4 100644 --- a/frappe/automation/doctype/assignment_rule/test_assignment_rule.py +++ b/frappe/automation/doctype/assignment_rule/test_assignment_rule.py @@ -20,6 +20,7 @@ class TestAutoAssign(unittest.TestCase): dict(day = 'Friday'), dict(day = 'Saturday'), ] + self.days = days self.assignment_rule = get_assignment_rule([days, days]) clear_assignments() @@ -87,6 +88,30 @@ class TestAutoAssign(unittest.TestCase): for user in ('test@example.com', 'test1@example.com', 'test2@example.com'): self.assertEqual(len(frappe.get_all('ToDo', dict(owner = user, reference_type = 'Note'))), 10) + def test_based_on_field(self): + self.assignment_rule.rule = 'Based on Field' + self.assignment_rule.field = 'owner' + self.assignment_rule.save() + + frappe.set_user('test1@example.com') + note = make_note(dict(public=1)) + # check if auto assigned to doc owner, test1@example.com + self.assertEqual(frappe.db.get_value('ToDo', dict( + reference_type = 'Note', + reference_name = note.name, + status = 'Open' + ), 'owner'), 'test1@example.com') + + frappe.set_user('test2@example.com') + note = make_note(dict(public=1)) + # check if auto assigned to doc owner, test2@example.com + self.assertEqual(frappe.db.get_value('ToDo', dict( + reference_type = 'Note', + reference_name = note.name, + status = 'Open' + ), 'owner'), 'test2@example.com') + + frappe.set_user('Administrator') def test_assign_condition(self): # check condition @@ -180,6 +205,55 @@ class TestAutoAssign(unittest.TestCase): status = 'Open' ), 'owner'), ['test3@example.com']) + def test_assignment_rule_condition(self): + frappe.db.sql("DELETE FROM `tabAssignment Rule`") + + # Add expiry_date custom field + from frappe.custom.doctype.custom_field.custom_field import create_custom_field + df = dict(fieldname='expiry_date', label='Expiry Date', fieldtype='Date') + create_custom_field('Note', df) + + assignment_rule = frappe.get_doc(dict( + name = 'Assignment with Due Date', + doctype = 'Assignment Rule', + document_type = 'Note', + assign_condition = 'public == 0', + due_date_based_on = 'expiry_date', + assignment_days = self.days, + users = [ + dict(user = 'test@example.com'), + ] + )).insert() + + expiry_date = frappe.utils.add_days(frappe.utils.nowdate(), 2) + note1 = make_note({'expiry_date': expiry_date}) + note2 = make_note({'expiry_date': expiry_date}) + + note1_todo = frappe.get_all('ToDo', filters=dict( + reference_type = 'Note', + reference_name = note1.name, + status = 'Open' + ))[0] + + note1_todo_doc = frappe.get_doc('ToDo', note1_todo.name) + self.assertEqual(frappe.utils.get_date_str(note1_todo_doc.date), expiry_date) + + # due date should be updated if the reference doc's date is updated. + note1.expiry_date = frappe.utils.add_days(expiry_date, 2) + note1.save() + note1_todo_doc.reload() + self.assertEqual(frappe.utils.get_date_str(note1_todo_doc.date), note1.expiry_date) + + # saving one note's expiry should not update other note todo's due date + note2_todo = frappe.get_all('ToDo', filters=dict( + reference_type = 'Note', + reference_name = note2.name, + status = 'Open' + ), fields=['name', 'date'])[0] + self.assertNotEqual(frappe.utils.get_date_str(note2_todo.date), note1.expiry_date) + self.assertEqual(frappe.utils.get_date_str(note2_todo.date), expiry_date) + assignment_rule.delete() + def clear_assignments(): frappe.db.sql("delete from tabToDo where reference_type = 'Note'") @@ -237,4 +311,4 @@ def make_note(values=None): note.insert() - return note \ No newline at end of file + return note diff --git a/frappe/automation/doctype/assignment_rule_user/assignment_rule_user.json b/frappe/automation/doctype/assignment_rule_user/assignment_rule_user.json index f529772c8e..5a159c8267 100644 --- a/frappe/automation/doctype/assignment_rule_user/assignment_rule_user.json +++ b/frappe/automation/doctype/assignment_rule_user/assignment_rule_user.json @@ -1,76 +1,34 @@ { - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, + "actions": [], + "allow_read": 1, "creation": "2019-02-27 11:41:46.602400", - "custom": 0, - "docstatus": 0, "doctype": "DocType", - "document_type": "", "editable_grid": 1, "engine": "InnoDB", + "field_order": [ + "user" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "user", "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, "label": "User", - "length": 0, - "no_copy": 0, "options": "User", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "reqd": 1 } ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, + "index_web_pages_for_search": 1, "istable": 1, - "max_attachments": 0, - "modified": "2019-02-27 17:16:41.399261", + "links": [], + "modified": "2020-09-29 20:12:14.456785", "modified_by": "Administrator", "module": "Automation", "name": "Assignment Rule User", - "name_case": "", "owner": "Administrator", "permissions": [], "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, "sort_field": "modified", "sort_order": "DESC", - "track_changes": 1, - "track_seen": 0, - "track_views": 0 + "track_changes": 1 } \ No newline at end of file diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.js b/frappe/automation/doctype/auto_repeat/auto_repeat.js index a11de1d881..7028ac486d 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.js +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.js @@ -30,7 +30,7 @@ frappe.ui.form.on('Auto Repeat', { refresh: function(frm) { // auto repeat message if (frm.is_new()) { - let customize_form_link = `${__('Customize Form')}`; + let customize_form_link = `${__('Customize Form')}`; frm.dashboard.set_headline(__('To configure Auto Repeat, enable "Allow Auto Repeat" from {0}.', [customize_form_link])); } @@ -44,6 +44,22 @@ frappe.ui.form.on('Auto Repeat', { // auto repeat schedule frappe.auto_repeat.render_schedule(frm); + + frm.trigger('toggle_submit_on_creation'); + }, + + reference_doctype: function(frm) { + frm.trigger('toggle_submit_on_creation'); + }, + + toggle_submit_on_creation: function(frm) { + // submit on creation checkbox + if (frm.doc.reference_doctype) { + frappe.model.with_doctype(frm.doc.reference_doctype, () => { + let meta = frappe.get_meta(frm.doc.reference_doctype); + frm.toggle_display('submit_on_creation', meta.is_submittable); + }); + } }, template: function(frm) { @@ -86,15 +102,13 @@ frappe.ui.form.on('Auto Repeat', { frappe.auto_repeat.render_schedule = function(frm) { if (!frm.is_dirty() && frm.doc.status !== 'Disabled') { - frappe.call({ - method: "get_auto_repeat_schedule", - doc: frm.doc - }).done((r) => { + frm.call("get_auto_repeat_schedule").then(r => { frm.dashboard.wrapper.empty(); frm.dashboard.add_section( frappe.render_template("auto_repeat_schedule", { - schedule_details : r.message || [] - }) + schedule_details: r.message || [] + }), + __('Auto Repeat Schedule') ); frm.dashboard.show(); }); diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.json b/frappe/automation/doctype/auto_repeat/auto_repeat.json index 8ee6ca1d45..74965346fd 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.json +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_import": 1, "allow_rename": 1, "autoname": "format:AUT-AR-{#####}", @@ -12,6 +13,7 @@ "section_break_3", "reference_doctype", "reference_document", + "submit_on_creation", "column_break_5", "start_date", "end_date", @@ -21,6 +23,8 @@ "repeat_on_last_day", "column_break_12", "next_schedule_date", + "section_break_16", + "repeat_on_days", "notification", "notify_by_email", "recipients", @@ -186,9 +190,28 @@ "fieldname": "repeat_on_last_day", "fieldtype": "Check", "label": "Repeat on Last Day of the Month" + }, + { + "depends_on": "eval:doc.frequency==='Weekly';", + "fieldname": "repeat_on_days", + "fieldtype": "Table", + "label": "Repeat on Days", + "options": "Auto Repeat Day" + }, + { + "default": "0", + "fieldname": "submit_on_creation", + "fieldtype": "Check", + "label": "Submit on Creation" + }, + { + "depends_on": "eval:doc.frequency==='Weekly';", + "fieldname": "section_break_16", + "fieldtype": "Section Break" } ], - "modified": "2019-07-17 11:30:51.412317", + "links": [], + "modified": "2021-01-12 09:24:49.719611", "modified_by": "Administrator", "module": "Automation", "name": "Auto Repeat", diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.py b/frappe/automation/doctype/auto_repeat/auto_repeat.py index fcf24bf1a9..281e699640 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.py @@ -5,6 +5,7 @@ from __future__ import unicode_literals import frappe from frappe import _ +from datetime import timedelta from frappe.desk.form import assign_to from frappe.utils.jinja import validate_template from dateutil.relativedelta import relativedelta @@ -13,16 +14,21 @@ from frappe.utils import cstr, getdate, split_emails, add_days, today, get_last_ from frappe.model.document import Document from frappe.core.doctype.communication.email import make from frappe.utils.background_jobs import get_jobs +from frappe.automation.doctype.assignment_rule.assignment_rule import get_repeated +from frappe.contacts.doctype.contact.contact import get_contacts_linked_from +from frappe.contacts.doctype.contact.contact import get_contacts_linking_to month_map = {'Monthly': 1, 'Quarterly': 3, 'Half-yearly': 6, 'Yearly': 12} - +week_map = {'Monday': 0, 'Tuesday': 1, 'Wednesday': 2, 'Thursday': 3, 'Friday': 4, 'Saturday': 5, 'Sunday': 6} class AutoRepeat(Document): def validate(self): self.update_status() self.validate_reference_doctype() + self.validate_submit_on_creation() self.validate_dates() self.validate_email_id() + self.validate_auto_repeat_days() self.set_dates() self.update_auto_repeat_id() self.unlink_if_applicable() @@ -48,7 +54,7 @@ class AutoRepeat(Document): if self.disabled: self.next_schedule_date = None else: - self.next_schedule_date = get_next_schedule_date(self.start_date, self.frequency, self.start_date, self.repeat_on_day, self.repeat_on_last_day, self.end_date) + self.next_schedule_date = self.get_next_schedule_date(schedule_date=self.start_date) def unlink_if_applicable(self): if self.status == 'Completed' or self.disabled: @@ -60,6 +66,11 @@ class AutoRepeat(Document): if not frappe.get_meta(self.reference_doctype).allow_auto_repeat: frappe.throw(_("Enable Allow Auto Repeat for the doctype {0} in Customize Form").format(self.reference_doctype)) + def validate_submit_on_creation(self): + if self.submit_on_creation and not frappe.get_meta(self.reference_doctype).is_submittable: + frappe.throw(_('Cannot enable {0} for a non-submittable doctype').format( + frappe.bold('Submit on Creation'))) + def validate_dates(self): if frappe.flags.in_patch: return @@ -82,6 +93,12 @@ class AutoRepeat(Document): else: frappe.throw(_("'Recipients' not specified")) + def validate_auto_repeat_days(self): + auto_repeat_days = self.get_auto_repeat_days() + if not len(set(auto_repeat_days)) == len(auto_repeat_days): + repeated_days = get_repeated(auto_repeat_days) + frappe.throw(_('Auto Repeat Day {0} has been repeated.').format(frappe.bold(repeated_days))) + def update_auto_repeat_id(self): #check if document is already on auto repeat auto_repeat = frappe.db.get_value(self.reference_doctype, self.reference_document, "auto_repeat") @@ -107,7 +124,7 @@ class AutoRepeat(Document): end_date = getdate(self.end_date) if not self.end_date: - next_date = get_next_schedule_date(start_date, self.frequency, self.start_date, self.repeat_on_day, self.repeat_on_last_day) + next_date = self.get_next_schedule_date(schedule_date=start_date) row = { "reference_document": self.reference_document, "frequency": self.frequency, @@ -116,8 +133,7 @@ class AutoRepeat(Document): schedule_details.append(row) if self.end_date: - next_date = get_next_schedule_date( - start_date, self.frequency, self.start_date, self.repeat_on_day, self.repeat_on_last_day, for_full_schedule=True) + next_date = self.get_next_schedule_date(schedule_date=start_date, for_full_schedule=True) while (getdate(next_date) < getdate(end_date)): row = { @@ -126,8 +142,7 @@ class AutoRepeat(Document): "next_scheduled_date" : next_date } schedule_details.append(row) - next_date = get_next_schedule_date( - next_date, self.frequency, self.start_date, self.repeat_on_day, self.repeat_on_last_day, end_date, for_full_schedule=True) + next_date = self.get_next_schedule_date(schedule_date=next_date, for_full_schedule=True) return schedule_details @@ -150,6 +165,9 @@ class AutoRepeat(Document): self.update_doc(new_doc, reference_doc) new_doc.insert(ignore_permissions = True) + if self.submit_on_creation: + new_doc.submit() + return new_doc def update_doc(self, new_doc, reference_doc): @@ -160,7 +178,7 @@ class AutoRepeat(Document): if new_doc.meta.get_field('auto_repeat'): new_doc.set('auto_repeat', self.name) - for fieldname in ['naming_series', 'ignore_pricing_rule', 'posting_time', 'select_print_heading', 'remarks', 'owner']: + for fieldname in ['naming_series', 'ignore_pricing_rule', 'posting_time', 'select_print_heading', 'user_remark', 'remarks', 'owner']: if new_doc.meta.get_field(fieldname): new_doc.set(fieldname, reference_doc.get(fieldname)) @@ -202,6 +220,75 @@ class AutoRepeat(Document): new_doc.set('from_date', from_date) new_doc.set('to_date', to_date) + def get_next_schedule_date(self, schedule_date, for_full_schedule=False): + """ + Returns the next schedule date for auto repeat after a recurring document has been created. + Adds required offset to the schedule_date param and returns the next schedule date. + + :param schedule_date: The date when the last recurring document was created. + :param for_full_schedule: If True, returns the immediate next schedule date, else the full schedule. + """ + if month_map.get(self.frequency): + month_count = month_map.get(self.frequency) + month_diff(schedule_date, self.start_date) - 1 + else: + month_count = 0 + + day_count = 0 + if month_count and self.repeat_on_last_day: + day_count = 31 + next_date = get_next_date(self.start_date, month_count, day_count) + elif month_count and self.repeat_on_day: + day_count = self.repeat_on_day + next_date = get_next_date(self.start_date, month_count, day_count) + elif month_count: + next_date = get_next_date(self.start_date, month_count) + else: + days = self.get_days(schedule_date) + next_date = add_days(schedule_date, days) + + # next schedule date should be after or on current date + if not for_full_schedule: + while getdate(next_date) < getdate(today()): + if month_count: + month_count += month_map.get(self.frequency, 0) + next_date = get_next_date(self.start_date, month_count, day_count) + else: + days = self.get_days(next_date) + next_date = add_days(next_date, days) + + return next_date + + def get_days(self, schedule_date): + if self.frequency == "Weekly": + days = self.get_offset_for_weekly_frequency(schedule_date) + else: + # daily frequency + days = 1 + + return days + + def get_offset_for_weekly_frequency(self, schedule_date): + # if weekdays are not set, offset is 7 from current schedule date + if not self.repeat_on_days: + return 7 + + repeat_on_days = self.get_auto_repeat_days() + current_schedule_day = getdate(schedule_date).weekday() + weekdays = list(week_map.keys()) + + # if repeats on more than 1 day or + # start date's weekday is not in repeat days, then get next weekday + # else offset is 7 + if len(repeat_on_days) > 1 or weekdays[current_schedule_day] not in repeat_on_days: + weekday = get_next_weekday(current_schedule_day, repeat_on_days) + next_weekday_number = week_map.get(weekday, 0) + # offset for upcoming weekday + return timedelta((7 + next_weekday_number - current_schedule_day) % 7).days + return 7 + + def get_auto_repeat_days(self): + return [d.day for d in self.get('repeat_on_days', [])] + def send_notification(self, new_doc): """Notify concerned people about recurring document generation""" subject = self.subject or '' @@ -243,13 +330,8 @@ class AutoRepeat(Document): def fetch_linked_contacts(self): if self.reference_doctype and self.reference_document: - res = frappe.db.get_all('Contact', - fields=['email_id'], - filters=[ - ['Dynamic Link', 'link_doctype', '=', self.reference_doctype], - ['Dynamic Link', 'link_name', '=', self.reference_document] - ]) - + res = get_contacts_linking_to(self.reference_doctype, self.reference_document, fields=['email_id']) + res += get_contacts_linked_from(self.reference_doctype, self.reference_document, fields=['email_id']) email_ids = list(set([d.email_id for d in res])) if not email_ids: frappe.msgprint(_('No contacts linked to document'), alert=True) @@ -282,42 +364,24 @@ class AutoRepeat(Document): ) -def get_next_schedule_date(schedule_date, frequency, start_date, repeat_on_day=None, repeat_on_last_day=False, end_date=None, for_full_schedule=False): - if month_map.get(frequency): - month_count = month_map.get(frequency) + month_diff(schedule_date, start_date) - 1 - else: - month_count = 0 - - day_count = 0 - if month_count and repeat_on_last_day: - day_count = 31 - next_date = get_next_date(start_date, month_count, day_count) - elif month_count and repeat_on_day: - day_count = repeat_on_day - next_date = get_next_date(start_date, month_count, day_count) - elif month_count: - next_date = get_next_date(start_date, month_count) - else: - days = 7 if frequency == 'Weekly' else 1 - next_date = add_days(schedule_date, days) - - # next schedule date should be after or on current date - if not for_full_schedule: - while getdate(next_date) < getdate(today()): - if month_count: - month_count += month_map.get(frequency) - next_date = get_next_date(start_date, month_count, day_count) - elif days: - next_date = add_days(next_date, days) - - return next_date - - def get_next_date(dt, mcount, day=None): dt = getdate(dt) dt += relativedelta(months=mcount, day=day) return dt + +def get_next_weekday(current_schedule_day, weekdays): + days = list(week_map.keys()) + if current_schedule_day > 0: + days = days[(current_schedule_day + 1):] + days[:current_schedule_day] + else: + days = days[(current_schedule_day + 1):] + + for entry in days: + if entry in weekdays: + return entry + + #called through hooks def make_auto_repeat_entry(): enqueued_method = 'frappe.automation.doctype.auto_repeat.auto_repeat.create_repeated_entries' @@ -328,6 +392,7 @@ def make_auto_repeat_entry(): data = get_auto_repeat_entries(date) frappe.enqueue(enqueued_method, data=data) + def create_repeated_entries(data): for d in data: doc = frappe.get_doc('Auto Repeat', d.name) @@ -337,10 +402,11 @@ def create_repeated_entries(data): if schedule_date == current_date and not doc.disabled: doc.create_documents() - schedule_date = get_next_schedule_date(schedule_date, doc.frequency, doc.start_date, doc.repeat_on_day, doc.repeat_on_last_day, doc.end_date) + schedule_date = doc.get_next_schedule_date(schedule_date=schedule_date) if schedule_date and not doc.disabled: frappe.db.set_value('Auto Repeat', doc.name, 'next_schedule_date', schedule_date) + def get_auto_repeat_entries(date=None): if not date: date = getdate(today()) @@ -349,6 +415,7 @@ def get_auto_repeat_entries(date=None): ['status', '=', 'Active'] ]) + #called through hooks def set_auto_repeat_as_completed(): auto_repeat = frappe.get_all("Auto Repeat", filters = {'status': ['!=', 'Disabled']}) @@ -358,6 +425,7 @@ def set_auto_repeat_as_completed(): doc.status = 'Completed' doc.save() + @frappe.whitelist() def make_auto_repeat(doctype, docname, frequency = 'Daily', start_date = None, end_date = None): if not start_date: diff --git a/frappe/automation/doctype/auto_repeat/test_auto_repeat.py b/frappe/automation/doctype/auto_repeat/test_auto_repeat.py index 60fa9cb59e..0d6229cd9e 100644 --- a/frappe/automation/doctype/auto_repeat/test_auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/test_auto_repeat.py @@ -7,10 +7,9 @@ import unittest import frappe from frappe.custom.doctype.custom_field.custom_field import create_custom_field -from frappe.automation.doctype.auto_repeat.auto_repeat import get_auto_repeat_entries, create_repeated_entries +from frappe.automation.doctype.auto_repeat.auto_repeat import get_auto_repeat_entries, create_repeated_entries, week_map from frappe.utils import today, add_days, getdate, add_months - def add_custom_fields(): df = dict( fieldname='auto_repeat', label='Auto Repeat', fieldtype='Link', insert_after='sender', @@ -42,6 +41,52 @@ class TestAutoRepeat(unittest.TestCase): self.assertEqual(todo.get('description'), new_todo.get('description')) + def test_weekly_auto_repeat(self): + todo = frappe.get_doc( + dict(doctype='ToDo', description='test weekly todo', assigned_by='Administrator')).insert() + + doc = make_auto_repeat(reference_doctype='ToDo', + frequency='Weekly', reference_document=todo.name, start_date=add_days(today(), -7)) + + self.assertEqual(doc.next_schedule_date, today()) + data = get_auto_repeat_entries(getdate(today())) + create_repeated_entries(data) + frappe.db.commit() + + todo = frappe.get_doc(doc.reference_doctype, doc.reference_document) + self.assertEqual(todo.auto_repeat, doc.name) + + new_todo = frappe.db.get_value('ToDo', + {'auto_repeat': doc.name, 'name': ('!=', todo.name)}, 'name') + + new_todo = frappe.get_doc('ToDo', new_todo) + + self.assertEqual(todo.get('description'), new_todo.get('description')) + + def test_weekly_auto_repeat_with_weekdays(self): + todo = frappe.get_doc( + dict(doctype='ToDo', description='test auto repeat with weekdays', assigned_by='Administrator')).insert() + + weekdays = list(week_map.keys()) + current_weekday = getdate().weekday() + days = [ + {'day': weekdays[current_weekday]}, + {'day': weekdays[(current_weekday + 2) % 7]} + ] + doc = make_auto_repeat(reference_doctype='ToDo', + frequency='Weekly', reference_document=todo.name, start_date=add_days(today(), -7), days=days) + + self.assertEqual(doc.next_schedule_date, today()) + data = get_auto_repeat_entries(getdate(today())) + create_repeated_entries(data) + frappe.db.commit() + + todo = frappe.get_doc(doc.reference_doctype, doc.reference_document) + self.assertEqual(todo.auto_repeat, doc.name) + + doc.reload() + self.assertEqual(doc.next_schedule_date, add_days(getdate(), 2)) + def test_monthly_auto_repeat(self): start_date = today() end_date = add_months(start_date, 12) @@ -111,6 +156,25 @@ class TestAutoRepeat(unittest.TestCase): doc = make_auto_repeat(frequency='Daily', reference_document=todo.name, start_date=add_days(today(), -2)) self.assertEqual(getdate(doc.next_schedule_date), current_date) + def test_submit_on_creation(self): + doctype = 'Test Submittable DocType' + create_submittable_doctype(doctype) + + current_date = getdate() + submittable_doc = frappe.get_doc(dict(doctype=doctype, test='test submit on creation')).insert() + submittable_doc.submit() + doc = make_auto_repeat(frequency='Daily', reference_doctype=doctype, reference_document=submittable_doc.name, + start_date=add_days(current_date, -1), submit_on_creation=1) + + data = get_auto_repeat_entries(current_date) + create_repeated_entries(data) + docnames = frappe.db.get_all(doc.reference_doctype, + filters={'auto_repeat': doc.name}, + fields=['docstatus'], + limit=1 + ) + self.assertEquals(docnames[0].docstatus, 1) + def make_auto_repeat(**args): args = frappe._dict(args) @@ -118,13 +182,46 @@ def make_auto_repeat(**args): 'doctype': 'Auto Repeat', 'reference_doctype': args.reference_doctype or 'ToDo', 'reference_document': args.reference_document or frappe.db.get_value('ToDo', 'name'), + 'submit_on_creation': args.submit_on_creation or 0, 'frequency': args.frequency or 'Daily', 'start_date': args.start_date or add_days(today(), -1), 'end_date': args.end_date or "", 'notify_by_email': args.notify or 0, 'recipients': args.recipients or "", 'subject': args.subject or "", - 'message': args.message or "" + 'message': args.message or "", + 'repeat_on_days': args.days or [] }).insert(ignore_permissions=True) return doc + + +def create_submittable_doctype(doctype): + if frappe.db.exists('DocType', doctype): + return + else: + doc = frappe.get_doc({ + 'doctype': 'DocType', + '__newname': doctype, + 'module': 'Custom', + 'custom': 1, + 'is_submittable': 1, + 'fields': [{ + 'fieldname': 'test', + 'label': 'Test', + 'fieldtype': 'Data' + }], + 'permissions': [{ + 'role': 'System Manager', + 'read': 1, + 'write': 1, + 'create': 1, + 'delete': 1, + 'submit': 1, + 'cancel': 1, + 'amend': 1 + }] + }).insert() + + doc.allow_auto_repeat = 1 + doc.save() \ No newline at end of file diff --git a/frappe/core/page/dashboard/__init__.py b/frappe/automation/doctype/auto_repeat_day/__init__.py similarity index 100% rename from frappe/core/page/dashboard/__init__.py rename to frappe/automation/doctype/auto_repeat_day/__init__.py diff --git a/frappe/automation/doctype/auto_repeat_day/auto_repeat_day.json b/frappe/automation/doctype/auto_repeat_day/auto_repeat_day.json new file mode 100644 index 0000000000..6f5c3060cd --- /dev/null +++ b/frappe/automation/doctype/auto_repeat_day/auto_repeat_day.json @@ -0,0 +1,33 @@ +{ + "actions": [], + "creation": "2020-11-10 22:30:53.690228", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "day" + ], + "fields": [ + { + "fieldname": "day", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Day", + "options": "Monday\nTuesday\nWednesday\nThursday\nFriday\nSaturday\nSunday", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2020-11-10 22:30:53.690228", + "modified_by": "Administrator", + "module": "Automation", + "name": "Auto Repeat Day", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/desk/doctype/desk_shortcut/desk_shortcut.py b/frappe/automation/doctype/auto_repeat_day/auto_repeat_day.py similarity index 88% rename from frappe/desk/doctype/desk_shortcut/desk_shortcut.py rename to frappe/automation/doctype/auto_repeat_day/auto_repeat_day.py index bbf0b2e074..3a7ced1370 100644 --- a/frappe/desk/doctype/desk_shortcut/desk_shortcut.py +++ b/frappe/automation/doctype/auto_repeat_day/auto_repeat_day.py @@ -6,5 +6,5 @@ from __future__ import unicode_literals # import frappe from frappe.model.document import Document -class DeskShortcut(Document): +class AutoRepeatDay(Document): pass diff --git a/frappe/automation/workspace/tools/tools.json b/frappe/automation/workspace/tools/tools.json new file mode 100644 index 0000000000..4a0835657b --- /dev/null +++ b/frappe/automation/workspace/tools/tools.json @@ -0,0 +1,229 @@ +{ + "category": "Administration", + "charts": [], + "creation": "2020-03-02 14:53:24.980279", + "developer_mode_only": 0, + "disable_user_customization": 0, + "docstatus": 0, + "doctype": "Workspace", + "extends_another_page": 0, + "hide_custom": 0, + "icon": "tool", + "idx": 0, + "is_standard": 1, + "label": "Tools", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Tools", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "To Do", + "link_to": "ToDo", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Calendar", + "link_to": "Event", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Note", + "link_to": "Note", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Files", + "link_to": "File", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Activity", + "link_to": "activity", + "link_type": "Page", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Email", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Newsletter", + "link_to": "Newsletter", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Email Group", + "link_to": "Email Group", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Automation", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Assignment Rule", + "link_to": "Assignment Rule", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Milestone", + "link_to": "Milestone", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Auto Repeat", + "link_to": "Auto Repeat", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Event Streaming", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Event Producer", + "link_to": "Event Producer", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Event Consumer", + "link_to": "Event Consumer", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Event Update Log", + "link_to": "Event Update Log", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Event Sync Log", + "link_to": "Event Sync Log", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Document Type Mapping", + "link_to": "Document Type Mapping", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2020-12-01 13:38:39.950350", + "modified_by": "Administrator", + "module": "Automation", + "name": "Tools", + "owner": "Administrator", + "pin_to_bottom": 0, + "pin_to_top": 0, + "shortcuts": [ + { + "label": "ToDo", + "link_to": "ToDo", + "type": "DocType" + }, + { + "label": "Note", + "link_to": "Note", + "type": "DocType" + }, + { + "label": "File", + "link_to": "File", + "type": "DocType" + }, + { + "label": "Assignment Rule", + "link_to": "Assignment Rule", + "type": "DocType" + }, + { + "label": "Auto Repeat", + "link_to": "Auto Repeat", + "type": "DocType" + } + ] +} \ No newline at end of file diff --git a/frappe/boot.py b/frappe/boot.py index 5b1a1bf573..0dfcb8d1b4 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -21,7 +21,7 @@ from frappe.website.doctype.web_page_view.web_page_view import is_tracking_enabl from frappe.social.doctype.energy_point_log.energy_point_log import get_energy_points from frappe.model.base_document import get_controller from frappe.social.doctype.post.post import frequently_visited_links -from frappe.core.doctype.navbar_settings.navbar_settings import get_navbar_settings +from frappe.core.doctype.navbar_settings.navbar_settings import get_navbar_settings, get_app_logo def get_bootinfo(): """build and return boot info""" @@ -39,7 +39,7 @@ def get_bootinfo(): bootinfo.server_date = frappe.utils.nowdate() if frappe.session['user'] != 'Guest': - bootinfo.user_info = get_fullnames() + bootinfo.user_info = get_user_info() bootinfo.sid = frappe.session['sid'] bootinfo.modules = {} @@ -48,6 +48,7 @@ def get_bootinfo(): bootinfo.letter_heads = get_letter_heads() bootinfo.active_domains = frappe.get_active_domains() bootinfo.all_domains = [d.get("name") for d in frappe.get_all("Domain")] + add_layouts(bootinfo) bootinfo.module_app = frappe.local.module_app bootinfo.single_types = [d.name for d in frappe.get_all('DocType', {'issingle': 1})] @@ -61,6 +62,7 @@ def get_bootinfo(): doclist.extend(get_meta_bundle("Page")) bootinfo.home_folder = frappe.db.get_value("File", {"is_home_folder": 1}) bootinfo.navbar_settings = get_navbar_settings() + bootinfo.notification_settings = get_notification_settings() # ipinfo if frappe.session.data.get('ipinfo'): @@ -88,6 +90,8 @@ def get_bootinfo(): bootinfo.frequently_visited_links = frequently_visited_links() bootinfo.link_preview_doctypes = get_link_preview_doctypes() bootinfo.additional_filters_config = get_additional_filters_from_hooks() + bootinfo.desk_settings = get_desk_settings() + bootinfo.app_logo_url = get_app_logo() return bootinfo @@ -106,11 +110,9 @@ def load_conf_settings(bootinfo): if key in conf: bootinfo[key] = conf.get(key) def load_desktop_data(bootinfo): - from frappe.config import get_modules_from_all_apps_for_user from frappe.desk.desktop import get_desk_sidebar_items - bootinfo.allowed_modules = get_modules_from_all_apps_for_user() - bootinfo.allowed_workspaces = get_desk_sidebar_items(flatten=True, cache=False) - bootinfo.module_page_map = get_controller("Desk Page").get_module_page_map() + bootinfo.allowed_workspaces = get_desk_sidebar_items() + bootinfo.module_page_map = get_controller("Workspace").get_module_page_map() bootinfo.dashboards = frappe.get_all("Dashboard") def get_allowed_pages(cache=False): @@ -222,19 +224,18 @@ def load_translations(bootinfo): bootinfo["__messages"] = messages -def get_fullnames(): - """map of user fullnames""" - ret = frappe.db.sql("""select `name`, full_name as fullname, - user_image as image, gender, email, username, bio, location, interest, banner_image, allowed_in_mentions - from tabUser where enabled=1 and user_type!='Website User'""", as_dict=1) +def get_user_info(): + user_info = frappe.db.get_all('User', fields=['`name`', 'full_name as fullname', 'user_image as image', + 'gender', 'email', 'username', 'bio', 'location', 'interest', 'banner_image', 'allowed_in_mentions', 'user_type'], + filters=dict(enabled=1)) - d = {} - for r in ret: - # if not r.image: - # r.image = get_gravatar(r.name) - d[r.name] = r + user_info_map = {d.name: d for d in user_info} - return d + admin_data = user_info_map.get('Administrator') + if admin_data: + user_info_map[admin_data.email] = admin_data + + return user_info_map def get_user(bootinfo): """get user info""" @@ -251,13 +252,12 @@ def add_home_page(bootinfo, docs): try: page = frappe.desk.desk_page.get(home_page) + docs.append(page) + bootinfo['home_page'] = page.name except (frappe.DoesNotExistError, frappe.PermissionError): if frappe.message_log: frappe.message_log.pop() - page = frappe.desk.desk_page.get('workspace') - - bootinfo['home_page'] = page.name - docs.append(page) + bootinfo['home_page'] = 'Workspaces' def add_timezone_info(bootinfo): system = bootinfo.sysdefaults.get("time_zone") @@ -273,7 +273,7 @@ def load_print(bootinfo, doclist): def load_print_css(bootinfo, print_settings): import frappe.www.printview - bootinfo.print_css = frappe.www.printview.get_print_style(print_settings.print_style or "Modern", for_legacy=True) + bootinfo.print_css = frappe.www.printview.get_print_style(print_settings.print_style or "Redesign", for_legacy=True) def get_unseen_notes(): return frappe.db.sql('''select `name`, title, content, notify_on_every_login from `tabNote` where notify_on_login=1 @@ -308,3 +308,24 @@ def get_additional_filters_from_hooks(): filter_config.update(frappe.get_attr(hook)()) return filter_config + +def add_layouts(bootinfo): + # add routes for readable doctypes + bootinfo.doctype_layouts = frappe.get_all('DocType Layout', ['name', 'route', 'document_type']) + +def get_desk_settings(): + role_list = frappe.get_all('Role', fields=['*'], filters=dict( + name=['in', frappe.get_roles()] + )) + desk_settings = {} + + from frappe.core.doctype.role.role import desk_properties + + for role in role_list: + for key in desk_properties: + desk_settings[key] = desk_settings.get(key) or role.get(key) + + return desk_settings + +def get_notification_settings(): + return frappe.get_cached_doc('Notification Settings', frappe.session.user) diff --git a/frappe/build.py b/frappe/build.py index 767217a9b9..baedb633b6 100644 --- a/frappe/build.py +++ b/frappe/build.py @@ -15,7 +15,7 @@ import frappe from frappe.utils.minify import JavascriptMinify import click -from requests import get +import psutil from six import iteritems, text_type from six.moves.urllib.parse import urlparse @@ -26,6 +26,8 @@ sites_path = os.path.abspath(os.getcwd()) def download_file(url, prefix): + from requests import get + filename = urlparse(url).path.split("/")[-1] local_filename = os.path.join(prefix, filename) with get(url, stream=True, allow_redirects=True) as r: @@ -40,6 +42,7 @@ def build_missing_files(): # check which files dont exist yet from the build.json and tell build.js to build only those! missing_assets = [] current_asset_files = [] + frappe_build = os.path.join("..", "apps", "frappe", "frappe", "public", "build.json") for type in ["css", "js"]: current_asset_files.extend( @@ -49,7 +52,7 @@ def build_missing_files(): ] ) - with open(os.path.join(sites_path, "assets", "frappe", "build.json")) as f: + with open(frappe_build) as f: all_asset_files = json.load(f).keys() for asset in all_asset_files: @@ -104,20 +107,28 @@ def download_frappe_assets(verbose=True): if frappe_head: try: url = get_assets_link(frappe_head) - click.secho("Retreiving assets...", fg="yellow") + click.secho("Retrieving assets...", fg="yellow") prefix = mkdtemp(prefix="frappe-assets-", suffix=frappe_head) assets_archive = download_file(url, prefix) print("\n{0} Downloaded Frappe assets from {1}".format(green('✔'), url)) if assets_archive: import tarfile + directories_created = set() click.secho("\nExtracting assets...\n", fg="yellow") with tarfile.open(assets_archive) as tar: for file in tar: if not file.isdir(): dest = "." + file.name.replace("./frappe-bench/sites", "") + asset_directory = os.path.dirname(dest) show = dest.replace("./assets/", "") + + if asset_directory not in directories_created: + if not os.path.exists(asset_directory): + os.makedirs(asset_directory, exist_ok=True) + directories_created.add(asset_directory) + tar.makefile(file, dest) print("{0} Restored {1}".format(green('✔'), show)) @@ -216,7 +227,7 @@ def bundle(no_compress, app=None, make_copy=False, restore=False, verbose=False, frappe_app_path = os.path.abspath(os.path.join(app_paths[0], "..")) check_yarn() - frappe.commands.popen(command, cwd=frappe_app_path) + frappe.commands.popen(command, cwd=frappe_app_path, env=get_node_env()) def watch(no_compress): @@ -228,13 +239,32 @@ def watch(no_compress): frappe_app_path = os.path.abspath(os.path.join(app_paths[0], "..")) check_yarn() frappe_app_path = frappe.get_app_path("frappe", "..") - frappe.commands.popen("{pacman} run watch".format(pacman=pacman), cwd=frappe_app_path) + frappe.commands.popen("{pacman} run watch".format(pacman=pacman), + cwd=frappe_app_path, env=get_node_env()) def check_yarn(): if not find_executable("yarn"): print("Please install yarn using below command and try again.\nnpm install -g yarn") +def get_node_env(): + node_env = { + "NODE_OPTIONS": f"--max_old_space_size={get_safe_max_old_space_size()}" + } + return node_env + +def get_safe_max_old_space_size(): + safe_max_old_space_size = 0 + try: + total_memory = psutil.virtual_memory().total / (1024 * 1024) + # reference for the safe limit assumption + # https://nodejs.org/api/cli.html#cli_max_old_space_size_size_in_megabytes + # set minimum value 1GB + safe_max_old_space_size = max(1024, int(total_memory * 0.75)) + except Exception: + pass + + return safe_max_old_space_size def make_asset_dirs(make_copy=False, restore=False): # don't even think of making assets_path absolute - rm -rf ahead. diff --git a/frappe/cache_manager.py b/frappe/cache_manager.py index 3b3d188999..bad879d2fa 100644 --- a/frappe/cache_manager.py +++ b/frappe/cache_manager.py @@ -13,7 +13,7 @@ common_default_keys = ["__default", "__global"] doctype_map_keys = ('energy_point_rule_map', 'assignment_rule_map', 'milestone_tracker_map', 'event_consumer_document_type_map') -global_cache_keys = ("app_hooks", "installed_apps", +global_cache_keys = ("app_hooks", "installed_apps", 'all_apps', "app_modules", "module_app", "system_settings", 'scheduler_events', 'time_zone', 'webhooks', 'active_domains', 'active_modules', 'assignment_rule', 'server_script_map', 'wkhtmltopdf_version', @@ -67,19 +67,18 @@ def clear_defaults_cache(user=None): elif frappe.flags.in_install!="frappe": frappe.cache().delete_key("defaults") -def clear_document_cache(): - frappe.local.document_cache = {} - frappe.cache().delete_key("document_cache") - def clear_doctype_cache(doctype=None): + clear_controller_cache(doctype) cache = frappe.cache() if getattr(frappe.local, 'meta_cache') and (doctype in frappe.local.meta_cache): del frappe.local.meta_cache[doctype] - for key in ('is_table', 'doctype_modules'): + for key in ('is_table', 'doctype_modules', 'document_cache'): cache.delete_value(key) + frappe.local.document_cache = {} + def clear_single(dt): for name in doctype_cache_keys: cache.hdel(name, dt) @@ -101,8 +100,14 @@ def clear_doctype_cache(doctype=None): for name in doctype_cache_keys: cache.delete_value(name) - # Clear all document's cache. To clear documents of a specific DocType document_cache should be restructured - clear_document_cache() +def clear_controller_cache(doctype=None): + if not doctype: + del frappe.controllers + frappe.controllers = {} + return + + for site_controllers in frappe.controllers.values(): + site_controllers.pop(doctype, None) def get_doctype_map(doctype, name, filters=None, order_by=None): cache = frappe.cache() diff --git a/frappe/chat/util/util.py b/frappe/chat/util/util.py index 5aa80a85ae..82df6dd127 100644 --- a/frappe/chat/util/util.py +++ b/frappe/chat/util/util.py @@ -1,27 +1,21 @@ from __future__ import unicode_literals +# imports - standard imports +import json +from collections.abc import MutableMapping, MutableSequence, Sequence + # imports - third-party imports import requests - -# imports - compatibility imports -import six - -# imports - standard imports -from collections import Sequence, MutableSequence, Mapping, MutableMapping -if six.PY2: - from urlparse import urlparse # PY2 -else: - from urllib.parse import urlparse # PY3 -import json +from urllib.parse import urlparse # imports - module imports -from frappe.model.document import Document -from frappe.exceptions import DuplicateEntryError -from frappe import _dict import frappe +from frappe.exceptions import DuplicateEntryError +from frappe.model.document import Document session = frappe.session + def get_user_doc(user = None): if isinstance(user, Document): return user @@ -38,12 +32,12 @@ def squashify(what): return what def safe_json_loads(*args): - results = [ ] + results = [] for arg in args: try: arg = json.loads(arg) - except Exception as e: + except Exception: pass results.append(arg) @@ -81,7 +75,7 @@ def dictify(arg): for i, a in enumerate(arg): arg[i] = dictify(a) elif isinstance(arg, MutableMapping): - arg = _dict(arg) + arg = frappe._dict(arg) return arg @@ -113,4 +107,4 @@ def get_emojis(): emojis = resp.json() redis.hset('frappe_emojis', 'emojis', emojis) - return dictify(emojis) \ No newline at end of file + return dictify(emojis) diff --git a/frappe/commands/__init__.py b/frappe/commands/__init__.py index b7294fff77..b9ae02e112 100644 --- a/frappe/commands/__init__.py +++ b/frappe/commands/__init__.py @@ -11,6 +11,7 @@ import frappe.utils import subprocess # nosec from functools import wraps from six import StringIO +from os import environ click.disable_unicode_literals_warning = True @@ -53,16 +54,20 @@ def get_site(context, raise_err=True): return None def popen(command, *args, **kwargs): - output = kwargs.get('output', True) - cwd = kwargs.get('cwd') - shell = kwargs.get('shell', True) + output = kwargs.get('output', True) + cwd = kwargs.get('cwd') + shell = kwargs.get('shell', True) raise_err = kwargs.get('raise_err') + env = kwargs.get('env') + if env: + env = dict(environ, **env) proc = subprocess.Popen(command, - stdout = None if output else subprocess.PIPE, - stderr = None if output else subprocess.PIPE, - shell = shell, - cwd = cwd + stdout=None if output else subprocess.PIPE, + stderr=None if output else subprocess.PIPE, + shell=shell, + cwd=cwd, + env=env ) return_ = proc.wait() diff --git a/frappe/commands/site.py b/frappe/commands/site.py index fe8b238f32..0fadf2a294 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -1,10 +1,5 @@ # imports - standard imports -import atexit -import compileall -import hashlib import os -import re -import shutil import sys # imports - third party imports @@ -13,10 +8,7 @@ import click # imports - module imports import frappe from frappe.commands import get_site, pass_context -from frappe.commands.scheduler import _is_scheduler_enabled from frappe.exceptions import SiteNotSpecifiedError -from frappe.installer import update_site_config -from frappe.utils import get_site_path, touch_file @click.command('new-site') @@ -38,6 +30,8 @@ def new_site(site, mariadb_root_username=None, mariadb_root_password=None, admin verbose=False, install_apps=None, source_sql=None, force=None, no_mariadb_socket=False, install_app=None, db_name=None, db_password=None, db_type=None, db_host=None, db_port=None): "Create a new site" + from frappe.installer import _new_site + frappe.init(site=site, new_site=True) _new_site(db_name, site, mariadb_root_username=mariadb_root_username, @@ -49,54 +43,6 @@ def new_site(site, mariadb_root_username=None, mariadb_root_password=None, admin if len(frappe.utils.get_sites()) == 1: use(site) -def _new_site(db_name, site, mariadb_root_username=None, mariadb_root_password=None, - admin_password=None, verbose=False, install_apps=None, source_sql=None, force=False, - no_mariadb_socket=False, reinstall=False, db_password=None, db_type=None, db_host=None, - db_port=None, new_site=False): - """Install a new Frappe site""" - - if not force and os.path.exists(site): - print('Site {0} already exists'.format(site)) - sys.exit(1) - - if no_mariadb_socket and not db_type == "mariadb": - print('--no-mariadb-socket requires db_type to be set to mariadb.') - sys.exit(1) - - if not db_name: - db_name = '_' + hashlib.sha1(site.encode()).hexdigest()[:16] - - from frappe.installer import install_db, make_site_dirs - from frappe.installer import install_app as _install_app - import frappe.utils.scheduler - - frappe.init(site=site) - - try: - # enable scheduler post install? - enable_scheduler = _is_scheduler_enabled() - except Exception: - enable_scheduler = False - - make_site_dirs() - - installing = touch_file(get_site_path('locks', 'installing.lock')) - - install_db(root_login=mariadb_root_username, root_password=mariadb_root_password, db_name=db_name, - admin_password=admin_password, verbose=verbose, source_sql=source_sql, force=force, reinstall=reinstall, - db_password=db_password, db_type=db_type, db_host=db_host, db_port=db_port, no_mariadb_socket=no_mariadb_socket) - apps_to_install = ['frappe'] + (frappe.conf.get("install_apps") or []) + (list(install_apps) or []) - for app in apps_to_install: - _install_app(app, verbose=verbose, set_as_patched=not source_sql) - - os.remove(installing) - - frappe.utils.scheduler.toggle_scheduler(enable_scheduler) - frappe.db.commit() - - scheduler_status = "disabled" if frappe.utils.scheduler.is_scheduler_disabled() else "enabled" - print("*** Scheduler is", scheduler_status, "***") - @click.command('restore') @click.argument('sql-file-path') @@ -107,36 +53,46 @@ def _new_site(db_name, site, mariadb_root_username=None, mariadb_root_password=N @click.option('--install-app', multiple=True, help='Install app after installation') @click.option('--with-public-files', help='Restores the public files of the site, given path to its tar file') @click.option('--with-private-files', help='Restores the private files of the site, given path to its tar file') -@click.option('--force', is_flag=True, default=False, help='Use a bit of force to get the job done') +@click.option('--force', is_flag=True, default=False, help='Ignore the validations and downgrade warnings. This action is not recommended') @pass_context def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_password=None, db_name=None, verbose=None, install_app=None, admin_password=None, force=None, with_public_files=None, with_private_files=None): "Restore site database from an sql file" - from frappe.installer import extract_sql_gzip, extract_tar_files, is_downgrade + from frappe.installer import ( + _new_site, + extract_sql_from_archive, + extract_files, + is_downgrade, + is_partial, + validate_database_sql + ) + force = context.force or force + decompressed_file_name = extract_sql_from_archive(sql_file_path) - # Extract the gzip file if user has passed *.sql.gz file instead of *.sql file - if not os.path.exists(sql_file_path): - base_path = '..' - sql_file_path = os.path.join(base_path, sql_file_path) - if not os.path.exists(sql_file_path): - print('Invalid path {0}'.format(sql_file_path[3:])) - sys.exit(1) - elif sql_file_path.startswith(os.sep): - base_path = os.sep - else: - base_path = '.' + # check if partial backup + if is_partial(decompressed_file_name): + click.secho( + "Partial Backup file detected. You cannot use a partial file to restore a Frappe Site.", + fg="red" + ) + click.secho( + "Use `bench partial-restore` to restore a partial backup to an existing site.", + fg="yellow" + ) + sys.exit(1) - if sql_file_path.endswith('sql.gz'): - decompressed_file_name = extract_sql_gzip(os.path.abspath(sql_file_path)) - else: - decompressed_file_name = sql_file_path + # check if valid SQL file + validate_database_sql(decompressed_file_name, _raise=not force) site = get_site(context) frappe.init(site=site) # dont allow downgrading to older versions of frappe without force if not force and is_downgrade(decompressed_file_name, verbose=True): - warn_message = "This is not recommended and may lead to unexpected behaviour. Do you want to continue anyway?" + warn_message = ( + "This is not recommended and may lead to unexpected behaviour. " + "Do you want to continue anyway?" + ) click.confirm(warn_message, abort=True) _new_site(frappe.conf.db_name, site, mariadb_root_username=mariadb_root_username, @@ -146,22 +102,39 @@ def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_pas # Extract public and/or private files to the restored site, if user has given the path if with_public_files: - with_public_files = os.path.join(base_path, with_public_files) - public = extract_tar_files(site, with_public_files, 'public') + public = extract_files(site, with_public_files) os.remove(public) if with_private_files: - with_private_files = os.path.join(base_path, with_private_files) - private = extract_tar_files(site, with_private_files, 'private') + private = extract_files(site, with_private_files) os.remove(private) # Removing temporarily created file if decompressed_file_name != sql_file_path: os.remove(decompressed_file_name) - success_message = "Site {0} has been restored{1}".format(site, " with files" if (with_public_files or with_private_files) else "") + success_message = "Site {0} has been restored{1}".format( + site, + " with files" if (with_public_files or with_private_files) else "" + ) click.secho(success_message, fg="green") + +@click.command('partial-restore') +@click.argument('sql-file-path') +@click.option("--verbose", "-v", is_flag=True) +@pass_context +def partial_restore(context, sql_file_path, verbose): + from frappe.installer import partial_restore + verbose = context.verbose or verbose + + site = get_site(context) + frappe.init(site=site) + frappe.connect(site=site) + partial_restore(sql_file_path, verbose) + frappe.destroy() + + @click.command('reinstall') @click.option('--admin-password', help='Administrator Password for reinstalled site') @click.option('--mariadb-root-username', help='Root username for MariaDB') @@ -174,6 +147,8 @@ def reinstall(context, admin_password=None, mariadb_root_username=None, mariadb_ _reinstall(site, admin_password, mariadb_root_username, mariadb_root_password, yes, verbose=context.verbose) def _reinstall(site, admin_password=None, mariadb_root_username=None, mariadb_root_password=None, yes=False, verbose=False): + from frappe.installer import _new_site + if not yes: click.confirm('This will wipe your database. Are you sure you want to reinstall?', abort=True) try: @@ -226,15 +201,51 @@ def install_app(context, apps): sys.exit(exit_code) -@click.command('list-apps') +@click.command("list-apps") @pass_context def list_apps(context): "List apps in site" - site = get_site(context) - frappe.init(site=site) - frappe.connect() - print("\n".join(frappe.get_installed_apps())) - frappe.destroy() + + def fix_whitespaces(text): + if site == context.sites[-1]: + text = text.rstrip() + if len(context.sites) == 1: + text = text.lstrip() + return text + + for site in context.sites: + frappe.init(site=site) + frappe.connect() + site_title = ( + click.style(f"{site}", fg="green") if len(context.sites) > 1 else "" + ) + apps = frappe.get_single("Installed Applications").installed_applications + + if apps: + name_len, ver_len = [ + max([len(x.get(y)) for x in apps]) + for y in ["app_name", "app_version"] + ] + template = "{{0:{0}}} {{1:{1}}} {{2}}".format(name_len, ver_len) + + installed_applications = [ + template.format(app.app_name, app.app_version, app.git_branch) + for app in apps + ] + applications_summary = "\n".join(installed_applications) + summary = f"{site_title}\n{applications_summary}\n" + + else: + applications_summary = "\n".join(frappe.get_installed_apps()) + summary = f"{site_title}\n{applications_summary}\n" + + summary = fix_whitespaces(summary) + + if applications_summary and summary: + print(summary) + + frappe.destroy() + @click.command('add-system-manager') @click.argument('email') @@ -269,13 +280,13 @@ def disable_user(context, email): user.save(ignore_permissions=True) frappe.db.commit() - @click.command('migrate') @click.option('--skip-failing', is_flag=True, help="Skip patches that fail to run") @click.option('--skip-search-index', is_flag=True, help="Skip search indexing for web documents") @pass_context def migrate(context, skip_failing=False, skip_search_index=False): "Run patches, sync schema and rebuild files/translations" + import re from frappe.migrate import migrate for site in context.sites: @@ -293,9 +304,6 @@ def migrate(context, skip_failing=False, skip_search_index=False): if not context.sites: raise SiteNotSpecifiedError - print("Compiling Python files...") - compileall.compile_dir('../apps', quiet=1, rx=re.compile('.*node_modules.*')) - @click.command('migrate-to') @click.argument('frappe_provider') @pass_context @@ -312,15 +320,16 @@ def migrate_to(context, frappe_provider): @click.command('run-patch') @click.argument('module') +@click.option('--force', is_flag=True) @pass_context -def run_patch(context, module): +def run_patch(context, module, force): "Run a particular patch" import frappe.modules.patch_handler for site in context.sites: frappe.init(site=site) try: frappe.connect() - frappe.modules.patch_handler.run_single(module, force=context.force) + frappe.modules.patch_handler.run_single(module, force=force or context.force) finally: frappe.destroy() if not context.sites: @@ -385,35 +394,54 @@ def use(site, sites_path='.'): @click.command('backup') @click.option('--with-files', default=False, is_flag=True, help="Take backup with files") -@click.option('--verbose', default=False, is_flag=True) +@click.option('--include', '--only', '-i', default="", type=str, help="Specify the DocTypes to backup seperated by commas") +@click.option('--exclude', '-e', default="", type=str, help="Specify the DocTypes to not backup seperated by commas") +@click.option('--backup-path', default=None, help="Set path for saving all the files in this operation") +@click.option('--backup-path-db', default=None, help="Set path for saving database file") +@click.option('--backup-path-files', default=None, help="Set path for saving public file") +@click.option('--backup-path-private-files', default=None, help="Set path for saving private file") +@click.option('--backup-path-conf', default=None, help="Set path for saving config file") +@click.option('--ignore-backup-conf', default=False, is_flag=True, help="Ignore excludes/includes set in config") +@click.option('--verbose', default=False, is_flag=True, help="Add verbosity") +@click.option('--compress', default=False, is_flag=True, help="Compress private and public files") @pass_context -def backup(context, with_files=False, backup_path_db=None, backup_path_files=None, - backup_path_private_files=None, quiet=False, verbose=False): +def backup(context, with_files=False, backup_path=None, backup_path_db=None, backup_path_files=None, + backup_path_private_files=None, backup_path_conf=None, ignore_backup_conf=False, verbose=False, + compress=False, include="", exclude=""): "Backup" from frappe.utils.backups import scheduled_backup verbose = verbose or context.verbose exit_code = 0 + for site in context.sites: try: frappe.init(site=site) frappe.connect() - odb = scheduled_backup(ignore_files=not with_files, backup_path_db=backup_path_db, backup_path_files=backup_path_files, backup_path_private_files=backup_path_private_files, force=True, verbose=verbose) - except Exception as e: + odb = scheduled_backup( + ignore_files=not with_files, + backup_path=backup_path, + backup_path_db=backup_path_db, + backup_path_files=backup_path_files, + backup_path_private_files=backup_path_private_files, + backup_path_conf=backup_path_conf, + ignore_conf=ignore_backup_conf, + include_doctypes=include, + exclude_doctypes=exclude, + compress=compress, + verbose=verbose, + force=True + ) + except Exception: + click.secho("Backup failed for Site {0}. Database or site_config.json may be corrupted".format(site), fg="red") if verbose: - print("Backup failed for {0}. Database or site_config.json may be corrupted".format(site)) + print(frappe.get_traceback()) exit_code = 1 continue - if verbose: - from frappe.utils import now - summary_title = "Backup Summary at {0}".format(now()) - print(summary_title + "\n" + "-" * len(summary_title)) - print("Database backup:", odb.backup_path_db) - if with_files: - print("Public files: ", odb.backup_path_files) - print("Private files: ", odb.backup_path_private_files) - + odb.print_summary() + click.secho("Backup for Site {0} has been successfully completed{1}".format(site, " with files" if with_files else ""), fg="green") frappe.destroy() + if not context.sites: raise SiteNotSpecifiedError @@ -482,13 +510,14 @@ def _drop_site(site, root_login='root', root_password=None, archived_sites_path= if force: pass else: - click.echo("="*80) - click.echo("Error: The operation has stopped because backup of {s}'s database failed.".format(s=site)) - click.echo("Reason: {reason}{sep}".format(reason=str(err), sep="\n")) - click.echo("Fix the issue and try again.") - click.echo( - "Hint: Use 'bench drop-site {s} --force' to force the removal of {s}".format(sep="\n", tab="\t", s=site) - ) + messages = [ + "=" * 80, + "Error: The operation has stopped because backup of {0}'s database failed.".format(site), + "Reason: {0}\n".format(str(err)), + "Fix the issue and try again.", + "Hint: Use 'bench drop-site {0} --force' to force the removal of {0}".format(site) + ] + click.echo("\n".join(messages)) sys.exit(1) drop_user_and_database(frappe.conf.db_name, root_login, root_password) @@ -618,8 +647,10 @@ def browse(context, site): @click.command('start-recording') @pass_context def start_recording(context): + import frappe.recorder for site in context.sites: frappe.init(site=site) + frappe.set_user("Administrator") frappe.recorder.start() if not context.sites: raise SiteNotSpecifiedError @@ -628,8 +659,10 @@ def start_recording(context): @click.command('stop-recording') @pass_context def stop_recording(context): + import frappe.recorder for site in context.sites: frappe.init(site=site) + frappe.set_user("Administrator") frappe.recorder.stop() if not context.sites: raise SiteNotSpecifiedError @@ -700,5 +733,6 @@ commands = [ stop_recording, add_to_hosts, start_ngrok, - build_search_index + build_search_index, + partial_restore ] diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index 5a5986ff57..e9fa7217a8 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -460,11 +460,21 @@ def console(context): frappe.init(site=site) frappe.connect() frappe.local.lang = frappe.db.get_default("lang") + import IPython all_apps = frappe.get_installed_apps() + failed_to_import = [] + for app in all_apps: - locals()[app] = __import__(app) + try: + locals()[app] = __import__(app) + except ModuleNotFoundError: + failed_to_import.append(app) + print("Apps in this namespace:\n{}".format(", ".join(all_apps))) + if failed_to_import: + print("\nFailed to import:\n{}".format(", ".join(failed_to_import))) + IPython.embed(display_banner="", header="", colors="neutral") @@ -554,10 +564,25 @@ def run_ui_tests(context, app, headless=False): site_env = 'CYPRESS_baseUrl={}'.format(site_url) password_env = 'CYPRESS_adminPassword={}'.format(admin_password) if admin_password else '' + os.chdir(app_base_path) + + node_bin = subprocess.getoutput("npm bin") + cypress_path = "{0}/cypress".format(node_bin) + plugin_path = "{0}/cypress-file-upload".format(node_bin) + + # check if cypress in path...if not, install it. + if not (os.path.exists(cypress_path) or os.path.exists(plugin_path)) \ + or not subprocess.getoutput("npm view cypress version").startswith("6."): + # install cypress + click.secho("Installing Cypress...", fg="yellow") + frappe.commands.popen("yarn add cypress@^6 cypress-file-upload@^5 --no-lockfile") + # run for headless mode run_or_open = 'run --browser chrome --record --key 4a48f41c-11b3-425b-aa88-c58048fa69eb' if headless else 'open' - command = '{site_env} {password_env} yarn run cypress {run_or_open}' - formatted_command = command.format(site_env=site_env, password_env=password_env, run_or_open=run_or_open) + command = '{site_env} {password_env} {cypress} {run_or_open}' + formatted_command = command.format(site_env=site_env, password_env=password_env, cypress=cypress_path, run_or_open=run_or_open) + + click.secho("Running Cypress...", fg="yellow") frappe.commands.popen(formatted_command, cwd=app_base_path, raise_err=True) diff --git a/frappe/config/__init__.py b/frappe/config/__init__.py index b62a3bc258..cc9d0e6c67 100644 --- a/frappe/config/__init__.py +++ b/frappe/config/__init__.py @@ -108,4 +108,4 @@ def is_domain(module): return module.get("category") == "Domains" def is_module(module): - return module.get("type") == "module" + return module.get("type") == "module" \ No newline at end of file diff --git a/frappe/config/automation.py b/frappe/config/automation.py deleted file mode 100644 index 08de969729..0000000000 --- a/frappe/config/automation.py +++ /dev/null @@ -1,59 +0,0 @@ -from __future__ import unicode_literals -from frappe import _ - -def get_data(): - data = [ - { - "label": _("Automation"), - "icon": "fa fa-random", - "items": [ - { - "type": "doctype", - "name": "Assignment Rule", - "description": _("Set up rules for user assignments.") - }, - { - "type": "doctype", - "name": "Milestone", - "description": _("Tracks milestones on the lifecycle of a document if it undergoes multiple stages.") - }, - { - "type": "doctype", - "name": "Auto Repeat", - "description": _("Automatically generates recurring documents.") - }, - ] - }, - { - "label": _("Event Streaming"), - "icon": "fa fa-random", - "items": [ - { - "type": "doctype", - "name": "Event Producer", - "description": _("The site you want to subscribe to for consuming events.") - }, - { - "type": "doctype", - "name": "Event Consumer", - "description": _("The site which is consuming your events.") - }, - { - "type": "doctype", - "name": "Event Update Log", - "description": _("Maintains a Log of all inserts, updates and deletions on Event Producer site for documents that have consumers.") - }, - { - "type": "doctype", - "name": "Event Sync Log", - "description": _("Maintains a log of every event consumed along with the status of the sync and a Resync button in case sync fails.") - }, - { - "type": "doctype", - "name": "Document Type Mapping", - "description": _("The mapping configuration between two doctypes.") - } - ] - } - ] - return data diff --git a/frappe/config/core.py b/frappe/config/core.py deleted file mode 100644 index bdf39dfb3f..0000000000 --- a/frappe/config/core.py +++ /dev/null @@ -1,66 +0,0 @@ -from __future__ import unicode_literals -from frappe import _ - -def get_data(): - return [ - { - "label": _("Documents"), - "items": [ - { - "type": "doctype", - "name": "DocType", - "description": _("Models (building blocks) of the Application"), - }, - { - "type": "doctype", - "name": "Module Def", - "description": _("Groups of DocTypes"), - }, - { - "type": "doctype", - "name": "Page", - "description": _("Pages in Desk (place holders)"), - }, - { - "type": "doctype", - "name": "Report", - "description": _("Script or Query reports"), - }, - { - "type": "doctype", - "name": "Print Format", - "description": _("Customized Formats for Printing, Email"), - }, - { - "type": "doctype", - "name": "Custom Script", - "description": _("Client side script extensions in Javascript"), - } - ] - }, - { - "label": _("Logs"), - "items": [ - { - "type": "doctype", - "name": "Error Log", - "description": _("Errors in Background Events"), - }, - { - "type": "doctype", - "name": "Email Queue", - "description": _("Background Email Queue"), - }, - { - "type": "page", - "label": _("Background Jobs"), - "name": "background_jobs", - }, - { - "type": "doctype", - "name": "Error Snapshot", - "description": _("A log of request errors"), - }, - ] - } - ] diff --git a/frappe/config/customization.py b/frappe/config/customization.py deleted file mode 100644 index 3d587e6839..0000000000 --- a/frappe/config/customization.py +++ /dev/null @@ -1,66 +0,0 @@ -from __future__ import unicode_literals -from frappe import _ - -def get_data(): - return [ - { - "label": _("Form Customization"), - "icon": "fa fa-glass", - "items": [ - { - "type": "doctype", - "name": "Customize Form", - "description": _("Change field properties (hide, readonly, permission etc.)") - }, - { - "type": "doctype", - "name": "Custom Field", - "description": _("Add fields to forms.") - }, - { - "type": "doctype", - "name": "Custom Script", - "description": _("Add custom javascript to forms.") - }, - { - "type": "doctype", - "name": "DocType", - "description": _("Add custom forms.") - }, - ] - }, - { - "label": _("Dashboards"), - "items": [ - { - "type": "doctype", - "name": "Dashboard", - }, - { - "type": "doctype", - "name": "Dashboard Chart", - }, - { - "type": "doctype", - "name": "Dashboard Chart Source", - }, - ] - }, - { - "label": _("Other"), - "items": [ - { - "type": "doctype", - "label": _("Custom Translations"), - "name": "Translation", - "description": _("Add your own translations") - }, - { - "type": "doctype", - "label": _("Package"), - "name": "Package", - "description": _("Import and Export Packages.") - } - ] - } - ] diff --git a/frappe/config/desk.py b/frappe/config/desk.py deleted file mode 100644 index 40db97ef8c..0000000000 --- a/frappe/config/desk.py +++ /dev/null @@ -1,67 +0,0 @@ -from __future__ import unicode_literals -from frappe import _ - -def get_data(): - return [ - { - "label": _("Tools"), - "icon": "octicon octicon-briefcase", - "items": [ - { - "type": "doctype", - "name": "ToDo", - "label": _("To Do"), - "description": _("Documents assigned to you and by you."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Event", - "label": _("Calendar"), - "link": "List/Event/Calendar", - "description": _("Event and other calendars."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Note", - "description": _("Private and public Notes."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "File", - "label": _("Files"), - }, - { - "type": "page", - "label": _("Chat"), - "name": "chat", - "description": _("Chat messages and other notifications."), - "data_doctype": "Communication" - }, - { - "type": "page", - "label": _("Activity"), - "name": "activity", - "description": _("Activity log of all users."), - }, - ] - }, - { - 'label': _('Email'), - 'items': [ - { - "type": "doctype", - "name": "Newsletter", - "description": _("Newsletters to contacts, leads."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Email Group", - "description": _("Email Group List"), - }, - ] - } - ] diff --git a/frappe/config/desktop.py b/frappe/config/desktop.py deleted file mode 100644 index 568cc76afd..0000000000 --- a/frappe/config/desktop.py +++ /dev/null @@ -1,133 +0,0 @@ -from __future__ import unicode_literals -import frappe -from frappe import _ - -def get_data(): - return [ - # Administration - { - "module_name": "Desk", - "category": "Administration", - "label": _("Tools"), - "color": "#FFF5A7", - "reverse": 1, - "icon": "octicon octicon-calendar", - "type": "module", - "description": "Todos, notes, calendar and newsletter." - }, - { - "module_name": "Settings", - "category": "Administration", - "label": _("Settings"), - "color": "#bdc3c7", - "reverse": 1, - "icon": "octicon octicon-settings", - "type": "module", - "description": "Data import, printing, email and workflows." - }, - { - "module_name": "Automation", - "category": "Administration", - "label": _("Automation"), - "color": "#bdc3c7", - "reverse": 1, - "icon": "octicon octicon-gist", - "type": "module", - "description": "Auto Repeat, Assignment Rule, Milestone Tracking and Event Streaming." - }, - { - "module_name": "Users and Permissions", - "category": "Administration", - "label": _("Users and Permissions"), - "color": "#bdc3c7", - "reverse": 1, - "icon": "octicon octicon-settings", - "type": "module", - "description": "Setup roles and permissions for users on documents." - }, - { - "module_name": "Customization", - "category": "Administration", - "label": _("Customization"), - "color": "#bdc3c7", - "reverse": 1, - "icon": "octicon octicon-settings", - "type": "module", - "description": "Customize forms, custom fields, scripts and translations." - }, - { - "module_name": "Integrations", - "category": "Administration", - "label": _("Integrations"), - "color": "#16a085", - "icon": "octicon octicon-globe", - "type": "module", - "description": "DropBox, Woocomerce, AWS, Shopify and GoCardless." - }, - { - "module_name": 'Contacts', - "category": "Administration", - "label": _("Contacts"), - "type": 'module', - "icon": "octicon octicon-book", - "color": '#ffaedb', - "description": "People Contacts and Address Book." - }, - { - "module_name": "Core", - "category": "Administration", - "_label": _("Developer"), - "label": "Developer", - "color": "#589494", - "icon": "octicon octicon-circuit-board", - "type": "module", - "system_manager": 1, - "condition": getattr(frappe.local.conf, 'developer_mode', 0), - "description": "Doctypes, dev tools and logs." - }, - - # Places - { - "module_name": "Website", - "category": "Places", - "label": _("Website"), - "_label": _("Website"), - "color": "#16a085", - "icon": "octicon octicon-globe", - "type": "module", - "description": "Webpages, webforms, blogs and website theme." - }, - { - "module_name": 'Social', - "category": "Places", - "label": _('Social'), - "icon": "octicon octicon-heart", - "type": 'link', - "link": '#social/home', - "color": '#FF4136', - 'standard': 1, - 'idx': 15, - "description": "Build your profile and share posts with other users." - }, - { - "module_name": 'Leaderboard', - "category": "Places", - "label": _('Leaderboard'), - "icon": "fa fa-trophy", - "type": 'link', - "link": '#leaderboard/User', - "color": '#FF4136', - 'standard': 1, - }, - { - "module_name": 'dashboard', - "category": "Places", - "label": _('Dashboard'), - "icon": "octicon octicon-graph", - "type": "link", - "link": "#dashboard", - "color": '#FF4136', - 'standard': 1, - 'idx': 10 - }, - ] diff --git a/frappe/config/docs.py b/frappe/config/docs.py deleted file mode 100644 index eafaeeb19e..0000000000 --- a/frappe/config/docs.py +++ /dev/null @@ -1,6 +0,0 @@ -# -*- coding: utf-8 -*- - -from __future__ import unicode_literals - -source_link = "https://github.com/frappe/frappe_io" -docs_base_url = "/docs" diff --git a/frappe/config/integrations.py b/frappe/config/integrations.py deleted file mode 100644 index a7ac20065f..0000000000 --- a/frappe/config/integrations.py +++ /dev/null @@ -1,122 +0,0 @@ -from __future__ import unicode_literals -from frappe import _ - -def get_data(): - return [ - { - "label": _("Payments"), - "icon": "fa fa-star", - "items": [ - { - "type": "doctype", - "name": "Braintree Settings", - "description": _("Braintree payment gateway settings"), - }, - { - "type": "doctype", - "name": "PayPal Settings", - "description": _("PayPal payment gateway settings"), - }, - { - "type": "doctype", - "name": "Razorpay Settings", - "description": _("Razorpay Payment gateway settings"), - }, - { - "type": "doctype", - "name": "Stripe Settings", - "description": _("Stripe payment gateway settings"), - }, - { - "type": "doctype", - "name": "Paytm Settings", - "description": _("Paytm payment gateway settings"), - }, - ] - }, - { - "label": _("Backup"), - "items": [ - { - "type": "doctype", - "name": "Dropbox Settings", - "description": _("Dropbox backup settings"), - }, - { - "type": "doctype", - "name": "S3 Backup Settings", - "description": _("S3 Backup Settings"), - }, - { - "type": "doctype", - "name": "Google Drive", - "description": _("Google Drive Backup."), - } - ] - }, - { - "label": _("Authentication"), - "items": [ - { - "type": "doctype", - "name": "Social Login Key", - "description": _("Enter keys to enable login via Facebook, Google, GitHub."), - }, - { - "type": "doctype", - "name": "LDAP Settings", - "description": _("Ldap settings"), - }, - { - "type": "doctype", - "name": "OAuth Client", - "description": _("Register OAuth Client App"), - }, - { - "type": "doctype", - "name": "OAuth Provider Settings", - "description": _("Settings for OAuth Provider"), - }, - ] - }, - { - "label": _("Webhook"), - "items": [ - { - "type": "doctype", - "name": "Webhook", - "description": _("Webhooks calling API requests into web apps"), - }, - { - "type": "doctype", - "name": "Slack Webhook URL", - "description": _("Slack Webhooks for internal integration"), - }, - ] - }, - { - "label": _("Google Services"), - "items": [ - { - "type": "doctype", - "name": "Google Settings", - "description": _("Google API Settings."), - }, - { - "type": "doctype", - "name": "Google Contacts", - "description": _("Google Contacts Integration."), - }, - { - "type": "doctype", - "name": "Google Calendar", - "description": _("Google Calendar Integration."), - }, - { - "type": "doctype", - "name": "Google Drive", - "description": _("Google Drive Integration."), - } - ] - } - ] diff --git a/frappe/config/settings.py b/frappe/config/settings.py deleted file mode 100644 index e43abd9fcb..0000000000 --- a/frappe/config/settings.py +++ /dev/null @@ -1,190 +0,0 @@ -from __future__ import unicode_literals -import frappe -from frappe import _ -from frappe.desk.moduleview import add_setup_section - -def get_data(): - data = [ - { - "label": _("Core"), - "icon": "fa fa-wrench", - "items": [ - { - "type": "doctype", - "name": "System Settings", - "label": _("System Settings"), - "description": _("Language, Date and Time settings"), - "hide_count": True - }, - { - "type": "doctype", - "name": "Global Defaults", - "label": _("Global Defaults"), - "description": _("Company, Fiscal Year and Currency defaults"), - "hide_count": True - }, - { - "type": "doctype", - "name": "Error Log", - "description": _("Log of error on automated events (scheduler).") - }, - { - "type": "doctype", - "name": "Error Snapshot", - "description": _("Log of error during requests.") - }, - { - "type": "doctype", - "name": "Domain Settings", - "label": _("Domain Settings"), - "description": _("Enable / Disable Domains"), - "hide_count": True - }, - ] - }, - { - "label": _("Data"), - "icon": "fa fa-th", - "items": [ - { - "type": "doctype", - "name": "Data Import", - "label": _("Import Data"), - "icon": "octicon octicon-cloud-upload", - "description": _("Import Data from CSV / Excel files.") - }, - { - "type": "doctype", - "name": "Data Export", - "label": _("Export Data"), - "icon": "octicon octicon-cloud-upload", - "description": _("Export Data in CSV / Excel format.") - }, - { - "type": "doctype", - "name": "Naming Series", - "description": _("Set numbering series for transactions."), - "hide_count": True - }, - { - "type": "doctype", - "name": "Rename Tool", - "label": _("Bulk Rename"), - "description": _("Rename many items by uploading a .csv file."), - "hide_count": True - }, - { - "type": "doctype", - "name": "Bulk Update", - "label": _("Bulk Update"), - "description": _("Update many values at one time."), - "hide_count": True - }, - { - "type": "page", - "name": "backups", - "label": _("Download Backups"), - "description": _("List of backups available for download"), - "icon": "fa fa-download" - }, - { - "type": "doctype", - "name": "Deleted Document", - "label": _("Deleted Documents"), - "description": _("Restore or permanently delete a document.") - }, - ] - }, - { - "label": _("Email / Notifications"), - "icon": "fa fa-envelope", - "items": [ - { - "type": "doctype", - "name": "Email Account", - "description": _("Add / Manage Email Accounts.") - }, - { - "type": "doctype", - "name": "Email Domain", - "description": _("Add / Manage Email Domains.") - }, - { - "type": "doctype", - "name": "Notification", - "description": _("Setup Notifications based on various criteria.") - }, - { - "type": "doctype", - "name": "Email Template", - "description": _("Email Templates for common queries.") - }, - { - "type": "doctype", - "name": "Auto Email Report", - "description": _("Setup Reports to be emailed at regular intervals"), - }, - { - "type": "doctype", - "name": "Newsletter", - "description": _("Create and manage newsletter") - }, - { - "type": "doctype", - "route": "Form/Notification Settings/{}".format(frappe.session.user), - "name": "Notification Settings", - "description": _("Configure notifications for mentions, assignments, energy points and more.") - } - ] - }, - { - "label": _("Printing"), - "icon": "fa fa-print", - "items": [ - { - "type": "page", - "label": _("Print Format Builder"), - "name": "print-format-builder", - "description": _("Drag and Drop tool to build and customize Print Formats.") - }, - { - "type": "doctype", - "name": "Print Settings", - "description": _("Set default format, page size, print style etc.") - }, - { - "type": "doctype", - "name": "Print Format", - "description": _("Customized HTML Templates for printing transactions.") - }, - { - "type": "doctype", - "name": "Print Style", - "description": _("Stylesheets for Print Formats") - }, - ] - }, - { - "label": _("Workflow"), - "icon": "fa fa-random", - "items": [ - { - "type": "doctype", - "name": "Workflow", - "description": _("Define workflows for forms.") - }, - { - "type": "doctype", - "name": "Workflow State", - "description": _("States for workflow (e.g. Draft, Approved, Cancelled).") - }, - { - "type": "doctype", - "name": "Workflow Action", - "description": _("Actions for workflow (e.g. Approve, Cancel).") - }, - ] - } - ] - add_setup_section(data, "frappe", "website", _("Website"), "fa fa-globe") - return data diff --git a/frappe/config/tools.py b/frappe/config/tools.py deleted file mode 100644 index 3ab2a59222..0000000000 --- a/frappe/config/tools.py +++ /dev/null @@ -1,2 +0,0 @@ -from __future__ import unicode_literals -from frappe import _ \ No newline at end of file diff --git a/frappe/config/users_and_permissions.py b/frappe/config/users_and_permissions.py deleted file mode 100644 index d50235e4a3..0000000000 --- a/frappe/config/users_and_permissions.py +++ /dev/null @@ -1,85 +0,0 @@ -from __future__ import unicode_literals -from frappe import _ - -def get_data(): - return [ - { - "label": _("Users"), - "icon": "fa fa-group", - "items": [ - { - "type": "doctype", - "name": "User", - "description": _("System and Website Users") - }, - { - "type": "doctype", - "name": "Role", - "description": _("User Roles") - }, - { - "type": "doctype", - "name": "Role Profile", - "description": _("Role Profile") - } - ] - }, - { - "label": _("Permissions"), - "icon": "fa fa-lock", - "items": [ - { - "type": "page", - "name": "permission-manager", - "label": _("Role Permissions Manager"), - "icon": "fa fa-lock", - "description": _("Set Permissions on Document Types and Roles") - }, - { - "type": "doctype", - "name": "User Permission", - "label": _("User Permissions"), - "icon": "fa fa-lock", - "description": _("Restrict user for specific document") - }, - { - "type": "doctype", - "name": "Role Permission for Page and Report", - "description": _("Set custom roles for page and report") - }, - { - "type": "report", - "is_query_report": True, - "doctype": "User", - "icon": "fa fa-eye-open", - "name": "Permitted Documents For User", - "description": _("Check which Documents are readable by a User") - }, - { - "type": "report", - "doctype": "DocShare", - "icon": "fa fa-share", - "name": "Document Share Report", - "description": _("Report of all document shares") - } - ] - }, - { - "label": _("Logs"), - "icon": "fa fa-group", - "items": [ - { - "type": "doctype", - "name": "Activity Log", - "label": _("Activity Log"), - "description": _("Activity Log by ") - }, - { - "type": "doctype", - "name": "Access Log", - "label": _("Access Log"), - "description": _("View Log of all print, download and export events") - } - ] - } - ] \ No newline at end of file diff --git a/frappe/config/website.py b/frappe/config/website.py deleted file mode 100644 index e7eb218889..0000000000 --- a/frappe/config/website.py +++ /dev/null @@ -1,117 +0,0 @@ -from __future__ import unicode_literals -from frappe import _ - -def get_data(): - return [ - { - "label": _("Web Site"), - "icon": "fa fa-star", - "items": [ - { - "type": "doctype", - "name": "Web Page", - "description": _("Content web page."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Web Form", - "description": _("User editable form on Website."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Website Sidebar", - }, - { - "type": "doctype", - "name": "Website Slideshow", - "description": _("Embed image slideshows in website pages."), - }, - { - "type": "doctype", - "name": "Website Route Meta", - "description": _("Add meta tags to your web pages"), - }, - ] - }, - { - "label": _("Blog"), - "items": [ - { - "type": "doctype", - "name": "Blog Post", - "description": _("Single Post (article)."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Blogger", - "description": _("A user who posts blogs."), - }, - { - "type": "doctype", - "name": "Blog Category", - "description": _("Categorize blog posts."), - }, - ] - }, - { - "label": _("Setup"), - "icon": "fa fa-cog", - "items": [ - { - "type": "doctype", - "name": "Website Settings", - "description": _("Setup of top navigation bar, footer and logo."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Website Theme", - "description": _("List of themes for Website."), - "onboard": 1, - }, - { - "type": "doctype", - "name": "Website Script", - "description": _("Javascript to append to the head section of the page."), - }, - { - "type": "doctype", - "name": "About Us Settings", - "description": _("Settings for About Us Page."), - }, - { - "type": "doctype", - "name": "Contact Us Settings", - "description": _("Settings for Contact Us Page."), - }, - ] - }, - { - "label": _("Portal"), - "items": [ - { - "type": "doctype", - "name": "Portal Settings", - "label": _("Portal Settings"), - "onboard": 1, - } - ] - }, - { - "label": _("Knowledge Base"), - "items": [ - { - "type": "doctype", - "name": "Help Category", - }, - { - "type": "doctype", - "name": "Help Article", - }, - ] - }, - - ] diff --git a/frappe/contacts/doctype/address/address.json b/frappe/contacts/doctype/address/address.json index 2f634074d2..e85a89ff1a 100644 --- a/frappe/contacts/doctype/address/address.json +++ b/frappe/contacts/doctype/address/address.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_import": 1, "allow_rename": 1, "creation": "2013-01-10 16:34:32", @@ -24,7 +25,6 @@ "is_shipping_address", "disabled", "linked_with", - "is_your_company_address", "links" ], "fields": [ @@ -75,7 +75,7 @@ { "fieldname": "state", "fieldtype": "Data", - "label": "State" + "label": "State/Province" }, { "fieldname": "country", @@ -138,12 +138,6 @@ "label": "Reference", "options": "fa fa-pushpin" }, - { - "default": "0", - "fieldname": "is_your_company_address", - "fieldtype": "Check", - "label": "Is Your Company Address" - }, { "fieldname": "links", "fieldtype": "Table", @@ -153,7 +147,8 @@ ], "icon": "fa fa-map-marker", "idx": 5, - "modified": "2019-09-08 11:41:04.145589", + "links": [], + "modified": "2020-10-21 16:14:37.284830", "modified_by": "Administrator", "module": "Contacts", "name": "Address", diff --git a/frappe/contacts/doctype/address/address.py b/frappe/contacts/doctype/address/address.py index e82ab9b26e..84b925d50e 100644 --- a/frappe/contacts/doctype/address/address.py +++ b/frappe/contacts/doctype/address/address.py @@ -39,14 +39,13 @@ class Address(Document): def validate(self): self.link_address() - self.validate_reference() self.validate_preferred_address() set_link_title(self) deduplicate_dynamic_links(self) def link_address(self): """Link address based on owner""" - if not self.links and not self.is_your_company_address: + if not self.links: contact_name = frappe.db.get_value("Contact", {"email_id": self.owner}) if contact_name: contact = frappe.get_cached_doc('Contact', contact_name) @@ -56,12 +55,6 @@ class Address(Document): return False - def validate_reference(self): - if self.is_your_company_address: - if not [row for row in self.links if row.link_doctype == "Company"]: - frappe.throw(_("Address needs to be linked to a Company. Please add a row for Company in the Links table below."), - title =_("Company not Linked")) - def validate_preferred_address(self): preferred_fields = ['is_primary_address', 'is_shipping_address'] @@ -204,25 +197,6 @@ def get_address_templates(address): else: return result -@frappe.whitelist() -def get_shipping_address(company, address = None): - filters = [ - ["Dynamic Link", "link_doctype", "=", "Company"], - ["Dynamic Link", "link_name", "=", company], - ["Address", "is_your_company_address", "=", 1] - ] - fields = ["*"] - if address and frappe.db.get_value('Dynamic Link', - {'parent': address, 'link_name': company}): - filters.append(["Address", "name", "=", address]) - - address = frappe.get_all("Address", filters=filters, fields=fields) or {} - - if address: - address_as_dict = address[0] - name, address_template = get_address_templates(address_as_dict) - return address_as_dict.get("name"), frappe.render_template(address_template, address_as_dict) - def get_company_address(company): ret = frappe._dict() ret.company_address = get_default_address('Company', company) diff --git a/frappe/contacts/doctype/contact/contact.json b/frappe/contacts/doctype/contact/contact.json index 2e2fb6df67..696cd61d6c 100644 --- a/frappe/contacts/doctype/contact/contact.json +++ b/frappe/contacts/doctype/contact/contact.json @@ -34,8 +34,8 @@ "email_ids", "phone_nos", "contact_details", - "is_primary_contact", "links", + "is_primary_contact", "more_info", "department", "unsubscribed" @@ -248,8 +248,9 @@ "icon": "fa fa-user", "idx": 1, "image_field": "image", + "index_web_pages_for_search": 1, "links": [], - "modified": "2020-04-06 18:25:28.223693", + "modified": "2020-08-27 14:12:09.906719", "modified_by": "Administrator", "module": "Contacts", "name": "Contact", diff --git a/frappe/contacts/doctype/contact/contact.py b/frappe/contacts/doctype/contact/contact.py index 987ba7d3d6..42fa039f74 100644 --- a/frappe/contacts/doctype/contact/contact.py +++ b/frappe/contacts/doctype/contact/contact.py @@ -256,3 +256,27 @@ def get_contact_with_phone_number(number): def get_contact_name(email_id): contact = frappe.get_list("Contact Email", filters={"email_id": email_id}, fields=["parent"], limit=1) return contact[0].parent if contact else None + +def get_contacts_linking_to(doctype, docname, fields=None): + """Return a list of contacts containing a link to the given document.""" + return frappe.get_list('Contact', fields=fields, filters=[ + ['Dynamic Link', 'link_doctype', '=', doctype], + ['Dynamic Link', 'link_name', '=', docname] + ]) + +def get_contacts_linked_from(doctype, docname, fields=None): + """Return a list of contacts that are contained in (linked from) the given document.""" + link_fields = frappe.get_meta(doctype).get('fields', { + 'fieldtype': 'Link', + 'options': 'Contact' + }) + if not link_fields: + return [] + + contact_names = frappe.get_value(doctype, docname, fieldname=[f.fieldname for f in link_fields]) + if not contact_names: + return [] + + return frappe.get_list('Contact', fields=fields, filters={ + 'name': ('in', contact_names) + }) diff --git a/frappe/core/desk_page/settings/settings.json b/frappe/core/desk_page/settings/settings.json deleted file mode 100644 index 642a4fdadd..0000000000 --- a/frappe/core/desk_page/settings/settings.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "cards": [ - { - "hidden": 0, - "label": "Data", - "links": "[\n {\n \"description\": \"Import Data from CSV / Excel files.\",\n \"icon\": \"octicon octicon-cloud-upload\",\n \"label\": \"Import Data\",\n \"name\": \"Data Import\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Export Data in CSV / Excel format.\",\n \"icon\": \"octicon octicon-cloud-upload\",\n \"label\": \"Export Data\",\n \"name\": \"Data Export\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Update many values at one time.\",\n \"hide_count\": true,\n \"label\": \"Bulk Update\",\n \"name\": \"Bulk Update\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"List of backups available for download\",\n \"icon\": \"fa fa-download\",\n \"label\": \"Download Backups\",\n \"name\": \"backups\",\n \"type\": \"page\"\n },\n {\n \"description\": \"Restore or permanently delete a document.\",\n \"label\": \"Deleted Documents\",\n \"name\": \"Deleted Document\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Email / Notifications", - "links": "[\n {\n \"description\": \"Add / Manage Email Accounts.\",\n \"label\": \"Email Account\",\n \"name\": \"Email Account\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Add / Manage Email Domains.\",\n \"label\": \"Email Domain\",\n \"name\": \"Email Domain\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Setup Notifications based on various criteria.\",\n \"label\": \"Notification\",\n \"name\": \"Notification\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Email Templates for common queries.\",\n \"label\": \"Email Template\",\n \"name\": \"Email Template\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Setup Reports to be emailed at regular intervals\",\n \"label\": \"Auto Email Report\",\n \"name\": \"Auto Email Report\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Create and manage newsletter\",\n \"label\": \"Newsletter\",\n \"name\": \"Newsletter\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Configure notifications for mentions, assignments, energy points and more.\",\n \"label\": \"Notification Settings\",\n \"name\": \"Notification Settings\",\n \"route\": \"Form/Notification Settings/Administrator\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Website", - "links": "[\n {\n \"description\": \"Setup of top navigation bar, footer and logo.\",\n \"label\": \"Website Settings\",\n \"name\": \"Website Settings\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"List of themes for Website.\",\n \"label\": \"Website Theme\",\n \"name\": \"Website Theme\",\n \"onboard\": 1,\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Javascript to append to the head section of the page.\",\n \"label\": \"Website Script\",\n \"name\": \"Website Script\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Settings for About Us Page.\",\n \"label\": \"About Us Settings\",\n \"name\": \"About Us Settings\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Settings for Contact Us Page.\",\n \"label\": \"Contact Us Settings\",\n \"name\": \"Contact Us Settings\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Core", - "links": "[\n {\n \"description\": \"Language, Date and Time settings\",\n \"hide_count\": true,\n \"label\": \"System Settings\",\n \"name\": \"System Settings\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Company, Fiscal Year and Currency defaults\",\n \"hide_count\": true,\n \"label\": \"Global Defaults\",\n \"name\": \"Global Defaults\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Log of error on automated events (scheduler).\",\n \"label\": \"Error Log\",\n \"name\": \"Error Log\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Log of error during requests.\",\n \"label\": \"Error Snapshot\",\n \"name\": \"Error Snapshot\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Enable / Disable Domains\",\n \"hide_count\": true,\n \"label\": \"Domain Settings\",\n \"name\": \"Domain Settings\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Printing", - "links": "[\n {\n \"description\": \"Drag and Drop tool to build and customize Print Formats.\",\n \"label\": \"Print Format Builder\",\n \"name\": \"print-format-builder\",\n \"type\": \"page\"\n },\n {\n \"description\": \"Set default format, page size, print style etc.\",\n \"label\": \"Print Settings\",\n \"name\": \"Print Settings\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Customized HTML Templates for printing transactions.\",\n \"label\": \"Print Format\",\n \"name\": \"Print Format\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Stylesheets for Print Formats\",\n \"label\": \"Print Style\",\n \"name\": \"Print Style\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Workflow", - "links": "[\n {\n \"description\": \"Define workflows for forms.\",\n \"label\": \"Workflow\",\n \"name\": \"Workflow\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"States for workflow (e.g. Draft, Approved, Cancelled).\",\n \"label\": \"Workflow State\",\n \"name\": \"Workflow State\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Actions for workflow (e.g. Approve, Cancel).\",\n \"label\": \"Workflow Action\",\n \"name\": \"Workflow Action\",\n \"type\": \"doctype\"\n }\n]" - } - ], - "category": "Modules", - "charts": [], - "creation": "2020-03-02 15:09:40.527211", - "developer_mode_only": 0, - "disable_user_customization": 1, - "docstatus": 0, - "doctype": "Desk Page", - "extends_another_page": 0, - "hide_custom": 0, - "idx": 0, - "is_standard": 1, - "label": "Settings", - "modified": "2020-07-14 10:09:09.520557", - "modified_by": "Administrator", - "module": "Core", - "name": "Settings", - "owner": "Administrator", - "pin_to_bottom": 1, - "pin_to_top": 0, - "shortcuts": [ - { - "icon": "octicon octicon-settings", - "label": "System Settings", - "link_to": "System Settings", - "type": "DocType" - }, - { - "icon": "fa fa-print", - "label": "Print Settings", - "link_to": "Print Settings", - "type": "DocType" - }, - { - "icon": "fa fa-globe", - "label": "Website Settings", - "link_to": "Website Settings", - "type": "DocType" - } - ], - "shortcuts_label": "Settings" -} \ No newline at end of file diff --git a/frappe/core/desk_page/users/users.json b/frappe/core/desk_page/users/users.json deleted file mode 100644 index f1f43a4ef0..0000000000 --- a/frappe/core/desk_page/users/users.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "cards": [ - { - "hidden": 0, - "label": "Users", - "links": "[\n {\n \"description\": \"System and Website Users\",\n \"label\": \"User\",\n \"name\": \"User\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"User Roles\",\n \"label\": \"Role\",\n \"name\": \"Role\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Role Profile\",\n \"label\": \"Role Profile\",\n \"name\": \"Role Profile\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Logs", - "links": "[\n {\n \"description\": \"Activity Log by \",\n \"label\": \"Activity Log\",\n \"name\": \"Activity Log\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"View Log of all print, download and export events\",\n \"label\": \"Access Log\",\n \"name\": \"Access Log\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Permissions", - "links": "[\n {\n \"description\": \"Set Permissions on Document Types and Roles\",\n \"icon\": \"fa fa-lock\",\n \"label\": \"Role Permissions Manager\",\n \"name\": \"permission-manager\",\n \"type\": \"page\"\n },\n {\n \"description\": \"Restrict user for specific document\",\n \"icon\": \"fa fa-lock\",\n \"label\": \"User Permissions\",\n \"name\": \"User Permission\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Set custom roles for page and report\",\n \"label\": \"Role Permission for Page and Report\",\n \"name\": \"Role Permission for Page and Report\",\n \"type\": \"doctype\"\n },\n {\n \"dependencies\": [\n \"User\"\n ],\n \"description\": \"Check which Documents are readable by a User\",\n \"doctype\": \"User\",\n \"icon\": \"fa fa-eye-open\",\n \"is_query_report\": true,\n \"label\": \"Permitted Documents For User\",\n \"name\": \"Permitted Documents For User\",\n \"type\": \"report\"\n },\n {\n \"dependencies\": [\n \"DocShare\"\n ],\n \"description\": \"Report of all document shares\",\n \"doctype\": \"DocShare\",\n \"icon\": \"fa fa-share\",\n \"label\": \"Document Share Report\",\n \"name\": \"Document Share Report\",\n \"type\": \"report\"\n }\n]" - } - ], - "category": "Administration", - "charts": [], - "creation": "2020-03-02 15:12:16.754449", - "developer_mode_only": 0, - "disable_user_customization": 0, - "docstatus": 0, - "doctype": "Desk Page", - "extends_another_page": 0, - "idx": 0, - "is_standard": 1, - "label": "Users", - "modified": "2020-04-26 22:36:14.311554", - "modified_by": "Administrator", - "module": "Core", - "name": "Users", - "owner": "Administrator", - "pin_to_bottom": 0, - "pin_to_top": 0, - "shortcuts": [ - { - "label": "User", - "link_to": "User", - "type": "DocType" - }, - { - "label": "Role", - "link_to": "Role", - "type": "DocType" - }, - { - "label": "Permission Manager", - "link_to": "permission-manager", - "type": "Page" - }, - { - "label": "User Profile", - "link_to": "user-profile", - "type": "Page" - } - ] -} \ No newline at end of file diff --git a/frappe/core/doctype/activity_log/activity_log.py b/frappe/core/doctype/activity_log/activity_log.py index 27a2892ca8..98dc91806d 100644 --- a/frappe/core/doctype/activity_log/activity_log.py +++ b/frappe/core/doctype/activity_log/activity_log.py @@ -40,7 +40,11 @@ def add_authentication_log(subject, user, operation="Login", status="Success"): "operation": operation, }).insert(ignore_permissions=True, ignore_links=True) -def clear_authentication_logs(): - """clear 100 day old authentication logs""" +def clear_activity_logs(days=None): + """clear 90 day old authentication logs or configured in log settings""" + + if not days: + days = 90 + frappe.db.sql("""delete from `tabActivity Log` where \ - creation< (NOW() - INTERVAL '100' DAY)""") \ No newline at end of file + creation< (NOW() - INTERVAL '{0}' DAY)""".format(days)) \ No newline at end of file diff --git a/frappe/core/doctype/activity_log/test_activity_log.py b/frappe/core/doctype/activity_log/test_activity_log.py index 4dbfd6700e..bd0ea08cc7 100644 --- a/frappe/core/doctype/activity_log/test_activity_log.py +++ b/frappe/core/doctype/activity_log/test_activity_log.py @@ -77,6 +77,10 @@ class TestActivityLog(unittest.TestCase): self.assertRaises(frappe.AuthenticationError, LoginManager) self.assertRaises(frappe.AuthenticationError, LoginManager) self.assertRaises(frappe.AuthenticationError, LoginManager) + + # REMOVE ME: current logic allows allow_consecutive_login_attempts+1 attempts + # before raising security exception, remove below line when that is fixed. + self.assertRaises(frappe.AuthenticationError, LoginManager) self.assertRaises(frappe.SecurityException, LoginManager) time.sleep(5) self.assertRaises(frappe.AuthenticationError, LoginManager) diff --git a/frappe/core/doctype/comment/comment.py b/frappe/core/doctype/comment/comment.py index a2105c1511..e4fd181733 100644 --- a/frappe/core/doctype/comment/comment.py +++ b/frappe/core/doctype/comment/comment.py @@ -18,10 +18,7 @@ from frappe.exceptions import ImplicitCommitError class Comment(Document): def after_insert(self): self.notify_mentions() - - frappe.publish_realtime('new_communication', self.as_dict(), - doctype=self.reference_doctype, docname=self.reference_name, - after_commit=True) + self.notify_change('add') def validate(self): if not self.comment_email: @@ -30,12 +27,30 @@ class Comment(Document): def on_update(self): update_comment_in_doc(self) + if self.is_new(): + self.notify_change('update') def on_trash(self): self.remove_comment_from_cache() - frappe.publish_realtime('delete_communication', self.as_dict(), - doctype= self.reference_doctype, docname = self.reference_name, - after_commit=True) + self.notify_change('delete') + + def notify_change(self, action): + key_map = { + 'Like': 'like_logs', + 'Assigned': 'assignment_logs', + 'Assignment Completed': 'assignment_logs', + 'Comment': 'comments', + 'Attachment': 'attachment_logs', + 'Attachment Removed': 'attachment_logs', + } + key = key_map.get(self.comment_type) + if not key: return + + frappe.publish_realtime('update_docinfo_for_{}_{}'.format(self.reference_doctype, self.reference_name), { + 'doc': self.as_dict(), + 'key': key, + 'action': action + }, after_commit=True) def remove_comment_from_cache(self): _comments = get_comments_from_parent(self) @@ -150,7 +165,7 @@ def update_comments_in_parent(reference_doctype, reference_name, _comments): try: # use sql, so that we do not mess with the timestamp frappe.db.sql("""update `tab{0}` set `_comments`=%s where name=%s""".format(reference_doctype), # nosec - (json.dumps(_comments[-50:]), reference_name)) + (json.dumps(_comments[-100:]), reference_name)) except Exception as e: if frappe.db.is_column_missing(e) and getattr(frappe.local, 'request', None): diff --git a/frappe/core/doctype/communication/communication.js b/frappe/core/doctype/communication/communication.js index cdbdf97852..07674d16ae 100644 --- a/frappe/core/doctype/communication/communication.js +++ b/frappe/core/doctype/communication/communication.js @@ -99,8 +99,7 @@ frappe.ui.form.on("Communication", { } }, - show_relink_dialog: function(frm){ - var lib = "frappe.email"; + show_relink_dialog: function(frm) { var d = new frappe.ui.Dialog ({ title: __("Relink Communication"), fields: [{ @@ -138,8 +137,10 @@ frappe.ui.form.on("Communication", { } }); }, - function () { - frappe.show_alert('Document not Relinked') + function() { + frappe.show_alert({ + message: __('Document not Relinked'), 'indicator': 'info' + }); } ); } diff --git a/frappe/core/doctype/communication/communication.py b/frappe/core/doctype/communication/communication.py index fc929351d4..5ebf714645 100644 --- a/frappe/core/doctype/communication/communication.py +++ b/frappe/core/doctype/communication/communication.py @@ -99,10 +99,7 @@ class Communication(Document): frappe.db.set_value("Communication", self.reference_name, "status", "Replied") if self.communication_type == "Communication": - # send new comment to listening clients - frappe.publish_realtime('new_communication', self.as_dict(), - doctype=self.reference_doctype, docname=self.reference_name, - after_commit=True) + self.notify_change('add') elif self.communication_type in ("Chat", "Notification", "Bot"): if self.reference_name == frappe.session.user: @@ -125,10 +122,14 @@ class Communication(Document): def on_trash(self): if self.communication_type == "Communication": - # send delete comment to listening clients - frappe.publish_realtime('delete_communication', self.as_dict(), - doctype= self.reference_doctype, docname = self.reference_name, - after_commit=True) + self.notify_change('delete') + + def notify_change(self, action): + frappe.publish_realtime('update_docinfo_for_{}_{}'.format(self.reference_doctype, self.reference_name), { + 'doc': self.as_dict(), + 'key': 'communications', + 'action': action + }, after_commit=True) def set_status(self): if not self.is_new(): @@ -244,9 +245,7 @@ class Communication(Document): if delivery_status: self.db_set('delivery_status', delivery_status) - - frappe.publish_realtime('update_communication', self.as_dict(), - doctype=self.reference_doctype, docname=self.reference_name, after_commit=True) + self.notify_change('update') # for list views and forms self.notify_update() @@ -260,10 +259,8 @@ class Communication(Document): # Timeline Links def set_timeline_links(self): contacts = [] - if (self.email_account and frappe.db.get_value("Email Account", self.email_account, "create_contact")) or \ - frappe.flags.in_test: - - contacts = get_contacts([self.sender, self.recipients, self.cc, self.bcc]) + create_contact_enabled = self.email_account and frappe.db.get_value("Email Account", self.email_account, "create_contact") + contacts = get_contacts([self.sender, self.recipients, self.cc, self.bcc], auto_create_contact=create_contact_enabled) for contact_name in contacts: self.add_link('Contact', contact_name) @@ -342,7 +339,7 @@ def get_permission_query_conditions_for_communication(user): return """`tabCommunication`.email_account in ({email_accounts})"""\ .format(email_accounts=','.join(email_accounts)) -def get_contacts(email_strings): +def get_contacts(email_strings, auto_create_contact=False): email_addrs = [] for email_string in email_strings: @@ -357,7 +354,7 @@ def get_contacts(email_strings): email = get_email_without_link(email) contact_name = get_contact_name(email) - if not contact_name and email: + if not contact_name and email and auto_create_contact: email_parts = email.split("@") first_name = frappe.unscrub(email_parts[0]) diff --git a/frappe/core/doctype/custom_docperm/custom_docperm.json b/frappe/core/doctype/custom_docperm/custom_docperm.json index f8f7f58be1..93f5431903 100644 --- a/frappe/core/doctype/custom_docperm/custom_docperm.json +++ b/frappe/core/doctype/custom_docperm/custom_docperm.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_import": 1, "autoname": "hash", "creation": "2017-01-11 04:21:35.217943", @@ -13,6 +14,7 @@ "column_break_2", "permlevel", "section_break_4", + "select", "read", "write", "create", @@ -211,9 +213,16 @@ "fieldtype": "Data", "label": "Reference Document Type", "read_only": 1 + }, + { + "default": "0", + "fieldname": "select", + "fieldtype": "Check", + "label": "Select" } ], - "modified": "2019-10-31 16:58:16.157079", + "links": [], + "modified": "2020-12-03 15:20:48.296730", "modified_by": "Administrator", "module": "Core", "name": "Custom DocPerm", diff --git a/frappe/core/doctype/data_import/fixtures/sample_import_file_for_update.csv b/frappe/core/doctype/data_import/fixtures/sample_import_file_for_update.csv index 656985b519..e48208ea72 100644 --- a/frappe/core/doctype/data_import/fixtures/sample_import_file_for_update.csv +++ b/frappe/core/doctype/data_import/fixtures/sample_import_file_for_update.csv @@ -1,2 +1,2 @@ -Title ,Description ,Number ,another_number ,ID (Table Field 1) ,Child Title (Table Field 1) ,Child Description (Table Field 1) ,Child 2 Title (Table Field 2) ,Child 2 Date (Table Field 2) ,Child 2 Number (Table Field 2) ,Child Title (Table Field 1 Again) ,Child Date (Table Field 1 Again) ,Child Number (Table Field 1 Again) ,table_field_1_again.child_another_number -Test 26 ,test description ,1 ,2 ,"" ,child title ,child description ,child title ,14-08-2019 ,4 ,child title again ,22-09-2020 ,5 , 7 +Title,Description,Number,another_number,ID (Table Field 1),Child Title (Table Field 1),Child Description (Table Field 1),Child 2 Title (Table Field 2),Child 2 Date (Table Field 2),Child 2 Number (Table Field 2),Child Title (Table Field 1 Again),Child Date (Table Field 1 Again),Child Number (Table Field 1 Again),table_field_1_again.child_another_number +Test 26,test description,1,2,"",child title,child description,child title,14-08-2019,4,child title again,22-09-2020,5,7 diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py index 5271690527..dde3dfaee9 100644 --- a/frappe/core/doctype/data_import/importer.py +++ b/frappe/core/doctype/data_import/importer.py @@ -616,7 +616,9 @@ class Row: id_field = get_id_field(doctype) id_value = doc.get(id_field.fieldname) if id_value and frappe.db.exists(doctype, id_value): - doc = frappe.get_doc(doctype, id_value) + existing_doc = frappe.get_doc(doctype, id_value) + existing_doc.update(doc) + doc = existing_doc else: # for table rows being inserted in update # create a new doc with defaults set @@ -749,7 +751,7 @@ class Row: self.warnings.append( { "row": self.row_number, - "message": _("{0} is a mandatory field asdadsf").format(id_field.label), + "message": _("{0} is a mandatory field").format(id_field.label), } ) return diff --git a/frappe/core/doctype/data_import/test_importer.py b/frappe/core/doctype/data_import/test_importer.py index 249451fd4d..b083b9eaaa 100644 --- a/frappe/core/doctype/data_import/test_importer.py +++ b/frappe/core/doctype/data_import/test_importer.py @@ -5,12 +5,14 @@ from __future__ import unicode_literals import unittest import frappe +from frappe.core.doctype.data_import.importer import Importer from frappe.utils import getdate, format_duration doctype_name = 'DocType for Import' class TestImporter(unittest.TestCase): - def setUp(self): + @classmethod + def setUpClass(cls): create_doctype_if_not_exists(doctype_name) def test_data_import_from_file(self): @@ -71,19 +73,28 @@ class TestImporter(unittest.TestCase): self.assertEqual(warnings[2]['message'], "Title is a mandatory field") def test_data_import_update(self): - if not frappe.db.exists(doctype_name, 'Test 26'): - frappe.get_doc( - doctype=doctype_name, - title='Test 26' - ).insert() + existing_doc = frappe.get_doc( + doctype=doctype_name, + title=frappe.generate_hash(doctype_name, 8), + table_field_1=[{'child_title': 'child title to update'}] + ) + existing_doc.save() + frappe.db.commit() import_file = get_import_file('sample_import_file_for_update') data_import = self.get_importer(doctype_name, import_file, update=True) - data_import.start_import() + i = Importer(data_import.reference_doctype, data_import=data_import) - updated_doc = frappe.get_doc(doctype_name, 'Test 26') + # update child table id in template date + i.import_file.raw_data[1][4] = existing_doc.table_field_1[0].name + i.import_file.raw_data[1][0] = existing_doc.name + i.import_file.parse_data_from_template() + i.import_data() + + updated_doc = frappe.get_doc(doctype_name, existing_doc.name) self.assertEqual(updated_doc.description, 'test description') self.assertEqual(updated_doc.table_field_1[0].child_title, 'child title') + self.assertEqual(updated_doc.table_field_1[0].name, existing_doc.table_field_1[0].name) self.assertEqual(updated_doc.table_field_1[0].child_description, 'child description') self.assertEqual(updated_doc.table_field_1_again[0].child_title, 'child title again') diff --git a/frappe/core/doctype/data_import_legacy/data_import_legacy.js b/frappe/core/doctype/data_import_legacy/data_import_legacy.js index 9a301af76e..8e4f397171 100644 --- a/frappe/core/doctype/data_import_legacy/data_import_legacy.js +++ b/frappe/core/doctype/data_import_legacy/data_import_legacy.js @@ -32,7 +32,7 @@ frappe.ui.form.on('Data Import Legacy', { frm.reload_doc(); } if (data.progress) { - let progress_bar = $(frm.dashboard.progress_area).find(".progress-bar"); + let progress_bar = $(frm.dashboard.progress_area.body).find(".progress-bar"); if (progress_bar) { $(progress_bar).removeClass("progress-bar-danger").addClass("progress-bar-success progress-bar-striped"); $(progress_bar).css("width", data.progress + "%"); diff --git a/frappe/core/doctype/deleted_document/deleted_document_list.js b/frappe/core/doctype/deleted_document/deleted_document_list.js index f5e1147dfb..92413bfdf4 100644 --- a/frappe/core/doctype/deleted_document/deleted_document_list.js +++ b/frappe/core/doctype/deleted_document/deleted_document_list.js @@ -9,15 +9,16 @@ frappe.listview_settings["Deleted Document"] = { args: { docnames }, callback: function (r) { if (r.message) { - function body(docnames) { + let body = (docnames) => { const html = docnames.map(docname => { - return `
  • ${docname}
  • `; + return `
  • ${docname}
  • `; }); return "
      " + html.join(""); - } - function message(title, docnames) { + }; + + let message = (title, docnames) => { return (docnames.length > 0) ? title + body(docnames) + "
    ": ""; - } + }; const { restored, invalid, failed } = r.message; const restored_summary = message(__("Documents restored successfully"), restored); diff --git a/frappe/core/doctype/docfield/docfield.json b/frappe/core/doctype/docfield/docfield.json index e420d3b775..ca134665b8 100644 --- a/frappe/core/doctype/docfield/docfield.json +++ b/frappe/core/doctype/docfield/docfield.json @@ -13,6 +13,7 @@ "fieldname", "precision", "length", + "non_negative", "hide_days", "hide_seconds", "reqd", @@ -473,13 +474,20 @@ "fieldname": "hide_border", "fieldtype": "Check", "label": "Hide Border" + }, + { + "default": "0", + "depends_on": "eval:in_list([\"Int\", \"Float\", \"Currency\"], doc.fieldtype)", + "fieldname": "non_negative", + "fieldtype": "Check", + "label": "Non Negative" } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-08-28 11:28:21.252853", + "modified": "2020-10-29 06:09:26.454990", "modified_by": "Administrator", "module": "Core", "name": "DocField", diff --git a/frappe/core/doctype/docperm/docperm.json b/frappe/core/doctype/docperm/docperm.json index 1a23118a29..4411a67435 100644 --- a/frappe/core/doctype/docperm/docperm.json +++ b/frappe/core/doctype/docperm/docperm.json @@ -1,775 +1,229 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, + "actions": [], "autoname": "hash", - "beta": 0, "creation": "2013-02-22 01:27:33", - "custom": 0, - "docstatus": 0, "doctype": "DocType", "document_type": "Setup", "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "role_and_level", + "role", + "if_owner", + "column_break_2", + "permlevel", + "section_break_4", + "select", + "read", + "write", + "create", + "delete", + "column_break_8", + "submit", + "cancel", + "amend", + "additional_permissions", + "report", + "export", + "import", + "set_user_permissions", + "column_break_19", + "share", + "print", + "email" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "role_and_level", "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Role and Level", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Role and Level" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "role", "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, "label": "Role", - "length": 0, - "no_copy": 0, "oldfieldname": "role", "oldfieldtype": "Link", "options": "Role", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, "print_width": "150px", - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, "width": "150px" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, + "default": "0", "description": "Apply this rule if the User is the Owner", "fieldname": "if_owner", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "If user is the owner", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "If user is the owner" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "column_break_2", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldtype": "Column Break" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "default": "0", "fieldname": "permlevel", "fieldtype": "Int", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, "label": "Level", - "length": 0, - "no_copy": 0, "oldfieldname": "permlevel", "oldfieldtype": "Int", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, "print_width": "40px", - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, "width": "40px" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "section_break_4", "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Permissions", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Permissions" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "default": "1", "fieldname": "read", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, "label": "Read", - "length": 0, - "no_copy": 0, "oldfieldname": "read", "oldfieldtype": "Check", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, "print_width": "32px", - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, "width": "32px" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "default": "1", "fieldname": "write", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, "label": "Write", - "length": 0, - "no_copy": 0, "oldfieldname": "write", "oldfieldtype": "Check", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, "print_width": "32px", - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, "width": "32px" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "default": "1", "fieldname": "create", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, "label": "Create", - "length": 0, - "no_copy": 0, "oldfieldname": "create", "oldfieldtype": "Check", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, "print_width": "32px", - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, "width": "32px" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "default": "1", "fieldname": "delete", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, - "label": "Delete", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Delete" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "column_break_8", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldtype": "Column Break" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, + "default": "0", "fieldname": "submit", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, "label": "Submit", - "length": 0, - "no_copy": 0, "oldfieldname": "submit", "oldfieldtype": "Check", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, "print_width": "32px", - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, "width": "32px" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, + "default": "0", "fieldname": "cancel", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, "label": "Cancel", - "length": 0, - "no_copy": 0, "oldfieldname": "cancel", "oldfieldtype": "Check", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, "print_width": "32px", - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, "width": "32px" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, + "default": "0", "fieldname": "amend", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, "label": "Amend", - "length": 0, - "no_copy": 0, "oldfieldname": "amend", "oldfieldtype": "Check", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, "print_width": "32px", - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, "width": "32px" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "additional_permissions", "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Additional Permissions", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Additional Permissions" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "default": "1", "fieldname": "report", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, "label": "Report", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, "print_width": "32px", - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, "width": "32px" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "default": "1", "fieldname": "export", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Export", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Export" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, + "default": "0", "fieldname": "import", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Import", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Import" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, + "default": "0", "description": "This role update User Permissions for a user", "fieldname": "set_user_permissions", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Set User Permissions", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Set User Permissions" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "column_break_19", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldtype": "Column Break" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "default": "1", "fieldname": "share", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Share", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Share" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "default": "1", "fieldname": "print", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Print", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Print" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "default": "1", "fieldname": "email", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Email", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Email" + }, + { + "default": "0", + "fieldname": "select", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Select" } ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, "idx": 1, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, "istable": 1, - "max_attachments": 0, - "modified": "2018-05-29 11:54:38.613936", + "links": [], + "modified": "2020-12-03 15:15:30.488212", "modified_by": "Administrator", "module": "Core", "name": "DocPerm", "owner": "Administrator", "permissions": [], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_order": "ASC", - "track_changes": 0, - "track_seen": 0 + "sort_field": "modified", + "sort_order": "ASC" } \ No newline at end of file diff --git a/frappe/core/doctype/doctype/doctype.js b/frappe/core/doctype/doctype/doctype.js index b3469abf29..3e2a423b06 100644 --- a/frappe/core/doctype/doctype/doctype.js +++ b/frappe/core/doctype/doctype/doctype.js @@ -24,11 +24,11 @@ frappe.ui.form.on('DocType', { if (!frm.is_new() && !frm.doc.istable) { if (frm.doc.issingle) { frm.add_custom_button(__('Go to {0}', [frm.doc.name]), () => { - frappe.set_route('Form', frm.doc.name); + window.open(`/app/${frappe.router.slug(frm.doc.name)}`); }); } else { frm.add_custom_button(__('Go to {0} List', [frm.doc.name]), () => { - frappe.set_route('List', frm.doc.name, 'List'); + window.open(`/app/${frappe.router.slug(frm.doc.name)}`); }); } } diff --git a/frappe/core/doctype/doctype/doctype.json b/frappe/core/doctype/doctype/doctype.json index 215ef8cd62..1533829b3c 100644 --- a/frappe/core/doctype/doctype/doctype.json +++ b/frappe/core/doctype/doctype/doctype.json @@ -132,7 +132,7 @@ "label": "Editable Grid" }, { - "default": "1", + "default": "0", "depends_on": "eval:!doc.istable && !doc.issingle", "description": "Open a dialog with mandatory fields to create a new record quickly", "fieldname": "quick_entry", @@ -427,7 +427,7 @@ "label": "Allow Guest to View" }, { - "depends_on": "has_web_view", + "depends_on": "eval:!doc.istable", "fieldname": "route", "fieldtype": "Data", "label": "Route" @@ -555,7 +555,7 @@ }, { "group": "Customization", - "link_doctype": "Custom Script", + "link_doctype": "Client Script", "link_fieldname": "dt" }, { @@ -609,7 +609,7 @@ "link_fieldname": "reference_doctype" } ], - "modified": "2020-09-24 13:13:58.227153", + "modified": "2021-02-04 15:10:09.227205", "modified_by": "Administrator", "module": "Core", "name": "DocType", @@ -637,6 +637,7 @@ "write": 1 } ], + "route": "doctype", "search_fields": "module", "show_name_in_global_search": 1, "sort_field": "modified", diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 9d37849746..cbcfa350f5 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -5,6 +5,7 @@ from __future__ import unicode_literals import re, copy, os, shutil import json +from frappe.cache_manager import clear_user_cache, clear_controller_cache # imports - third party imports import six @@ -25,7 +26,7 @@ from frappe.database.schema import validate_column_name, validate_column_length from frappe.model.docfield import supports_translation from frappe.modules.import_file import get_file_path from frappe.model.meta import Meta - +from frappe.desk.utils import validate_route_conflict class InvalidFieldNameError(frappe.ValidationError): pass class UniqueFieldnameError(frappe.ValidationError): pass @@ -55,12 +56,44 @@ class DocType(Document): - Check fieldnames (duplication etc) - Clear permission table for child tables - Add `amended_from` and `amended_by` if Amendable - - Add custom field `auto_repeat` if Repeatable""" + - Add custom field `auto_repeat` if Repeatable + - Check if links point to valid fieldnames""" self.check_developer_mode() self.validate_name() + self.set_defaults_for_single_and_table() + self.scrub_field_names() + self.set_default_in_list_view() + self.set_default_translatable() + self.validate_series() + self.validate_document_type() + validate_fields(self) + + if not self.istable: + validate_permissions(self) + + self.make_amendable() + self.make_repeatable() + self.validate_nestedset() + self.validate_website() + validate_links_table_fieldnames(self) + + if not self.is_new(): + self.before_update = frappe.get_doc('DocType', self.name) + self.setup_fields_to_fetch() + + check_email_append_to(self) + + if self.default_print_format and not self.custom: + frappe.throw(_('Standard DocType cannot have default print format, use Customize Form')) + + def after_insert(self): + # clear user cache so that on the next reload this doctype is included in boot + clear_user_cache(frappe.session.user) + + def set_defaults_for_single_and_table(self): if self.issingle: self.allow_import = 0 self.is_submittable = 0 @@ -70,39 +103,6 @@ class DocType(Document): self.allow_import = 0 self.permissions = [] - self.scrub_field_names() - self.set_default_in_list_view() - self.set_default_translatable() - self.validate_series() - self.validate_document_type() - validate_fields(self) - - if self.istable: - # no permission records for child table - self.permissions = [] - else: - validate_permissions(self) - - self.make_amendable() - self.make_repeatable() - self.validate_nestedset() - self.validate_website() - - if not self.is_new(): - self.before_update = frappe.get_doc('DocType', self.name) - - if not self.is_new(): - self.setup_fields_to_fetch() - - check_email_append_to(self) - - 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 set_default_in_list_view(self): '''Set default in-list-view for first 4 mandatory fields''' if not [d.fieldname for d in self.fields if d.in_list_view]: @@ -127,6 +127,10 @@ class DocType(Document): if not frappe.conf.get("developer_mode") and not self.custom: frappe.throw(_("Not in Developer Mode! Set in site_config.json or make 'Custom' DocType."), CannotCreateStandardDoctypeError) + if frappe.conf.get('developer_mode'): + self.owner = 'Administrator' + self.modified_by = 'Administrator' + def setup_fields_to_fetch(self): '''Setup query to update values for newly set fetch values''' try: @@ -185,6 +189,9 @@ class DocType(Document): def validate_website(self): """Ensure that website generator has field 'route'""" + if self.route: + self.route = self.route.strip('/') + if self.has_web_view: # route field must be present if not 'route' in [d.fieldname for d in self.fields]: @@ -271,7 +278,6 @@ class DocType(Document): def on_update(self): """Update database schema, make controller templates if `custom` is not set and clear cache.""" - self.delete_duplicate_custom_fields() try: frappe.db.updatedb(self.name, Meta(self)) except Exception as e: @@ -283,9 +289,15 @@ class DocType(Document): self.update_fields_to_fetch() - from frappe import conf - allow_doctype_export = frappe.flags.allow_doctype_export or (not frappe.flags.in_test and conf.get('developer_mode')) - if not self.custom and not frappe.flags.in_import and allow_doctype_export: + allow_doctype_export = ( + not self.custom + and not frappe.flags.in_import + and ( + frappe.conf.developer_mode + or frappe.flags.allow_doctype_export + ) + ) + if allow_doctype_export: self.export_doc() self.make_controller_template() @@ -310,18 +322,6 @@ class DocType(Document): clear_linked_doctype_cache() - def delete_duplicate_custom_fields(self): - if not (frappe.db.table_exists(self.name) and frappe.db.table_exists("Custom Field")): - return - - fields = [d.fieldname for d in self.fields if d.fieldtype in data_fieldtypes] - if fields: - frappe.db.sql('''delete from - `tabCustom Field` - where - dt = {0} and fieldname in ({1}) - '''.format('%s', ', '.join(['%s'] * len(fields))), tuple([self.name] + fields), as_dict=True) - def sync_global_search(self): '''If global search settings are changed, rebuild search properties for this table''' global_search_fields_before_update = [d.fieldname for d in @@ -375,19 +375,31 @@ class DocType(Document): if merge: frappe.throw(_("DocType can not be merged")) - # Do not rename and move files and folders for custom doctype - if not self.custom and not frappe.flags.in_test and not frappe.flags.in_patch: - self.rename_files_and_folders(old, new) - def after_rename(self, old, new, merge=False): """Change table name using `RENAME TABLE` if table exists. Or update `doctype` property for Single type.""" + if self.issingle: frappe.db.sql("""update tabSingles set doctype=%s where doctype=%s""", (new, old)) frappe.db.sql("""update tabSingles set value=%s where doctype=%s and field='name' and value = %s""", (new, new, old)) else: - frappe.db.sql("rename table `tab%s` to `tab%s`" % (old, new)) + frappe.db.multisql({ + "mariadb": f"RENAME TABLE `tab{old}` TO `tab{new}`", + "postgres": f"ALTER TABLE `tab{old}` RENAME TO `tab{new}`" + }) + frappe.db.commit() + + # Do not rename and move files and folders for custom doctype + if not self.custom: + if not frappe.flags.in_patch: + self.rename_files_and_folders(old, new) + + clear_controller_cache(old) + + def after_delete(self): + if not self.custom: + clear_controller_cache(self.name) def rename_files_and_folders(self, old, new): # move files @@ -565,7 +577,8 @@ class DocType(Document): def make_repeatable(self): """If allow_auto_repeat is set, add auto_repeat custom field.""" if self.allow_auto_repeat: - if not frappe.db.exists('Custom Field', {'fieldname': 'auto_repeat', 'dt': self.name}): + if not frappe.db.exists('Custom Field', {'fieldname': 'auto_repeat', 'dt': self.name}) and \ + not frappe.db.exists('DocField', {'fieldname': 'auto_repeat', 'parent': self.name}): insert_after = self.fields[len(self.fields) - 1].fieldname df = dict(fieldname='auto_repeat', label='Auto Repeat', fieldtype='Link', options='Auto Repeat', insert_after=insert_after, read_only=1, no_copy=1, print_hide=1) create_custom_field(self.name, df) @@ -643,7 +656,7 @@ class DocType(Document): flags = {"flags": re.ASCII} if six.PY3 else {} # a DocType name should not start or end with an empty space - if re.match("^[ \t\n\r]+|[ \t\n\r]+$", name, **flags): + if re.search("^[ \t\n\r]+|[ \t\n\r]+$", name, **flags): frappe.throw(_("DocType's name should not start or end with whitespace"), frappe.NameError) # a DocType's name should not start with a number or underscore @@ -651,11 +664,24 @@ class DocType(Document): if not re.match("^(?![\W])[^\d_\s][\w ]+$", name, **flags): frappe.throw(_("DocType's name should start with a letter and it can only consist of letters, numbers, spaces and underscores"), frappe.NameError) + validate_route_conflict(self.doctype, self.name) + +def validate_links_table_fieldnames(meta): + """Validate fieldnames in Links table""" + if frappe.flags.in_patch: return + if frappe.flags.in_fixtures: return + if not meta.links: return + + for index, link in enumerate(meta.links): + link_meta = frappe.get_meta(link.link_doctype) + if not link_meta.get_field(link.link_fieldname): + message = _("Row #{0}: Could not find field {1} in {2} DocType").format(index+1, frappe.bold(link.link_fieldname), frappe.bold(link.link_doctype)) + frappe.throw(message, InvalidFieldNameError, _("Invalid Fieldname")) def validate_fields_for_doctype(doctype): - doc = frappe.get_doc("DocType", doctype) - doc.delete_duplicate_custom_fields() - validate_fields(frappe.get_meta(doctype, cached=False)) + meta = frappe.get_meta(doctype, cached=False) + validate_links_table_fieldnames(meta) + validate_fields(meta) # this is separate because it is also called via custom field def validate_fields(meta): @@ -747,8 +773,8 @@ def validate_fields(meta): def check_illegal_default(d): if d.fieldtype == "Check" and not d.default: d.default = '0' - if d.fieldtype == "Check" and d.default not in ('0', '1'): - frappe.throw(_("Default for 'Check' type of field must be either '0' or '1'")) + if d.fieldtype == "Check" and cint(d.default) not in (0, 1): + frappe.throw(_("Default for 'Check' type of field {0} must be either '0' or '1'").format(frappe.bold(d.fieldname))) if d.fieldtype == "Select" and d.default: if not d.options: frappe.throw(_("Options for {0} must be set before setting the default value.").format(frappe.bold(d.fieldname))) @@ -976,10 +1002,10 @@ def validate_fields(meta): check_sort_field(meta) check_image_field(meta) -def validate_permissions_for_doctype(doctype, for_remove=False): +def validate_permissions_for_doctype(doctype, for_remove=False, alert=False): """Validates if permissions are set correctly.""" doctype = frappe.get_doc("DocType", doctype) - validate_permissions(doctype, for_remove) + validate_permissions(doctype, for_remove, alert=alert) # save permissions for perm in doctype.get("permissions"): @@ -1002,9 +1028,10 @@ def clear_permissions_cache(doctype): """, doctype): frappe.clear_cache(user=user) -def validate_permissions(doctype, for_remove=False): +def validate_permissions(doctype, for_remove=False, alert=False): permissions = doctype.get("permissions") - if not permissions: + # Some DocTypes may not have permissions by default, don't show alert for them + if not permissions and alert: frappe.msgprint(_('No Permissions Specified'), alert=True, indicator='orange') issingle = issubmittable = isimportable = False if doctype: @@ -1016,7 +1043,7 @@ def validate_permissions(doctype, for_remove=False): return _("For {0} at level {1} in {2} in row {3}").format(d.role, d.permlevel, d.parent, d.idx) def check_atleast_one_set(d): - if not d.read and not d.write and not d.submit and not d.cancel and not d.create: + if not d.select and not d.read and not d.write and not d.submit and not d.cancel and not d.create: frappe.throw(_("{0}: No basic permissions set").format(get_txt(d))) def check_double(d): diff --git a/frappe/core/doctype/doctype/patches/set_route.py b/frappe/core/doctype/doctype/patches/set_route.py new file mode 100644 index 0000000000..c052a51f38 --- /dev/null +++ b/frappe/core/doctype/doctype/patches/set_route.py @@ -0,0 +1,7 @@ +import frappe +from frappe.desk.utils import slug + +def execute(): + for doctype in frappe.get_all('DocType', ['name', 'route'], dict(istable=0)): + if not doctype.route: + frappe.db.set_value('DocType', doctype.name, 'route', slug(doctype.name), update_modified = False) \ No newline at end of file diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py index 00e80ce4e7..ec88a2d14c 100644 --- a/frappe/core/doctype/doctype/test_doctype.py +++ b/frappe/core/doctype/doctype/test_doctype.py @@ -5,48 +5,34 @@ from __future__ import unicode_literals import frappe import unittest -from frappe.core.doctype.doctype.doctype import UniqueFieldnameError, IllegalMandatoryError, DoctypeLinkError, WrongOptionsDoctypeLinkError,\ - HiddenAndMandatoryWithoutDefaultError, CannotIndexedError, InvalidFieldNameError, CannotCreateStandardDoctypeError +from frappe.core.doctype.doctype.doctype import (UniqueFieldnameError, + IllegalMandatoryError, + DoctypeLinkError, + WrongOptionsDoctypeLinkError, + HiddenAndMandatoryWithoutDefaultError, + CannotIndexedError, + InvalidFieldNameError, + validate_links_table_fieldnames) # test_records = frappe.get_test_records('DocType') - class TestDocType(unittest.TestCase): - def new_doctype(self, name, unique=0, depends_on=''): - return frappe.get_doc({ - "doctype": "DocType", - "module": "Core", - "custom": 1, - "fields": [{ - "label": "Some Field", - "fieldname": "some_fieldname", - "fieldtype": "Data", - "unique": unique, - "depends_on": depends_on, - }], - "permissions": [{ - "role": "System Manager", - "read": 1, - }], - "name": name - }) - def test_validate_name(self): - self.assertRaises(frappe.NameError, self.new_doctype("_Some DocType").insert) - self.assertRaises(frappe.NameError, self.new_doctype("8Some DocType").insert) - self.assertRaises(frappe.NameError, self.new_doctype("Some (DocType)").insert) + self.assertRaises(frappe.NameError, new_doctype("_Some DocType").insert) + self.assertRaises(frappe.NameError, new_doctype("8Some DocType").insert) + self.assertRaises(frappe.NameError, new_doctype("Some (DocType)").insert) for name in ("Some DocType", "Some_DocType"): if frappe.db.exists("DocType", name): frappe.delete_doc("DocType", name) - doc = self.new_doctype(name).insert() + doc = new_doctype(name).insert() doc.delete() def test_doctype_unique_constraint_dropped(self): if frappe.db.exists("DocType", "With_Unique"): frappe.delete_doc("DocType", "With_Unique") - dt = self.new_doctype("With_Unique", unique=1) + dt = new_doctype("With_Unique", unique=1) dt.insert() doc1 = frappe.new_doc("With_Unique") @@ -67,7 +53,7 @@ class TestDocType(unittest.TestCase): doc2.delete() def test_validate_search_fields(self): - doc = self.new_doctype("Test Search Fields") + doc = new_doctype("Test Search Fields") doc.search_fields = "some_fieldname" doc.insert() self.assertEqual(doc.name, "Test Search Fields") @@ -85,7 +71,7 @@ class TestDocType(unittest.TestCase): self.assertRaises(frappe.ValidationError, doc.save) def test_depends_on_fields(self): - doc = self.new_doctype("Test Depends On", depends_on="eval:doc.__islocal == 0") + doc = new_doctype("Test Depends On", depends_on="eval:doc.__islocal == 0") doc.insert() # check if the assignment operation is allowed in depends_on @@ -261,7 +247,7 @@ class TestDocType(unittest.TestCase): frappe.flags.allow_doctype_export = 0 def test_unique_field_name_for_two_fields(self): - doc = self.new_doctype('Test Unique Field') + doc = new_doctype('Test Unique Field') field_1 = doc.append('fields', {}) field_1.fieldname = 'some_fieldname_1' field_1.fieldtype = 'Data' @@ -273,7 +259,7 @@ class TestDocType(unittest.TestCase): self.assertRaises(UniqueFieldnameError, doc.insert) def test_fieldname_is_not_name(self): - doc = self.new_doctype('Test Name Field') + doc = new_doctype('Test Name Field') field_1 = doc.append('fields', {}) field_1.label = 'Name' field_1.fieldtype = 'Data' @@ -283,7 +269,7 @@ class TestDocType(unittest.TestCase): self.assertRaises(InvalidFieldNameError, doc.save) def test_illegal_mandatory_validation(self): - doc = self.new_doctype('Test Illegal mandatory') + doc = new_doctype('Test Illegal mandatory') field_1 = doc.append('fields', {}) field_1.fieldname = 'some_fieldname_1' field_1.fieldtype = 'Section Break' @@ -292,7 +278,7 @@ class TestDocType(unittest.TestCase): self.assertRaises(IllegalMandatoryError, doc.insert) def test_link_with_wrong_and_no_options(self): - doc = self.new_doctype('Test link') + doc = new_doctype('Test link') field_1 = doc.append('fields', {}) field_1.fieldname = 'some_fieldname_1' field_1.fieldtype = 'Link' @@ -304,7 +290,7 @@ class TestDocType(unittest.TestCase): self.assertRaises(WrongOptionsDoctypeLinkError, doc.insert) def test_hidden_and_mandatory_without_default(self): - doc = self.new_doctype('Test hidden and mandatory') + doc = new_doctype('Test hidden and mandatory') field_1 = doc.append('fields', {}) field_1.fieldname = 'some_fieldname_1' field_1.fieldtype = 'Data' @@ -314,7 +300,7 @@ class TestDocType(unittest.TestCase): self.assertRaises(HiddenAndMandatoryWithoutDefaultError, doc.insert) def test_field_can_not_be_indexed_validation(self): - doc = self.new_doctype('Test index') + doc = new_doctype('Test index') field_1 = doc.append('fields', {}) field_1.fieldname = 'some_fieldname_1' field_1.fieldtype = 'Long Text' @@ -327,14 +313,14 @@ class TestDocType(unittest.TestCase): from frappe.desk.form.linked_with import get_submitted_linked_docs, cancel_all_linked_docs #create doctype - link_doc = self.new_doctype('Test Linked Doctype') + link_doc = new_doctype('Test Linked Doctype') link_doc.is_submittable = 1 for data in link_doc.get('permissions'): data.submit = 1 data.cancel = 1 link_doc.insert() - doc = self.new_doctype('Test Doctype') + doc = new_doctype('Test Doctype') doc.is_submittable = 1 field_2 = doc.append('fields', {}) field_2.label = 'Test Linked Doctype' @@ -377,12 +363,12 @@ class TestDocType(unittest.TestCase): doc.delete() frappe.db.commit() - def test_ignore_cancelation_of_linked_doctype_during_cancell(self): + def test_ignore_cancelation_of_linked_doctype_during_cancel(self): import json from frappe.desk.form.linked_with import get_submitted_linked_docs, cancel_all_linked_docs #create linked doctype - link_doc = self.new_doctype('Test Linked Doctype 1') + link_doc = new_doctype('Test Linked Doctype 1') link_doc.is_submittable = 1 for data in link_doc.get('permissions'): data.submit = 1 @@ -390,7 +376,7 @@ class TestDocType(unittest.TestCase): link_doc.insert() #create first parent doctype - test_doc_1 = self.new_doctype('Test Doctype 1') + test_doc_1 = new_doctype('Test Doctype 1') test_doc_1.is_submittable = 1 field_2 = test_doc_1.append('fields', {}) @@ -405,7 +391,7 @@ class TestDocType(unittest.TestCase): test_doc_1.insert() #crete second parent doctype - doc = self.new_doctype('Test Doctype 2') + doc = new_doctype('Test Doctype 2') doc.is_submittable = 1 field_2 = doc.append('fields', {}) @@ -469,3 +455,55 @@ class TestDocType(unittest.TestCase): doc.delete() test_doc_1.delete() frappe.db.commit() + + def test_links_table_fieldname_validation(self): + doc = new_doctype("Test Links Table Validation") + + # check valid data + doc.append("links", { + 'link_doctype': "User", + 'link_fieldname': "first_name" + }) + validate_links_table_fieldnames(doc) # no error + doc.links = [] # reset links table + + # check invalid doctype + doc.append("links", { + 'link_doctype': "User2", + 'link_fieldname': "first_name" + }) + self.assertRaises(frappe.DoesNotExistError, validate_links_table_fieldnames, doc) + doc.links = [] # reset links table + + # check invalid fieldname + doc.append("links", { + 'link_doctype': "User", + 'link_fieldname': "a_field_that_does_not_exists" + }) + self.assertRaises(InvalidFieldNameError, validate_links_table_fieldnames, doc) + + +def new_doctype(name, unique=0, depends_on='', fields=None): + doc = frappe.get_doc({ + "doctype": "DocType", + "module": "Core", + "custom": 1, + "fields": [{ + "label": "Some Field", + "fieldname": "some_fieldname", + "fieldtype": "Data", + "unique": unique, + "depends_on": depends_on, + }], + "permissions": [{ + "role": "System Manager", + "read": 1, + }], + "name": name + }) + + if fields: + for f in fields: + doc.append('fields', f) + + return doc \ No newline at end of file diff --git a/frappe/core/doctype/doctype_action/doctype_action.json b/frappe/core/doctype/doctype_action/doctype_action.json index 0f9da802eb..080755c479 100644 --- a/frappe/core/doctype/doctype_action/doctype_action.json +++ b/frappe/core/doctype/doctype_action/doctype_action.json @@ -9,7 +9,8 @@ "action_type", "action", "group", - "hidden" + "hidden", + "custom" ], "fields": [ { @@ -48,12 +49,19 @@ "fieldname": "hidden", "fieldtype": "Check", "label": "Hidden" + }, + { + "default": "0", + "fieldname": "custom", + "fieldtype": "Check", + "hidden": 1, + "label": "Custom" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-08-21 14:44:03.845315", + "modified": "2020-09-24 14:19:05.549835", "modified_by": "Administrator", "module": "Core", "name": "DocType Action", diff --git a/frappe/core/doctype/doctype_link/doctype_link.json b/frappe/core/doctype/doctype_link/doctype_link.json index 752b4bb5da..0453894467 100644 --- a/frappe/core/doctype/doctype_link/doctype_link.json +++ b/frappe/core/doctype/doctype_link/doctype_link.json @@ -7,7 +7,9 @@ "field_order": [ "link_doctype", "link_fieldname", - "group" + "group", + "hidden", + "custom" ], "fields": [ { @@ -30,10 +32,25 @@ "fieldtype": "Data", "in_list_view": 1, "label": "Group" + }, + { + "default": "0", + "fieldname": "hidden", + "fieldtype": "Check", + "label": "Hidden" + }, + { + "default": "0", + "fieldname": "custom", + "fieldtype": "Check", + "hidden": 1, + "label": "Custom" } ], + "index_web_pages_for_search": 1, "istable": 1, - "modified": "2019-09-24 11:41:25.291377", + "links": [], + "modified": "2020-09-24 14:19:25.189511", "modified_by": "Administrator", "module": "Core", "name": "DocType Link", diff --git a/frappe/core/doctype/document_naming_rule/document_naming_rule.json b/frappe/core/doctype/document_naming_rule/document_naming_rule.json index 79eebdbe64..4a88e3be6e 100644 --- a/frappe/core/doctype/document_naming_rule/document_naming_rule.json +++ b/frappe/core/doctype/document_naming_rule/document_naming_rule.json @@ -34,7 +34,8 @@ "fieldname": "prefix", "fieldtype": "Data", "label": "Prefix", - "mandatory_depends_on": "eval:doc.naming_by===\"Numbered\"" + "mandatory_depends_on": "eval:doc.naming_by===\"Numbered\"", + "reqd": 1 }, { "fieldname": "counter", @@ -48,7 +49,8 @@ "fieldname": "prefix_digits", "fieldtype": "Int", "label": "Digits", - "mandatory_depends_on": "eval:doc.naming_by===\"Numbered\"" + "mandatory_depends_on": "eval:doc.naming_by===\"Numbered\"", + "reqd": 1 }, { "fieldname": "naming_section", @@ -69,7 +71,7 @@ "options": "Document Naming Rule Condition" }, { - "description": "Rules with higher priority will be applied first.", + "description": "Rules with higher priority number will be applied first.", "fieldname": "priority", "fieldtype": "Int", "label": "Priority" @@ -77,7 +79,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2020-09-21 10:23:34.401539", + "modified": "2020-11-04 14:38:14.836056", "modified_by": "Administrator", "module": "Core", "name": "Document Naming Rule", diff --git a/frappe/core/doctype/document_naming_rule/document_naming_rule.py b/frappe/core/doctype/document_naming_rule/document_naming_rule.py index 2de7552dc1..4b34293af6 100644 --- a/frappe/core/doctype/document_naming_rule/document_naming_rule.py +++ b/frappe/core/doctype/document_naming_rule/document_naming_rule.py @@ -6,14 +6,25 @@ from __future__ import unicode_literals import frappe from frappe.model.document import Document from frappe.utils.data import evaluate_filters +from frappe import _ class DocumentNamingRule(Document): + def validate(self): + self.validate_fields_in_conditions() + + def validate_fields_in_conditions(self): + if self.has_value_changed("document_type"): + docfields = [x.fieldname for x in frappe.get_meta(self.document_type).fields] + for condition in self.conditions: + if condition.field not in docfields: + frappe.throw(_("{0} is not a field of doctype {1}").format(frappe.bold(condition.field), frappe.bold(self.document_type))) + def apply(self, doc): ''' Apply naming rules for the given document. Will set `name` if the rule is matched. ''' if self.conditions: - if not evaluate_filters(doc, [(d.field, d.condition, d.value) for d in self.conditions]): + if not evaluate_filters(doc, [(self.document_type, d.field, d.condition, d.value) for d in self.conditions]): return counter = frappe.db.get_value(self.doctype, self.name, 'counter', for_update=True) or 0 diff --git a/frappe/core/doctype/domain_settings/domain_settings.js b/frappe/core/doctype/domain_settings/domain_settings.js index 1428727993..7178cb4cd6 100644 --- a/frappe/core/doctype/domain_settings/domain_settings.js +++ b/frappe/core/doctype/domain_settings/domain_settings.js @@ -18,6 +18,9 @@ frappe.ui.form.on('Domain Settings', { checked: active_domains.includes(domain) }; }); + }, + on_change: () => { + frm.dirty(); } }, render_input: true diff --git a/frappe/core/doctype/error_log/error_log.py b/frappe/core/doctype/error_log/error_log.py index 6cc8275404..ec02aaf446 100644 --- a/frappe/core/doctype/error_log/error_log.py +++ b/frappe/core/doctype/error_log/error_log.py @@ -17,9 +17,6 @@ def set_old_logs_as_seen(): frappe.db.sql("""UPDATE `tabError Log` SET `seen`=1 WHERE `seen`=0 AND `creation` < (NOW() - INTERVAL '7' DAY)""") - # clear old logs - frappe.db.sql("""DELETE FROM `tabError Log` WHERE `creation` < (NOW() - INTERVAL '30' DAY)""") - @frappe.whitelist() def clear_error_logs(): '''Flush all Error Logs''' diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index b8bed89a4d..445ca1184d 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -30,7 +30,7 @@ import frappe from frappe import _, conf from frappe.model.document import Document from frappe.utils import call_hook_method, cint, cstr, encode, get_files_path, get_hook_method, random_string, strip - +from frappe.utils.image import strip_exif_data class MaxFileSizeReachedError(frappe.ValidationError): pass @@ -93,6 +93,7 @@ class File(Document): self.set_is_private() self.set_file_name() self.validate_duplicate_entry() + self.validate_attachment_limit() self.validate_folder() if not self.file_url and not self.flags.ignore_file_validate: @@ -140,6 +141,26 @@ class File(Document): if self.file_url and (self.is_private != self.file_url.startswith('/private')): frappe.throw(_('Invalid file URL. Please contact System Administrator.')) + def validate_attachment_limit(self): + attachment_limit = 0 + if self.attached_to_doctype and self.attached_to_name: + attachment_limit = cint(frappe.get_meta(self.attached_to_doctype).max_attachments) + + if attachment_limit: + current_attachment_count = len(frappe.get_all('File', filters={ + 'attached_to_doctype': self.attached_to_doctype, + 'attached_to_name': self.attached_to_name, + }, limit=attachment_limit + 1)) + + if current_attachment_count >= attachment_limit: + frappe.throw( + _("Maximum Attachment Limit of {0} has been reached for {1} {2}.").format( + frappe.bold(attachment_limit), self.attached_to_doctype, self.attached_to_name + ), + exc=frappe.exceptions.AttachmentLimitReached, + title=_('Attachment Limit Reached') + ) + def set_folder_name(self): """Make parent folders if not exists based on reference doctype and name""" if self.attached_to_doctype and not self.folder: @@ -435,6 +456,7 @@ class File(Document): def save_file(self, content=None, decode=False, ignore_existing_file_check=False): file_exists = False self.content = content + if decode: if isinstance(content, text_type): self.content = content.encode("utf-8") @@ -445,10 +467,19 @@ class File(Document): if not self.is_private: self.is_private = 0 - self.file_size = self.check_max_file_size() - self.content_hash = get_content_hash(self.content) + self.content_type = mimetypes.guess_type(self.file_name)[0] + self.file_size = self.check_max_file_size() + + if ( + self.content_type and "image" in self.content_type + and frappe.get_system_settings("strip_exif_metadata_from_uploaded_images") + ): + self.content = strip_exif_data(self.content, self.content_type) + + self.content_hash = get_content_hash(self.content) + duplicate_file = None # check if a file exists with the same content hash and is also in the same folder (public or private) @@ -612,7 +643,12 @@ def get_extension(filename, extn, content): return extn def get_local_image(file_url): - file_path = frappe.get_site_path("public", file_url.lstrip("/")) + if file_url.startswith("/private"): + file_url_path = (file_url.lstrip("/"), ) + else: + file_url_path = ("public", file_url.lstrip("/")) + + file_path = frappe.get_site_path(*file_url_path) try: image = Image.open(file_path) @@ -904,10 +940,33 @@ def validate_filename(filename): return fname @frappe.whitelist() -def get_files_in_folder(folder): - return frappe.db.get_all('File', +def get_files_in_folder(folder, start=0, page_length=20): + start = cint(start) + page_length = cint(page_length) + + files = frappe.db.get_all('File', { 'folder': folder }, - ['name', 'file_name', 'file_url', 'is_folder', 'modified'] + ['name', 'file_name', 'file_url', 'is_folder', 'modified'], + start=start, + page_length=page_length + 1 + ) + return { + 'files': files[:page_length], + 'has_more': len(files) > page_length + } + +@frappe.whitelist() +def get_files_by_search_text(text): + if not text: + return [] + + text = '%' + cstr(text).lower() + '%' + return frappe.db.get_all('File', + fields=['name', 'file_name', 'file_url', 'is_folder', 'modified'], + filters={'is_folder': False}, + or_filters={'file_name': ('like', text), 'file_url': text, 'name': ('like', text)}, + order_by='modified desc', + limit=20 ) def update_existing_file_docs(doc): diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index 85397ea1ee..e627558680 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -160,6 +160,31 @@ class TestSameContent(unittest.TestCase): def test_saved_content(self): self.assertFalse(os.path.exists(get_files_path(self.dup_filename))) + def test_attachment_limit(self): + doctype, docname = make_test_doc() + from frappe.custom.doctype.property_setter.property_setter import make_property_setter + limit_property = make_property_setter('ToDo', None, 'max_attachments', 1, 'int', for_doctype=True) + file1 = frappe.get_doc({ + "doctype": "File", + "file_name": 'test-attachment', + "attached_to_doctype": doctype, + "attached_to_name": docname, + "content": 'test' + }) + + file1.insert() + + file2 = frappe.get_doc({ + "doctype": "File", + "file_name": 'test-attachment', + "attached_to_doctype": doctype, + "attached_to_name": docname, + "content": 'test2' + }) + + self.assertRaises(frappe.exceptions.AttachmentLimitReached, file2.insert) + limit_property.delete() + frappe.clear_cache(doctype='ToDo') def tearDown(self): # File gets deleted on rollback, so blank diff --git a/frappe/core/page/desktop/__init__.py b/frappe/core/doctype/log_setting_user/__init__.py similarity index 100% rename from frappe/core/page/desktop/__init__.py rename to frappe/core/doctype/log_setting_user/__init__.py diff --git a/frappe/core/doctype/log_setting_user/log_setting_user.js b/frappe/core/doctype/log_setting_user/log_setting_user.js new file mode 100644 index 0000000000..a1eb824e22 --- /dev/null +++ b/frappe/core/doctype/log_setting_user/log_setting_user.js @@ -0,0 +1,8 @@ +// Copyright (c) 2020, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Log Setting User', { + // refresh: function(frm) { + + // } +}); diff --git a/frappe/core/doctype/log_setting_user/log_setting_user.json b/frappe/core/doctype/log_setting_user/log_setting_user.json new file mode 100644 index 0000000000..7f4b0ef874 --- /dev/null +++ b/frappe/core/doctype/log_setting_user/log_setting_user.json @@ -0,0 +1,34 @@ +{ + "actions": [], + "autoname": "field:user", + "creation": "2020-10-08 13:09:36.034430", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "user" + ], + "fields": [ + { + "fieldname": "user", + "fieldtype": "Link", + "in_list_view": 1, + "label": "User", + "options": "User", + "reqd": 1, + "unique": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2020-10-08 17:22:04.690348", + "modified_by": "Administrator", + "module": "Core", + "name": "Log Setting User", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/custom/doctype/custom_link/custom_link.py b/frappe/core/doctype/log_setting_user/log_setting_user.py similarity index 88% rename from frappe/custom/doctype/custom_link/custom_link.py rename to frappe/core/doctype/log_setting_user/log_setting_user.py index 11316d5751..df6d55f0a9 100644 --- a/frappe/custom/doctype/custom_link/custom_link.py +++ b/frappe/core/doctype/log_setting_user/log_setting_user.py @@ -6,5 +6,5 @@ from __future__ import unicode_literals # import frappe from frappe.model.document import Document -class CustomLink(Document): +class LogSettingUser(Document): pass diff --git a/frappe/integrations/doctype/twilio_settings/test_twilio_settings.py b/frappe/core/doctype/log_setting_user/test_log_setting_user.py similarity index 80% rename from frappe/integrations/doctype/twilio_settings/test_twilio_settings.py rename to frappe/core/doctype/log_setting_user/test_log_setting_user.py index bcb1368d68..507c02d87d 100644 --- a/frappe/integrations/doctype/twilio_settings/test_twilio_settings.py +++ b/frappe/core/doctype/log_setting_user/test_log_setting_user.py @@ -6,5 +6,5 @@ from __future__ import unicode_literals # import frappe import unittest -class TestTwilioSettings(unittest.TestCase): +class TestLogSettingUser(unittest.TestCase): pass diff --git a/frappe/core/page/workspace/__init__.py b/frappe/core/doctype/log_settings/__init__.py similarity index 100% rename from frappe/core/page/workspace/__init__.py rename to frappe/core/doctype/log_settings/__init__.py diff --git a/frappe/desk/doctype/desk_card/desk_card.js b/frappe/core/doctype/log_settings/log_settings.js similarity index 80% rename from frappe/desk/doctype/desk_card/desk_card.js rename to frappe/core/doctype/log_settings/log_settings.js index cc0272cb60..09a2086a1d 100644 --- a/frappe/desk/doctype/desk_card/desk_card.js +++ b/frappe/core/doctype/log_settings/log_settings.js @@ -1,7 +1,7 @@ // Copyright (c) 2020, Frappe Technologies and contributors // For license information, please see license.txt -frappe.ui.form.on('Desk Card', { +frappe.ui.form.on('Log Settings', { // refresh: function(frm) { // } diff --git a/frappe/core/doctype/log_settings/log_settings.json b/frappe/core/doctype/log_settings/log_settings.json new file mode 100644 index 0000000000..8a2596b35c --- /dev/null +++ b/frappe/core/doctype/log_settings/log_settings.json @@ -0,0 +1,83 @@ +{ + "actions": [], + "creation": "2020-10-08 12:12:21.694424", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "error_log_notification_section", + "users_to_notify", + "log_cleanup_section", + "clear_error_log_after", + "clear_activity_log_after", + "column_break_4", + "clear_email_queue_after" + ], + "fields": [ + { + "fieldname": "log_cleanup_section", + "fieldtype": "Section Break", + "label": "Log Cleanup" + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fieldname": "error_log_notification_section", + "fieldtype": "Section Break", + "label": "Error Log Notification" + }, + { + "fieldname": "users_to_notify", + "fieldtype": "Table MultiSelect", + "label": "Users To Notify", + "options": "Log Setting User" + }, + { + "default": "90", + "description": "In Days", + "fieldname": "clear_error_log_after", + "fieldtype": "Int", + "label": "Clear Error log After" + }, + { + "default": "90", + "description": "In Days", + "fieldname": "clear_activity_log_after", + "fieldtype": "Int", + "label": "Clear Activity Log After" + }, + { + "default": "90", + "description": "In Days", + "fieldname": "clear_email_queue_after", + "fieldtype": "Int", + "label": "Clear Email Queue After" + } + ], + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2020-10-13 12:18:48.649038", + "modified_by": "Administrator", + "module": "Core", + "name": "Log Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "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 new file mode 100644 index 0000000000..08e61d3289 --- /dev/null +++ b/frappe/core/doctype/log_settings/log_settings.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe import _ +from frappe.model.document import Document + +class LogSettings(Document): + def clear_logs(self): + self.clear_error_logs() + self.clear_activity_logs() + 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)) + + def clear_activity_logs(self): + from frappe.core.doctype.activity_log.activity_log import clear_activity_logs + clear_activity_logs(days=self.clear_activity_log_after) + + def clear_email_queue(self): + from frappe.email.queue import clear_outbox + clear_outbox(days=self.clear_email_queue_after) + +def run_log_clean_up(): + doc = frappe.get_doc("Log Settings") + doc.clear_logs() + +@frappe.whitelist() +def has_unseen_error_log(user): + + def _get_response(show_alert=True): + return { + 'show_alert': True, + 'message': _("You have unseen {0}").format(' Error Logs ') + } + + if frappe.db.sql_list("select name from `tabError Log` where seen = 0 limit 1"): + log_settings = frappe.get_cached_doc('Log Settings') + + if log_settings.users_to_notify: + if user in [u.user for u in log_settings.users_to_notify]: + return _get_response() + else: + return _get_response(show_alert=False) + else: + return _get_response() \ No newline at end of file diff --git a/frappe/desk/doctype/desk_card/test_desk_card.py b/frappe/core/doctype/log_settings/test_log_settings.py similarity index 81% rename from frappe/desk/doctype/desk_card/test_desk_card.py rename to frappe/core/doctype/log_settings/test_log_settings.py index de9587d17e..2824c71c88 100644 --- a/frappe/desk/doctype/desk_card/test_desk_card.py +++ b/frappe/core/doctype/log_settings/test_log_settings.py @@ -6,5 +6,5 @@ from __future__ import unicode_literals # import frappe import unittest -class TestDeskCard(unittest.TestCase): +class TestLogSettings(unittest.TestCase): pass diff --git a/frappe/core/doctype/module_def/module_def.json b/frappe/core/doctype/module_def/module_def.json index b841eb1f0b..7a8bfd76a7 100644 --- a/frappe/core/doctype/module_def/module_def.json +++ b/frappe/core/doctype/module_def/module_def.json @@ -51,7 +51,7 @@ "link_fieldname": "module" }, { - "link_doctype": "Desk Page", + "link_doctype": "Workspace", "link_fieldname": "module" } ], diff --git a/frappe/core/doctype/module_def/module_def.py b/frappe/core/doctype/module_def/module_def.py index 930c46e60b..7e63572162 100644 --- a/frappe/core/doctype/module_def/module_def.py +++ b/frappe/core/doctype/module_def/module_def.py @@ -43,7 +43,7 @@ class ModuleDef(Document): def on_trash(self): """Delete module name from modules.txt""" - if frappe.flags.in_uninstall or self.custom: + if not frappe.conf.get('developer_mode') or frappe.flags.in_uninstall or self.custom: return modules = None diff --git a/frappe/custom/doctype/custom_link/__init__.py b/frappe/core/doctype/module_profile/__init__.py similarity index 100% rename from frappe/custom/doctype/custom_link/__init__.py rename to frappe/core/doctype/module_profile/__init__.py diff --git a/frappe/core/doctype/module_profile/module_profile.js b/frappe/core/doctype/module_profile/module_profile.js new file mode 100644 index 0000000000..9c92042dda --- /dev/null +++ b/frappe/core/doctype/module_profile/module_profile.js @@ -0,0 +1,19 @@ +// Copyright (c) 2020, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Module Profile', { + refresh: function(frm) { + if (has_common(frappe.user_roles, ["Administrator", "System Manager"])) { + if (!frm.module_editor && frm.doc.__onload && frm.doc.__onload.all_modules) { + let module_area = $('
    ') + .appendTo(frm.fields_dict.module_html.wrapper); + + frm.module_editor = new frappe.ModuleEditor(frm, module_area); + } + } + + if (frm.module_editor) { + frm.module_editor.refresh(); + } + } +}); diff --git a/frappe/core/doctype/module_profile/module_profile.json b/frappe/core/doctype/module_profile/module_profile.json new file mode 100644 index 0000000000..0e4e56962e --- /dev/null +++ b/frappe/core/doctype/module_profile/module_profile.json @@ -0,0 +1,60 @@ +{ + "actions": [], + "autoname": "field:module_profile_name", + "creation": "2020-12-22 22:00:30.614475", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "module_profile_name", + "module_html", + "block_modules" + ], + "fields": [ + { + "fieldname": "module_profile_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Module Profile Name", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "module_html", + "fieldtype": "HTML", + "label": "Module HTML" + }, + { + "fieldname": "block_modules", + "fieldtype": "Table", + "hidden": 1, + "label": "Block Modules", + "options": "Block Module", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-01-03 15:36:52.622696", + "modified_by": "Administrator", + "module": "Core", + "name": "Module Profile", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/core/doctype/module_profile/module_profile.py b/frappe/core/doctype/module_profile/module_profile.py new file mode 100644 index 0000000000..4f392353ac --- /dev/null +++ b/frappe/core/doctype/module_profile/module_profile.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +from frappe.model.document import Document + +class ModuleProfile(Document): + def onload(self): + from frappe.config import get_modules_from_all_apps + self.set_onload('all_modules', + [m.get("module_name") for m in get_modules_from_all_apps()]) diff --git a/frappe/core/doctype/module_profile/test_module_profile.py b/frappe/core/doctype/module_profile/test_module_profile.py new file mode 100644 index 0000000000..400053d22c --- /dev/null +++ b/frappe/core/doctype/module_profile/test_module_profile.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies and Contributors +# See license.txt +from __future__ import unicode_literals +import frappe +import unittest + +class TestModuleProfile(unittest.TestCase): + def test_make_new_module_profile(self): + if not frappe.db.get_value('Module Profile', '_Test Module Profile'): + frappe.get_doc({ + 'doctype': 'Module Profile', + 'module_profile_name': '_Test Module Profile', + 'block_modules': [ + {'module': 'Accounts'} + ] + }).insert() + + # add to user and check + if not frappe.db.get_value('User', 'test-for-module_profile@example.com'): + new_user = frappe.get_doc({ + 'doctype': 'User', + 'email':'test-for-module_profile@example.com', + 'first_name':'Test User' + }).insert() + else: + new_user = frappe.get_doc('User', 'test-for-module_profile@example.com') + + new_user.module_profile = '_Test Module Profile' + new_user.save() + + self.assertEqual(new_user.block_modules[0].module, 'Accounts') diff --git a/frappe/core/doctype/navbar_item/navbar_item.json b/frappe/core/doctype/navbar_item/navbar_item.json index 3bfea52558..541d785710 100644 --- a/frappe/core/doctype/navbar_item/navbar_item.json +++ b/frappe/core/doctype/navbar_item/navbar_item.json @@ -2,7 +2,6 @@ "actions": [], "creation": "2020-08-01 23:38:41.783206", "doctype": "DocType", - "editable_grid": 1, "engine": "InnoDB", "field_order": [ "item_label", @@ -30,6 +29,7 @@ "in_list_view": 1, "label": "Item Type", "options": "Route\nAction\nSeparator", + "read_only_depends_on": "eval:doc.is_standard", "show_days": 1, "show_seconds": 1 }, @@ -59,6 +59,7 @@ "in_list_view": 1, "label": "Route", "mandatory_depends_on": "eval:doc.item_type == 'Route'", + "read_only_depends_on": "eval:doc.is_standard", "show_days": 1, "show_seconds": 1 }, @@ -68,13 +69,14 @@ "fieldtype": "Data", "label": "Action", "mandatory_depends_on": "eval:doc.item_type == 'Action'", + "read_only_depends_on": "eval:doc.is_standard", "show_days": 1, "show_seconds": 1 } ], "istable": 1, "links": [], - "modified": "2020-08-06 16:32:49.597060", + "modified": "2020-11-02 10:57:37.709262", "modified_by": "Administrator", "module": "Core", "name": "Navbar Item", diff --git a/frappe/core/doctype/navbar_settings/navbar_settings.py b/frappe/core/doctype/navbar_settings/navbar_settings.py index f7c437bf00..2244bc9e4e 100644 --- a/frappe/core/doctype/navbar_settings/navbar_settings.py +++ b/frappe/core/doctype/navbar_settings/navbar_settings.py @@ -23,9 +23,9 @@ class NavbarSettings(Document): if not frappe.flags.in_patch and (len(before_save_items) > len(after_save_items)): frappe.throw(_("Please hide the standard navbar items instead of deleting them")) -@frappe.whitelist() +@frappe.whitelist(allow_guest=True) def get_app_logo(): - app_logo = frappe.db.get_single_value('Navbar Settings', 'app_logo') + app_logo = frappe.db.get_single_value('Navbar Settings', 'app_logo', cache=True) if not app_logo: app_logo = frappe.get_hooks('app_logo_url')[-1] diff --git a/frappe/core/doctype/page/page.py b/frappe/core/doctype/page/page.py index 65befcded5..bdec350efd 100644 --- a/frappe/core/doctype/page/page.py +++ b/frappe/core/doctype/page/page.py @@ -9,6 +9,7 @@ from frappe.build import html_to_js_template from frappe.model.utils import render_include from frappe import conf, _, safe_decode from frappe.desk.form.meta import get_code_files_via_hooks, get_js +from frappe.desk.utils import validate_route_conflict from frappe.core.doctype.custom_role.custom_role import get_custom_allowed_roles from six import text_type @@ -33,6 +34,8 @@ class Page(Document): self.name += '-' + str(cnt) def validate(self): + validate_route_conflict(self.doctype, self.name) + if self.is_new() and not getattr(conf,'developer_mode', 0): frappe.throw(_("Not in Developer Mode")) diff --git a/frappe/core/doctype/page/patches/drop_unused_pages.py b/frappe/core/doctype/page/patches/drop_unused_pages.py new file mode 100644 index 0000000000..93b47cebcc --- /dev/null +++ b/frappe/core/doctype/page/patches/drop_unused_pages.py @@ -0,0 +1,5 @@ +import frappe + +def execute(): + for name in ('desktop', 'space'): + frappe.delete_doc('Page', name) \ No newline at end of file diff --git a/frappe/core/doctype/page/test_page.py b/frappe/core/doctype/page/test_page.py index 78659f1ffd..f7b3952a5b 100644 --- a/frappe/core/doctype/page/test_page.py +++ b/frappe/core/doctype/page/test_page.py @@ -8,4 +8,6 @@ import unittest test_records = frappe.get_test_records('Page') class TestPage(unittest.TestCase): - pass + def test_naming(self): + self.assertRaises(frappe.NameError, frappe.get_doc(dict(doctype='Page', page_name='DocType', module='Core')).insert) + self.assertRaises(frappe.NameError, frappe.get_doc(dict(doctype='Page', page_name='Settings', module='Core')).insert) diff --git a/frappe/core/doctype/prepared_report/prepared_report.py b/frappe/core/doctype/prepared_report/prepared_report.py index 2c02d99dad..1d0d6ebb09 100644 --- a/frappe/core/doctype/prepared_report/prepared_report.py +++ b/frappe/core/doctype/prepared_report/prepared_report.py @@ -89,20 +89,18 @@ def delete_expired_prepared_reports(): 'creation': ['<', frappe.utils.add_days(frappe.utils.now(), -expiry_period)] }) - args = { - 'reports': prepared_reports_to_delete, - 'limit': 50 - } - - enqueue(method=delete_prepared_reports, job_name="delete_prepared_reports", **args) + batches = frappe.utils.create_batch(prepared_reports_to_delete, 100) + for batch in batches: + args = { + 'reports': batch, + } + enqueue(method=delete_prepared_reports, job_name="delete_prepared_reports", **args) @frappe.whitelist() -def delete_prepared_reports(reports, limit=None): +def delete_prepared_reports(reports): reports = frappe.parse_json(reports) - for index, doc in enumerate(reports): - if limit and index == limit: - return - frappe.delete_doc('Prepared Report', doc['name'], ignore_permissions=True) + for report in reports: + frappe.delete_doc('Prepared Report', report['name'], ignore_permissions=True) def create_json_gz_file(data, dt, dn): # Storing data in CSV file causes information loss diff --git a/frappe/core/doctype/report/report.js b/frappe/core/doctype/report/report.js index 14e9b3a901..f78fd3e812 100644 --- a/frappe/core/doctype/report/report.js +++ b/frappe/core/doctype/report/report.js @@ -2,7 +2,9 @@ frappe.ui.form.on('Report', { refresh: function(frm) { if (frm.doc.is_standard === "Yes" && !frappe.boot.developer_mode) { // make the document read-only - frm.set_read_only(); + frm.disable_form(); + } else { + frm.enable_save(); } let doc = frm.doc; @@ -32,8 +34,6 @@ frappe.ui.form.on('Report', { }); }, doc.disabled ? "fa fa-check" : "fa fa-off"); } - - frm.events.report_type(frm); }, ref_doctype: function(frm) { diff --git a/frappe/core/doctype/report/report.py b/frappe/core/doctype/report/report.py index 8b7a03aa28..01c32bcb57 100644 --- a/frappe/core/doctype/report/report.py +++ b/frappe/core/doctype/report/report.py @@ -49,6 +49,10 @@ class Report(Document): self.export_doc() def on_trash(self): + if (self.is_standard == 'Yes' + and not cint(getattr(frappe.local.conf, 'developer_mode', 0)) + and not frappe.flags.in_patch): + frappe.throw(_("You are not allowed to delete Standard Report")) delete_custom_role('report', self.name) def get_columns(self): @@ -57,8 +61,9 @@ class Report(Document): def set_doctype_roles(self): if not self.get('roles') and self.is_standard == 'No': meta = frappe.get_meta(self.ref_doctype) - roles = [{'role': d.role} for d in meta.permissions if d.permlevel==0] - self.set('roles', roles) + if not meta.istable: + roles = [{'role': d.role} for d in meta.permissions if d.permlevel==0] + self.set('roles', roles) def is_permitted(self): """Returns true if Has Role is not set or the user is allowed.""" diff --git a/frappe/core/doctype/report/test_report.py b/frappe/core/doctype/report/test_report.py index 805b903300..d76a1470e4 100644 --- a/frappe/core/doctype/report/test_report.py +++ b/frappe/core/doctype/report/test_report.py @@ -4,6 +4,8 @@ from __future__ import unicode_literals import frappe, json, os import unittest +from frappe.desk.query_report import run, save_report +from frappe.custom.doctype.customize_form.customize_form import reset_customization test_records = frappe.get_test_records('Report') test_dependencies = ['User'] @@ -27,7 +29,57 @@ class TestReport(unittest.TestCase): columns, data = report.get_data(filters={'user': 'Administrator', 'doctype': 'DocType'}) self.assertEqual(columns[0].get('label'), 'Name') self.assertEqual(columns[1].get('label'), 'Module') - self.assertTrue('User' in [d[0] for d in data]) + self.assertTrue('User' in [d.get('name') for d in data]) + + def test_custom_report(self): + reset_customization('User') + custom_report_name = save_report( + 'Permitted Documents For User', + 'Permitted Documents For User Custom', + json.dumps([{ + 'fieldname': 'email', + 'fieldtype': 'Data', + 'label': 'Email', + 'insert_after_index': 0, + 'link_field': 'name', + 'doctype': 'User', + 'options': 'Email', + 'width': 100, + 'id':'email', + 'name': 'Email' + }])) + custom_report = frappe.get_doc('Report', custom_report_name) + columns, result = custom_report.run_query_report( + filters={ + 'user': 'Administrator', + 'doctype': 'User' + }, user=frappe.session.user) + + self.assertListEqual(['email'], [column.get('fieldname') for column in columns]) + admin_dict = frappe.core.utils.find(result, lambda d: d['name'] == 'Administrator') + self.assertDictEqual({'name': 'Administrator', 'user_type': 'System User', 'email': 'admin@example.com'}, admin_dict) + + def test_report_with_custom_column(self): + reset_customization('User') + response = run('Permitted Documents For User', + filters={'user': 'Administrator', 'doctype': 'User'}, + custom_columns=[{ + 'fieldname': 'email', + 'fieldtype': 'Data', + 'label': 'Email', + 'insert_after_index': 0, + 'link_field': 'name', + 'doctype': 'User', + 'options': 'Email', + 'width': 100, + 'id':'email', + 'name': 'Email' + }]) + result = response.get('result') + columns = response.get('columns') + self.assertListEqual(['name', 'email', 'user_type'], [column.get('fieldname') for column in columns]) + admin_dict = frappe.core.utils.find(result, lambda d: d['name'] == 'Administrator') + self.assertDictEqual({'name': 'Administrator', 'user_type': 'System User', 'email': 'admin@example.com'}, admin_dict) def test_report_permissions(self): frappe.set_user('test@example.com') diff --git a/frappe/core/doctype/report_filter/report_filter.json b/frappe/core/doctype/report_filter/report_filter.json index 9d277db11d..964294b96e 100644 --- a/frappe/core/doctype/report_filter/report_filter.json +++ b/frappe/core/doctype/report_filter/report_filter.json @@ -44,7 +44,7 @@ }, { "fieldname": "options", - "fieldtype": "Data", + "fieldtype": "Small Text", "label": "Options" }, { @@ -58,7 +58,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-08-17 16:15:46.937267", + "modified": "2020-12-05 19:20:00.503097", "modified_by": "Administrator", "module": "Core", "name": "Report Filter", diff --git a/frappe/core/doctype/role/patches/v13_set_default_desk_properties.py b/frappe/core/doctype/role/patches/v13_set_default_desk_properties.py new file mode 100644 index 0000000000..375ea02e0e --- /dev/null +++ b/frappe/core/doctype/role/patches/v13_set_default_desk_properties.py @@ -0,0 +1,10 @@ +import frappe +from ..role import desk_properties + +def execute(): + frappe.reload_doctype('role') + for role in frappe.get_all('Role', ['name', 'desk_access']): + role_doc = frappe.get_doc('Role', role.name) + for key in desk_properties: + role_doc.set(key, role_doc.desk_access) + role_doc.save() \ No newline at end of file diff --git a/frappe/core/doctype/role/role.json b/frappe/core/doctype/role/role.json index 0ad15ba10b..e47dc7194b 100644 --- a/frappe/core/doctype/role/role.json +++ b/frappe/core/doctype/role/role.json @@ -13,7 +13,19 @@ "column_break_4", "disabled", "desk_access", - "two_factor_auth" + "two_factor_auth", + "navigation_settings_section", + "search_bar", + "notifications", + "chat", + "list_settings_section", + "list_sidebar", + "bulk_actions", + "view_switcher", + "form_settings_section", + "form_sidebar", + "timeline", + "dashboard" ], "fields": [ { @@ -60,12 +72,82 @@ { "fieldname": "column_break_4", "fieldtype": "Column Break" + }, + { + "fieldname": "navigation_settings_section", + "fieldtype": "Section Break", + "label": "Navigation Settings" + }, + { + "default": "1", + "fieldname": "search_bar", + "fieldtype": "Check", + "label": "Search Bar" + }, + { + "default": "1", + "fieldname": "chat", + "fieldtype": "Check", + "label": "Chat" + }, + { + "fieldname": "list_settings_section", + "fieldtype": "Section Break", + "label": "List Settings" + }, + { + "default": "1", + "fieldname": "list_sidebar", + "fieldtype": "Check", + "label": "Sidebar" + }, + { + "default": "1", + "fieldname": "bulk_actions", + "fieldtype": "Check", + "label": "Bulk Actions" + }, + { + "fieldname": "form_settings_section", + "fieldtype": "Section Break", + "label": "Form Settings" + }, + { + "default": "1", + "fieldname": "form_sidebar", + "fieldtype": "Check", + "label": "Sidebar" + }, + { + "default": "1", + "fieldname": "timeline", + "fieldtype": "Check", + "label": "Timeline" + }, + { + "default": "1", + "fieldname": "dashboard", + "fieldtype": "Check", + "label": "Dashboard" + }, + { + "default": "1", + "fieldname": "view_switcher", + "fieldtype": "Check", + "label": "View Switcher" + }, + { + "default": "1", + "fieldname": "notifications", + "fieldtype": "Check", + "label": "Notifications" } ], "icon": "fa fa-bookmark", "idx": 1, + "index_web_pages_for_search": 1, "links": [], - "modified": "2020-08-06 15:42:59.036960", + "modified": "2020-12-03 14:08:38.181035", "modified_by": "Administrator", "module": "Core", "name": "Role", diff --git a/frappe/core/doctype/role/role.py b/frappe/core/doctype/role/role.py index e458b401e4..7adfeba8d9 100644 --- a/frappe/core/doctype/role/role.py +++ b/frappe/core/doctype/role/role.py @@ -6,6 +6,9 @@ import frappe from frappe.model.document import Document +desk_properties = ("search_bar", "notifications", "chat", "list_sidebar", + "bulk_actions", "view_switcher", "form_sidebar", "timeline", "dashboard") + class Role(Document): def before_rename(self, old, new, merge=False): if old in ("Guest", "Administrator", "System Manager", "All"): @@ -16,11 +19,28 @@ class Role(Document): def validate(self): if self.disabled: - if self.name in ("Guest", "Administrator", "System Manager", "All"): - frappe.throw(frappe._("Standard roles cannot be disabled")) - else: - frappe.db.sql("delete from `tabHas Role` where role = %s", self.name) - frappe.clear_cache() + self.disable_role() + else: + self.set_desk_properties() + + def disable_role(self): + if self.name in ("Guest", "Administrator", "System Manager", "All"): + frappe.throw(frappe._("Standard roles cannot be disabled")) + else: + self.remove_roles() + + def set_desk_properties(self): + # set if desk_access is not allowed, unset all desk properties + if self.name == 'Guest': + self.desk_access = 0 + + if not self.desk_access: + for key in desk_properties: + self.set(key, 0) + + def remove_roles(self): + frappe.db.sql("delete from `tabHas Role` where role = %s", self.name) + frappe.clear_cache() def on_update(self): '''update system user desk access if this has changed in this update''' @@ -37,7 +57,7 @@ class Role(Document): def get_info_based_on_role(role, field='email'): ''' Get information of all users that have been assigned this role ''' users = frappe.get_list("Has Role", filters={"role": role, "parenttype": "User"}, - fields=["parent"]) + fields=["parent as user_name"]) return get_user_info(users, field) @@ -45,7 +65,7 @@ def get_user_info(users, field='email'): ''' Fetch details about users for the specified field ''' info_list = [] for user in users: - user_info, enabled = frappe.db.get_value("User", user.parent, [field, "enabled"]) + user_info, enabled = frappe.db.get_value("User", user.get("user_name"), [field, "enabled"]) if enabled and user_info not in ["admin@example.com", "guest@example.com"]: info_list.append(user_info) return info_list diff --git a/frappe/core/doctype/role_permission_for_page_and_report/role_permission_for_page_and_report.js b/frappe/core/doctype/role_permission_for_page_and_report/role_permission_for_page_and_report.js index 8a121f31ae..5048d24077 100644 --- a/frappe/core/doctype/role_permission_for_page_and_report/role_permission_for_page_and_report.js +++ b/frappe/core/doctype/role_permission_for_page_and_report/role_permission_for_page_and_report.js @@ -3,32 +3,32 @@ frappe.ui.form.on('Role Permission for Page and Report', { setup: function(frm) { - frm.trigger("set_queries") + frm.trigger("set_queries"); }, refresh: function(frm) { frm.disable_save(); frm.role_area.hide(); - frm.events.add_custom_buttons(frm); + frm.events.setup_buttons(frm); }, - add_custom_buttons: function(frm) { + setup_buttons: function(frm) { frm.clear_custom_buttons(); - if(frm.doc.set_role_for && frm.doc[frappe.model.scrub(frm.doc.set_role_for)]) { + frm.page.clear_actions(); + if (frm.doc.set_role_for && frm.doc[frappe.model.scrub(frm.doc.set_role_for)]) { frm.add_custom_button(__("Reset to defaults"), function() { frm.trigger("reset_roles"); }); - frm.add_custom_button(__("Update"), function() { + frm.page.set_primary_action(__("Update"), () => { frm.trigger("update_report_page_data"); - }).addClass('btn-primary'); + }); } }, onload: function(frm) { - if(!frm.roles_editor) { - frm.role_area = $('
    ') - .appendTo(frm.fields_dict.roles_html.wrapper); + if (!frm.roles_editor) { + frm.role_area = $(frm.fields_dict.roles_html.wrapper); frm.roles_editor = new frappe.RoleEditor(frm.role_area, frm); } }, @@ -54,17 +54,17 @@ frappe.ui.form.on('Role Permission for Page and Report', { }, page: function(frm) { - frm.events.add_custom_buttons(frm); - if(frm.doc.page) { + frm.events.setup_buttons(frm); + if (frm.doc.page) { frm.trigger("set_report_page_data"); } else { frm.trigger("set_role_for"); } }, - report: function(frm){ - frm.events.add_custom_buttons(frm); - if(frm.doc.report) { + report: function(frm) { + frm.events.setup_buttons(frm); + if (frm.doc.report) { frm.trigger("set_report_page_data"); } else { frm.trigger("set_role_for"); diff --git a/frappe/core/doctype/role_profile/role_profile.js b/frappe/core/doctype/role_profile/role_profile.js index d31618cc4a..e43980770a 100644 --- a/frappe/core/doctype/role_profile/role_profile.js +++ b/frappe/core/doctype/role_profile/role_profile.js @@ -3,20 +3,18 @@ frappe.ui.form.on('Role Profile', { refresh: function(frm) { - if(has_common(frappe.user_roles, ["Administrator", "System Manager"])) { - if(!frm.roles_editor) { - var role_area = $('
    ') - .appendTo(frm.fields_dict.roles_html.wrapper); + if (has_common(frappe.user_roles, ["Administrator", "System Manager"])) { + if (!frm.roles_editor) { + const role_area = $(frm.fields_dict.roles_html.wrapper); frm.roles_editor = new frappe.RoleEditor(role_area, frm); - frm.roles_editor.show(); - } else { - frm.roles_editor.show(); } + frm.roles_editor.show(); + } }, validate: function(frm) { - if(frm.roles_editor) { + if (frm.roles_editor) { frm.roles_editor.set_roles_in_table(); } } diff --git a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json index 2a9c1a4573..d4d79b21fb 100644 --- a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json +++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json @@ -36,7 +36,7 @@ }, { "default": "0", - "depends_on": "eval:doc.queue==='All'", + "depends_on": "eval:doc.frequency==='All'", "fieldname": "create_log", "fieldtype": "Check", "label": "Create Log" @@ -49,7 +49,7 @@ }, { "allow_in_quick_entry": 1, - "depends_on": "eval:doc.queue==='Cron'", + "depends_on": "eval:doc.frequency==='Cron'", "fieldname": "cron_format", "fieldtype": "Data", "label": "Cron Format", @@ -81,7 +81,7 @@ "link_fieldname": "scheduled_job_type" } ], - "modified": "2020-04-05 17:27:33.480562", + "modified": "2020-10-07 10:39:24.519460", "modified_by": "Administrator", "module": "Core", "name": "Scheduled Job Type", diff --git a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py index fa854f579e..92493a593a 100644 --- a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py +++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py @@ -3,6 +3,7 @@ # For license information, please see license.txt from __future__ import unicode_literals +from typing import Dict, List import frappe, json from frappe.model.document import Document @@ -11,18 +12,19 @@ from datetime import datetime from croniter import croniter from frappe.utils.background_jobs import enqueue, get_jobs + class ScheduledJobType(Document): def autoname(self): - self.name = '.'.join(self.method.split('.')[-2:]) + self.name = ".".join(self.method.split(".")[-2:]) def validate(self): - if self.frequency != 'All': + if self.frequency != "All": # force logging for all events other than continuous ones (ALL) self.create_log = 1 - def enqueue(self): + def enqueue(self, force=False): # enqueue event if last execution is done - if self.is_event_due(): + if self.is_event_due() or force: if frappe.flags.enqueued_jobs: frappe.flags.enqueued_jobs.append(self.method) @@ -84,7 +86,7 @@ class ScheduledJobType(Document): def log_status(self, status): # log file - frappe.logger("scheduler").info('Scheduled Job {0}: {1} for {2}'.format(status, self.method, frappe.local.site)) + frappe.logger("scheduler").info(f"Scheduled Job {status}: {self.method} for {frappe.local.site}") self.update_scheduler_log(status) def update_scheduler_log(self, status): @@ -111,28 +113,29 @@ class ScheduledJobType(Document): @frappe.whitelist() -def execute_event(doc): - frappe.only_for('System Manager') +def execute_event(doc: str): + frappe.only_for("System Manager") doc = json.loads(doc) - frappe.get_doc('Scheduled Job Type', doc.get('name')).enqueue() + frappe.get_doc("Scheduled Job Type", doc.get("name")).enqueue(force=True) + return doc -def run_scheduled_job(job_type): - '''This is a wrapper function that runs a hooks.scheduler_events method''' +def run_scheduled_job(job_type: str): + """This is a wrapper function that runs a hooks.scheduler_events method""" try: - frappe.get_doc('Scheduled Job Type', dict(method=job_type)).execute() + frappe.get_doc("Scheduled Job Type", dict(method=job_type)).execute() except Exception: print(frappe.get_traceback()) -def sync_jobs(hooks=None): +def sync_jobs(hooks: Dict = None): frappe.reload_doc("core", "doctype", "scheduled_job_type") scheduler_events = hooks or frappe.get_hooks("scheduler_events") all_events = insert_events(scheduler_events) clear_events(all_events) -def insert_events(scheduler_events): +def insert_events(scheduler_events: Dict) -> List: cron_jobs, event_jobs = [], [] for event_type in scheduler_events: events = scheduler_events.get(event_type) @@ -144,7 +147,7 @@ def insert_events(scheduler_events): return cron_jobs + event_jobs -def insert_cron_jobs(events): +def insert_cron_jobs(events: Dict) -> List: cron_jobs = [] for cron_format in events: for event in events.get(cron_format): @@ -153,25 +156,29 @@ def insert_cron_jobs(events): return cron_jobs -def insert_event_jobs(events, event_type): +def insert_event_jobs(events: List, event_type: str) -> List: event_jobs = [] for event in events: event_jobs.append(event) - frequency = event_type.replace('_', ' ').title() + frequency = event_type.replace("_", " ").title() insert_single_event(frequency, event) return event_jobs -def insert_single_event(frequency, event, cron_format=None): +def insert_single_event(frequency: str, event: str, cron_format: str = None): cron_expr = {"cron_format": cron_format} if cron_format else {} - doc = frappe.get_doc({ - "doctype": "Scheduled Job Type", - "method": event, - "cron_format": cron_format, - "frequency": frequency - }) + doc = frappe.get_doc( + { + "doctype": "Scheduled Job Type", + "method": event, + "cron_format": cron_format, + "frequency": frequency, + } + ) - if not frappe.db.exists("Scheduled Job Type", {"method": event, "frequency": frequency, **cron_expr }): + if not frappe.db.exists( + "Scheduled Job Type", {"method": event, "frequency": frequency, **cron_expr} + ): try: doc.insert() except frappe.DuplicateEntryError: @@ -179,7 +186,12 @@ def insert_single_event(frequency, event, cron_format=None): doc.insert() -def clear_events(all_events): - for event in frappe.get_all("Scheduled Job Type", ("name", "method")): - if event.method not in all_events: +def clear_events(all_events: List): + for event in frappe.get_all( + "Scheduled Job Type", fields=["name", "method", "server_script"] + ): + is_server_script = event.server_script + is_defined_in_hooks = event.method in all_events + + if not (is_defined_in_hooks or is_server_script): frappe.delete_doc("Scheduled Job Type", event.name) diff --git a/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py b/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py index e7db6f9045..d0a65defa4 100644 --- a/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py +++ b/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py @@ -22,7 +22,7 @@ class TestScheduledJobType(unittest.TestCase): self.assertEqual(all_job.frequency, 'All') daily_job = frappe.get_doc('Scheduled Job Type', - dict(method='frappe.email.queue.clear_outbox')) + dict(method='frappe.email.queue.set_expiry_for_email_queue')) self.assertEqual(daily_job.frequency, 'Daily') # check if cron jobs are synced @@ -38,7 +38,7 @@ class TestScheduledJobType(unittest.TestCase): self.assertEqual(updated_scheduled_job.frequency, "Hourly") def test_daily_job(self): - job = frappe.get_doc('Scheduled Job Type', dict(method = 'frappe.email.queue.clear_outbox')) + job = frappe.get_doc('Scheduled Job Type', dict(method = 'frappe.email.queue.set_expiry_for_email_queue')) job.db_set('last_execution', '2019-01-01 00:00:00') self.assertTrue(job.is_event_due(get_datetime('2019-01-02 00:00:06'))) self.assertFalse(job.is_event_due(get_datetime('2019-01-01 00:00:06'))) diff --git a/frappe/core/doctype/server_script/server_script.js b/frappe/core/doctype/server_script/server_script.js index 78ef2d0509..95a63780f8 100644 --- a/frappe/core/doctype/server_script/server_script.js +++ b/frappe/core/doctype/server_script/server_script.js @@ -6,71 +6,40 @@ frappe.ui.form.on('Server Script', { frm.trigger('setup_help'); }, refresh: function(frm) { - if (frm.doc.script_type === 'Scheduler Event' && !frm.doc.disabled) { - frm.add_custom_button('Schedule Script', function() { - var d = new frappe.ui.Dialog({ - title: "Schedule Script Execution", - fields: [ - { - fieldname: "event_type", - label: __('Select Event Type'), - fieldtype: "Select", - options: "All\nHourly\nDaily\nWeekly\nMonthly\nYearly\nHourly Long\nDaily Long\nWeekly Long\nMonthly Long" - }, - ], - primary_action_label: __('Schedule Script'), - primary_action: () => { - d.get_primary_btn().attr('disabled', true); - var data = d.get_values(); - d.hide(); - if(data) { - frm.events.schedule_script(frm, data); - } - - } - }); - - d.show(); - - }); + if (frm.doc.script_type != 'Scheduler Event') { + frm.dashboard.hide(); } }, - schedule_script(frm, data) { - frm.call({ - method: "frappe.core.doctype.server_script.server_script.setup_scheduler_events", - args: { - 'script_name': frm.doc.name, - 'frequency': data.event_type - } - }); - }, - setup_help(frm) { frm.get_field('help_html').html(` -

    Examples

    DocType Event

    -
    
    +

    Add logic for standard doctype events like Before Insert, After Submit, etc.

    +
    +	
     # set property
     if "test" in doc.description:
    -    doc.status = 'Closed'
    +	doc.status = 'Closed'
     
     
     # validate
     if "validate" in doc.description:
    -    raise frappe.ValidationError
    +	raise frappe.ValidationError
     
     # auto create another document
    -if doc.allocted_to:
    -    frappe.get_doc(dict(
    -        doctype = 'ToDo'
    -        owner = doc.allocated_to,
    -        description = doc.subject
    -    )).insert()
    -
    +if doc.allocated_to: + frappe.get_doc(dict( + doctype = 'ToDo' + owner = doc.allocated_to, + description = doc.subject + )).insert() +
    +
    +

    API Call

    +

    Respond to /api/method/<method-name> calls, just like whitelisted methods

    
     # respond to API
     
    @@ -79,6 +48,21 @@ if frappe.form_dict.message == "ping":
     else:
     	frappe.response['message'] = "ok"
     
    + +
    + +

    Permission Query

    +

    Add conditions to the where clause of list queries.

    +
    
    +# generate dynamic conditions and set it in the conditions variable
    +tenant_id = frappe.db.get_value(...)
    +conditions = 'tenant_id = {}'.format(tenant_id)
    +
    +# resulting select query
    +select name from \`tabPerson\`
    +where tenant_id = 2
    +order by creation desc
    +
    `); } diff --git a/frappe/core/doctype/server_script/server_script.json b/frappe/core/doctype/server_script/server_script.json index cc3995ad1d..b7e49673f8 100644 --- a/frappe/core/doctype/server_script/server_script.json +++ b/frappe/core/doctype/server_script/server_script.json @@ -8,6 +8,7 @@ "field_order": [ "script_type", "reference_doctype", + "event_frequency", "doctype_event", "api_method", "allow_guest", @@ -24,17 +25,18 @@ "fieldtype": "Select", "in_list_view": 1, "label": "Script Type", - "options": "DocType Event\nScheduler Event\nAPI", + "options": "DocType Event\nScheduler Event\nPermission Query\nAPI", "reqd": 1 }, { "fieldname": "script", "fieldtype": "Code", "label": "Script", + "options": "Python", "reqd": 1 }, { - "depends_on": "eval:doc.script_type==='DocType Event'", + "depends_on": "eval:['DocType Event', 'Permission Query'].includes(doc.script_type)", "fieldname": "reference_doctype", "fieldtype": "Link", "in_list_view": 1, @@ -46,7 +48,7 @@ "fieldname": "doctype_event", "fieldtype": "Select", "label": "DocType Event", - "options": "Before Insert\nBefore Save\nAfter Save\nBefore Submit\nAfter Submit\nBefore Cancel\nAfter Cancel\nBefore Delete\nAfter Delete\nBefore Save (Submitted Document)\nAfter Save (Submitted Document)" + "options": "Before Insert\nBefore Validate\nBefore Save\nAfter Save\nBefore Submit\nAfter Submit\nBefore Cancel\nAfter Cancel\nBefore Delete\nAfter Delete\nBefore Save (Submitted Document)\nAfter Save (Submitted Document)" }, { "depends_on": "eval:doc.script_type==='API'", @@ -83,11 +85,24 @@ { "fieldname": "help_html", "fieldtype": "HTML" + }, + { + "depends_on": "eval:doc.script_type == \"Scheduler Event\"", + "fieldname": "event_frequency", + "fieldtype": "Select", + "label": "Event Frequency", + "mandatory_depends_on": "eval:doc.script_type == \"Scheduler Event\"", + "options": "All\nHourly\nDaily\nWeekly\nMonthly\nYearly\nHourly Long\nDaily Long\nWeekly Long\nMonthly Long" } ], "index_web_pages_for_search": 1, - "links": [], - "modified": "2020-08-24 16:44:41.060350", + "links": [ + { + "link_doctype": "Scheduled Job Type", + "link_fieldname": "server_script" + } + ], + "modified": "2021-02-18 12:36:19.803425", "modified_by": "Administrator", "module": "Core", "name": "Server Script", diff --git a/frappe/core/doctype/server_script/server_script.py b/frappe/core/doctype/server_script/server_script.py index 839b784651..8838d9e954 100644 --- a/frappe/core/doctype/server_script/server_script.py +++ b/frappe/core/doctype/server_script/server_script.py @@ -4,6 +4,9 @@ from __future__ import unicode_literals +import ast +from typing import Dict, List + import frappe from frappe.model.document import Document from frappe.utils.safe_exec import safe_exec @@ -11,62 +14,147 @@ from frappe import _ class ServerScript(Document): - @staticmethod - def validate(): - frappe.only_for('Script Manager', True) + def validate(self): + frappe.only_for("Script Manager", True) + self.validate_script() + self.sync_scheduled_jobs() + self.clear_scheduled_events() - @staticmethod - def on_update(): - frappe.cache().delete_value('server_script_map') + def on_update(self): + frappe.cache().delete_value("server_script_map") + self.sync_scheduler_events() - def execute_method(self): - if self.script_type == 'API': - # validate if guest is allowed - if frappe.session.user == 'Guest' and not self.allow_guest: - raise frappe.PermissionError - _globals, _locals = safe_exec(self.script) - return _globals.frappe.flags # output can be stored in flags - else: - # wrong report type! + def on_trash(self): + if self.script_type == "Scheduler Event": + for job in self.scheduled_jobs: + frappe.delete_doc("Scheduled Job Type", job.name) + + @property + def scheduled_jobs(self) -> List[Dict[str, str]]: + return frappe.get_all( + "Scheduled Job Type", + filters={"server_script": self.name}, + fields=["name", "stopped"], + ) + + def validate_script(self): + """Utilizes the ast module to check for syntax errors + """ + ast.parse(self.script) + + def sync_scheduled_jobs(self): + """Sync Scheduled Job Type statuses if Server Script's disabled status is changed + """ + if self.script_type != "Scheduler Event" or not self.has_value_changed("disabled"): + return + + for scheduled_job in self.scheduled_jobs: + if bool(scheduled_job.stopped) != bool(self.disabled): + job = frappe.get_doc("Scheduled Job Type", scheduled_job.name) + job.stopped = self.disabled + job.save() + + def sync_scheduler_events(self): + """Create or update Scheduled Job Type documents for Scheduler Event Server Scripts + """ + if not self.disabled and self.event_frequency and self.script_type == "Scheduler Event": + setup_scheduler_events(script_name=self.name, frequency=self.event_frequency) + + def clear_scheduled_events(self): + """Deletes existing scheduled jobs by Server Script if self.event_frequency has changed + """ + if self.script_type == "Scheduler Event" and self.has_value_changed("event_frequency"): + for scheduled_job in self.scheduled_jobs: + frappe.delete_doc("Scheduled Job Type", scheduled_job.name) + + def execute_method(self) -> Dict: + """Specific to API endpoint Server Scripts + + Raises: + frappe.DoesNotExistError: If self.script_type is not API + frappe.PermissionError: If self.allow_guest is unset for API accessed by Guest user + + Returns: + dict: Evaluates self.script with frappe.utils.safe_exec.safe_exec and returns the flags set in it's safe globals + """ + # wrong report type! + if self.script_type != "API": raise frappe.DoesNotExistError - def execute_doc(self, doc): - # execute event - safe_exec(self.script, None, dict(doc = doc)) + # validate if guest is allowed + if frappe.session.user == "Guest" and not self.allow_guest: + raise frappe.PermissionError + + # output can be stored in flags + _globals, _locals = safe_exec(self.script) + return _globals.frappe.flags + + def execute_doc(self, doc: Document): + """Specific to Document Event triggered Server Scripts + + Args: + doc (Document): Executes script with for a certain document's events + """ + safe_exec(self.script, _locals={"doc": doc}) def execute_scheduled_method(self): - if self.script_type == 'Scheduler Event': - safe_exec(self.script) - else: - # wrong report type! + """Specific to Scheduled Jobs via Server Scripts + + Raises: + frappe.DoesNotExistError: If script type is not a scheduler event + """ + if self.script_type != "Scheduler Event": raise frappe.DoesNotExistError + safe_exec(self.script) + + def get_permission_query_conditions(self, user: str) -> List[str]: + """Specific to Permission Query Server Scripts + + Args: + user (str): Takes user email to execute script and return list of conditions + + Returns: + list: Returns list of conditions defined by rules in self.script + """ + locals = {"user": user, "conditions": ""} + safe_exec(self.script, None, locals) + if locals["conditions"]: + return locals["conditions"] + + @frappe.whitelist() def setup_scheduler_events(script_name, frequency): - method = frappe.scrub('{0}-{1}'.format(script_name, frequency)) - scheduled_script = frappe.db.get_value('Scheduled Job Type', - dict(method=method)) + """Creates or Updates Scheduled Job Type documents based on the specified script name and frequency + + Args: + script_name (str): Name of the Server Script document + frequency (str): Event label compatible with the Frappe scheduler + """ + method = frappe.scrub(f"{script_name}-{frequency}") + scheduled_script = frappe.db.get_value("Scheduled Job Type", {"method": method}) if not scheduled_script: - doc = frappe.get_doc(dict( - doctype = 'Scheduled Job Type', - method = method, - frequency = frequency, - server_script = script_name - )) + frappe.get_doc( + { + "doctype": "Scheduled Job Type", + "method": method, + "frequency": frequency, + "server_script": script_name, + } + ).insert() - doc.insert() - - frappe.msgprint(_('Enabled scheduled execution for script {0}').format(script_name)) + frappe.msgprint(_("Enabled scheduled execution for script {0}").format(script_name)) else: - doc = frappe.get_doc('Scheduled Job Type', scheduled_script) - doc.update(dict( - doctype = 'Scheduled Job Type', - method = method, - frequency = frequency, - server_script = script_name - )) + doc = frappe.get_doc("Scheduled Job Type", scheduled_script) + + if doc.frequency == frequency: + return + + doc.frequency = frequency doc.save() - frappe.msgprint(_('Scheduled execution for script {0} has updated').format(script_name)) + frappe.msgprint( + _("Scheduled execution for script {0} has updated").format(script_name) + ) diff --git a/frappe/core/doctype/server_script/server_script_utils.py b/frappe/core/doctype/server_script/server_script_utils.py index e03504f30b..12a8fa47fa 100644 --- a/frappe/core/doctype/server_script/server_script_utils.py +++ b/frappe/core/doctype/server_script/server_script_utils.py @@ -6,6 +6,7 @@ import frappe EVENT_MAP = { 'before_insert': 'Before Insert', 'after_insert': 'After Insert', + 'before_validate': 'Before Validate', 'validate': 'Before Save', 'on_update': 'After Save', 'before_submit': 'Before Submit', @@ -50,6 +51,9 @@ def get_server_script_map(): # }, # '_api': { # '[path]': '[server script]' + # }, + # 'permission_query': { + # 'DocType': '[server script]' # } # } if frappe.flags.in_patch and not frappe.db.table_exists('Server Script'): @@ -57,16 +61,20 @@ def get_server_script_map(): script_map = frappe.cache().get_value('server_script_map') if script_map is None: - script_map = {} + script_map = { + 'permission_query': {} + } enabled_server_scripts = frappe.get_all('Server Script', fields=('name', 'reference_doctype', 'doctype_event','api_method', 'script_type'), filters={'disabled': 0}) for script in enabled_server_scripts: if script.script_type == 'DocType Event': script_map.setdefault(script.reference_doctype, {}).setdefault(script.doctype_event, []).append(script.name) + elif script.script_type == 'Permission Query': + script_map['permission_query'][script.reference_doctype] = script.name else: script_map.setdefault('_api', {})[script.api_method] = script.name frappe.cache().set_value('server_script_map', script_map) - return script_map \ No newline at end of file + return script_map diff --git a/frappe/core/doctype/server_script/test_server_script.py b/frappe/core/doctype/server_script/test_server_script.py index 3356e584af..aac8b3deed 100644 --- a/frappe/core/doctype/server_script/test_server_script.py +++ b/frappe/core/doctype/server_script/test_server_script.py @@ -45,6 +45,22 @@ frappe.response['message'] = 'hello' allow_guest = 1, script = ''' frappe.flags = 'hello' +''' + ), + dict( + name='test_permission_query', + script_type = 'Permission Query', + reference_doctype = 'ToDo', + script = ''' +conditions = '1 = 1' +'''), + dict( + name='test_invalid_namespace_method', + script_type = 'DocType Event', + doctype_event = 'Before Insert', + reference_doctype = 'Note', + script = ''' +frappe.method_that_doesnt_exist("do some magic") ''' ) ] @@ -65,6 +81,7 @@ class TestServerScript(unittest.TestCase): def tearDownClass(cls): frappe.db.commit() frappe.db.sql('truncate `tabServer Script`') + frappe.cache().delete_value('server_script_map') def setUp(self): frappe.cache().delete_value('server_script_map') @@ -85,3 +102,12 @@ class TestServerScript(unittest.TestCase): def test_api_return(self): 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(isinstance(frappe.db.get_list('ToDo'), list)) + + def test_attribute_error(self): + """Raise AttributeError if method not found in Namespace""" + note = frappe.get_doc({"doctype": "Note", "title": "Test Note: Server Script"}) + self.assertRaises(AttributeError, note.insert) diff --git a/frappe/core/doctype/sms_settings/sms_settings.json b/frappe/core/doctype/sms_settings/sms_settings.json index 3bb89604af..073fb88bc7 100755 --- a/frappe/core/doctype/sms_settings/sms_settings.json +++ b/frappe/core/doctype/sms_settings/sms_settings.json @@ -202,7 +202,7 @@ "issingle": 1, "istable": 0, "max_attachments": 0, - "modified": "2017-11-01 12:57:20.943845", + "modified": "2021-03-02 18:06:00.868688", "modified_by": "Administrator", "module": "Core", "name": "SMS Settings", @@ -233,6 +233,6 @@ "read_only": 0, "read_only_onload": 0, "show_name_in_global_search": 0, - "track_changes": 0, + "track_changes": 1, "track_seen": 0 -} \ No newline at end of file +} diff --git a/frappe/core/doctype/system_settings/system_settings.js b/frappe/core/doctype/system_settings/system_settings.js index b6514dea9f..c0c9074cbc 100644 --- a/frappe/core/doctype/system_settings/system_settings.js +++ b/frappe/core/doctype/system_settings/system_settings.js @@ -1,37 +1,36 @@ -frappe.ui.form.on("System Settings", "refresh", function(frm) { - frappe.call({ - method: "frappe.core.doctype.system_settings.system_settings.load", - callback: function(data) { - frappe.all_timezones = data.message.timezones; - frm.set_df_property("time_zone", "options", frappe.all_timezones); +frappe.ui.form.on("System Settings", { + refresh: function(frm) { + frappe.call({ + method: "frappe.core.doctype.system_settings.system_settings.load", + callback: function(data) { + frappe.all_timezones = data.message.timezones; + frm.set_df_property("time_zone", "options", frappe.all_timezones); - $.each(data.message.defaults, function(key, val) { - frm.set_value(key, val); - frappe.sys_defaults[key] = val; - }) + $.each(data.message.defaults, function(key, val) { + frm.set_value(key, val); + frappe.sys_defaults[key] = val; + }); + } + }); + }, + enable_password_policy: function(frm) { + if (frm.doc.enable_password_policy == 0) { + frm.set_value("minimum_password_score", ""); + } else { + frm.set_value("minimum_password_score", "2"); } - }); -}); - -frappe.ui.form.on("System Settings", "enable_password_policy", function(frm) { - if(frm.doc.enable_password_policy == 0){ - frm.set_value("minimum_password_score", ""); - } else { - frm.set_value("minimum_password_score", "2"); - } -}); - -frappe.ui.form.on("System Settings", "enable_two_factor_auth", function(frm) { - if(frm.doc.enable_two_factor_auth == 0){ - frm.set_value("bypass_2fa_for_retricted_ip_users", 0); - frm.set_value("bypass_restrict_ip_check_if_2fa_enabled", 0); - } -}); - -frappe.ui.form.on("System Settings", "enable_prepared_report_auto_deletion", function(frm) { - if (frm.doc.enable_prepared_report_auto_deletion) { - if (!frm.doc.prepared_report_expiry_period) { - frm.set_value('prepared_report_expiry_period', 7); + }, + enable_two_factor_auth: function(frm) { + if (frm.doc.enable_two_factor_auth == 0) { + frm.set_value("bypass_2fa_for_retricted_ip_users", 0); + frm.set_value("bypass_restrict_ip_check_if_2fa_enabled", 0); + } + }, + enable_prepared_report_auto_deletion: function(frm) { + if (frm.doc.enable_prepared_report_auto_deletion) { + if (!frm.doc.prepared_report_expiry_period) { + frm.set_value('prepared_report_expiry_period', 7); + } } } }); diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index 17f97b3e1a..13dbc32620 100644 --- a/frappe/core/doctype/system_settings/system_settings.json +++ b/frappe/core/doctype/system_settings/system_settings.json @@ -6,6 +6,7 @@ "engine": "InnoDB", "field_order": [ "localization", + "app_name", "country", "language", "column_break_3", @@ -37,6 +38,7 @@ "allow_login_using_mobile_number", "allow_login_using_user_name", "allow_error_traceback", + "strip_exif_metadata_from_uploaded_images", "password_settings", "logout_on_password_reset", "force_user_to_reset_password", @@ -356,7 +358,7 @@ "collapsible": 1, "fieldname": "email", "fieldtype": "Section Break", - "label": "EMail" + "label": "Email" }, { "description": "Your organization name and address for the email footer.", @@ -460,12 +462,31 @@ "fieldname": "prepared_report_section", "fieldtype": "Section Break", "label": "Prepared Report" + }, + { + "default": "Frappe", + "fieldname": "app_name", + "fieldtype": "Data", + "label": "App Name" + }, + { + "default": "3", + "description": "Hourly rate limit for generating password reset links", + "fieldname": "password_reset_limit", + "fieldtype": "Int", + "label": "Password Reset Link Generation Limit" + }, + { + "default": "1", + "fieldname": "strip_exif_metadata_from_uploaded_images", + "fieldtype": "Check", + "label": "Strip EXIF tags from uploaded images" } ], "icon": "fa fa-cog", "issingle": 1, "links": [], - "modified": "2020-08-12 14:35:45.214327", + "modified": "2020-12-30 18:52:22.161391", "modified_by": "Administrator", "module": "Core", "name": "System Settings", @@ -483,4 +504,4 @@ "sort_field": "modified", "sort_order": "ASC", "track_changes": 1 -} \ No newline at end of file +} diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py index fb1fa4aff9..d16db5fecd 100644 --- a/frappe/core/doctype/user/test_user.py +++ b/frappe/core/doctype/user/test_user.py @@ -2,7 +2,7 @@ # MIT License. See license.txt from __future__ import unicode_literals -import frappe, unittest +import frappe, unittest, uuid from frappe.model.delete_doc import delete_doc from frappe.utils.data import today, add_to_date @@ -20,6 +20,7 @@ class TestUser(unittest.TestCase): frappe.db.set_value("System Settings", "System Settings", "enable_password_policy", 0) frappe.db.set_value("System Settings", "System Settings", "minimum_password_score", "") frappe.db.set_value("System Settings", "System Settings", "password_reset_limit", 3) + frappe.set_user('Administrator') def test_user_type(self): new_user = frappe.get_doc(dict(doctype='User', email='test-for-type@example.com', @@ -106,13 +107,17 @@ class TestUser(unittest.TestCase): frappe.set_user("testperm@example.com") me = frappe.get_doc("User", "testperm@example.com") - self.assertRaises(frappe.PermissionError, me.add_roles, "System Manager") + me.add_roles("System Manager") + + # system manager is not added (it is reset) + self.assertFalse('System Manager' in [d.role for d in me.roles]) frappe.set_user("Administrator") me = frappe.get_doc("User", "testperm@example.com") me.add_roles("System Manager") + # system manager now added by Administrator self.assertTrue("System Manager" in [d.role for d in me.get("roles")]) # def test_deny_multiple_sessions(self): @@ -235,6 +240,29 @@ class TestUser(unittest.TestCase): self.assertRaises(frappe.ValidationError, user.reset_password, False) + def test_user_rollback(self): + """ """ + frappe.db.commit() + frappe.db.begin() + user_id = str(uuid.uuid4()) + email = f'{user_id}@example.com' + try: + frappe.flags.in_import = True # disable throttling + frappe.get_doc(dict( + doctype='User', + email=email, + first_name=user_id, + )).insert() + finally: + frappe.flags.in_import = False + + # Check user has been added + self.assertIsNotNone(frappe.db.get("User", {"email": email})) + + # Check that rollback works + frappe.db.rollback() + self.assertIsNone(frappe.db.get("User", {"email": email})) + def delete_contact(user): frappe.db.sql("DELETE FROM `tabContact` WHERE `email_id`= %s", user) diff --git a/frappe/core/doctype/user/user.css b/frappe/core/doctype/user/user.css deleted file mode 100644 index ec17030060..0000000000 --- a/frappe/core/doctype/user/user.css +++ /dev/null @@ -1,28 +0,0 @@ -.user-role { - padding: 5px; - width: 50%; - float: left; -} - -table.user-perm { - border-collapse: collapse; - width: 100%; - overflow-x: scroll; - -webkit-overflow-scrolling: touch; - -ms-overflow-style: -ms-autohiding-scrollbar; -} - -table.user-perm td, table.user-perm th { - padding: 5px; - text-align: center; - border-bottom: 1px solid #aaa; - min-width: 30px; -} - -.module-block-list .checkbox { - margin-bottom: 0px; -} - -.module-block-list .checkbox label { - width: 100%; -} diff --git a/frappe/core/doctype/user/user.js b/frappe/core/doctype/user/user.js index b8e16bfe25..3548b4c913 100644 --- a/frappe/core/doctype/user/user.js +++ b/frappe/core/doctype/user/user.js @@ -27,7 +27,7 @@ frappe.ui.form.on('User', { }, callback: function(data) { frm.set_value("roles", []); - $.each(data.message || [], function(i, v){ + $.each(data.message || [], function(i, v) { var d = frm.add_child("roles"); d.role = v.role; }); @@ -37,16 +37,35 @@ frappe.ui.form.on('User', { } }, + module_profile: function(frm) { + if (frm.doc.module_profile) { + frappe.call({ + "method": "frappe.core.doctype.user.user.get_module_profile", + args: { + module_profile: frm.doc.module_profile + }, + callback: function(data) { + frm.set_value("block_modules", []); + $.each(data.message || [], function(i, v) { + let d = frm.add_child("block_modules"); + d.module = v.module; + }); + frm.module_editor && frm.module_editor.refresh(); + } + }); + } + }, + onload: function(frm) { frm.can_edit_roles = has_access_to_edit_user(); - if(frm.can_edit_roles && !frm.is_new()) { - if(!frm.roles_editor) { - var role_area = $('
    ') + if (frm.can_edit_roles && !frm.is_new()) { + if (!frm.roles_editor) { + const role_area = $('
    ') .appendTo(frm.fields_dict.roles_html.wrapper); frm.roles_editor = new frappe.RoleEditor(role_area, frm, frm.doc.role_profile_name ? 1 : 0); - var module_area = $('
    ') + var module_area = $('
    ') .appendTo(frm.fields_dict.modules_html.wrapper); frm.module_editor = new frappe.ModuleEditor(frm, module_area); } else { @@ -255,43 +274,3 @@ function get_roles_for_editing_user() { .filter(perm => perm.permlevel >= 1 && perm.write) .map(perm => perm.role) || ['System Manager']; } - -frappe.ModuleEditor = Class.extend({ - init: function(frm, wrapper) { - this.wrapper = $('
    ').appendTo(wrapper); - this.frm = frm; - this.make(); - }, - make: function() { - var me = this; - this.frm.doc.__onload.all_modules.forEach(function(m) { - $(repl('
    \ -
    ', {module: m})).appendTo(me.wrapper); - }); - this.bind(); - }, - refresh: function() { - var me = this; - this.wrapper.find(".block-module-check").prop("checked", true); - $.each(this.frm.doc.block_modules, function(i, d) { - me.wrapper.find(".block-module-check[data-module='"+ d.module +"']").prop("checked", false); - }); - }, - bind: function() { - var me = this; - this.wrapper.on("change", ".block-module-check", function() { - var module = $(this).attr('data-module'); - if($(this).prop("checked")) { - // remove from block_modules - me.frm.doc.block_modules = $.map(me.frm.doc.block_modules || [], function(d) { - if (d.module != module) { - return d; - } - }); - } else { - me.frm.add_child("block_modules", {"module": module}); - } - }); - } -}); diff --git a/frappe/core/doctype/user/user.json b/frappe/core/doctype/user/user.json index 2073f41fdd..747ace5de6 100644 --- a/frappe/core/doctype/user/user.json +++ b/frappe/core/doctype/user/user.json @@ -7,20 +7,20 @@ "doctype": "DocType", "engine": "InnoDB", "field_order": [ - "sb0_5", "enabled", "section_break_3", "email", - "first_name", - "middle_name", "last_name", + "language", + "column_break0", + "first_name", "full_name", + "time_zone", + "column_break_11", + "middle_name", + "username", "send_welcome_email", "unsubscribed", - "column_break0", - "username", - "language", - "time_zone", "user_image", "sb1", "role_profile_name", @@ -28,15 +28,17 @@ "roles", "short_bio", "gender", - "phone", - "mobile_no", "birth_date", - "location", - "banner_image", - "column_break_22", "interest", + "banner_image", + "desk_theme", + "column_break_26", + "phone", + "location", "bio", "mute_sounds", + "column_break_22", + "mobile_no", "change_password", "new_password", "logout_all_sessions", @@ -47,13 +49,13 @@ "document_follow_notify", "document_follow_frequency", "email_settings", + "email_signature", "thread_notify", "send_me_a_copy", "allowed_in_mentions", - "email_signature", - "email_inbox", "user_emails", "sb_allow_modules", + "module_profile", "modules_html", "block_modules", "home_settings", @@ -61,15 +63,16 @@ "defaults", "sb3", "simultaneous_sessions", - "user_type", - "login_after", - "login_before", "restrict_ip", - "bypass_restrict_ip_check_if_2fa_enabled", - "column_break1", - "last_login", "last_ip", + "column_break1", + "login_after", + "user_type", "last_active", + "section_break_63", + "login_before", + "bypass_restrict_ip_check_if_2fa_enabled", + "last_login", "last_known_versions", "third_party_authentication", "social_logins", @@ -80,10 +83,6 @@ "api_secret" ], "fields": [ - { - "fieldname": "sb0_5", - "fieldtype": "Section Break" - }, { "default": "1", "fieldname": "enabled", @@ -96,7 +95,8 @@ { "depends_on": "enabled", "fieldname": "section_break_3", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "label": "Basic Info" }, { "fieldname": "email", @@ -302,7 +302,7 @@ "no_copy": 1 }, { - "default": "0", + "default": "1", "fieldname": "logout_all_sessions", "fieldtype": "Check", "label": "Logout From All Devices After Changing Password" @@ -577,6 +577,30 @@ "fieldtype": "Password", "label": "API Secret", "read_only": 1 + }, + { + "fieldname": "column_break_11", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_26", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_63", + "fieldtype": "Column Break" + }, + { + "fieldname": "desk_theme", + "fieldtype": "Select", + "label": "Desk Theme", + "options": "Light\nDark" + }, + { + "fieldname": "module_profile", + "fieldtype": "Link", + "label": "Module Profile", + "options": "Module Profile" } ], "icon": "fa fa-user", @@ -623,11 +647,6 @@ "link_doctype": "User Permission", "link_fieldname": "user" }, - { - "group": "Settings", - "link_doctype": "Assignment Rule", - "link_fieldname": "user" - }, { "group": "Settings", "link_doctype": "Document Follow", @@ -642,10 +661,15 @@ "group": "Activity", "link_doctype": "ToDo", "link_fieldname": "owner" + }, + { + "group": "Integrations", + "link_doctype": "Token Cache", + "link_fieldname": "user" } ], "max_attachments": 5, - "modified": "2020-08-26 19:48:49.677800", + "modified": "2021-02-01 16:11:06.037543", "modified_by": "Administrator", "module": "Core", "name": "User", @@ -673,10 +697,11 @@ } ], "quick_entry": 1, + "route": "user", "search_fields": "full_name", "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", "title_field": "full_name", "track_changes": 1 -} +} \ No newline at end of file diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 2c5865fb69..3f19a6ef7b 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -6,14 +6,14 @@ import frappe from frappe.model.document import Document from frappe.utils import cint, flt, has_gravatar, escape_html, format_datetime, now_datetime, get_formatted_email, today from frappe import throw, msgprint, _ -from frappe.utils.password import update_password as _update_password +from frappe.utils.password import update_password as _update_password, check_password from frappe.desk.notifications import clear_notifications from frappe.desk.doctype.notification_settings.notification_settings import create_notification_settings from frappe.utils.user import get_system_managers from bs4 import BeautifulSoup import frappe.permissions import frappe.share - +import frappe.defaults from frappe.website.utils import is_signup_enabled from frappe.utils.background_jobs import enqueue @@ -75,6 +75,7 @@ class User(Document): self.validate_user_email_inbox() ask_pass_update() self.validate_roles() + self.validate_allowed_modules() self.validate_user_image() if self.language == "Loading...": @@ -85,9 +86,18 @@ class User(Document): def validate_roles(self): if self.role_profile_name: - role_profile = frappe.get_doc('Role Profile', self.role_profile_name) - self.set('roles', []) - self.append_roles(*[role.role for role in role_profile.roles]) + role_profile = frappe.get_doc('Role Profile', self.role_profile_name) + self.set('roles', []) + self.append_roles(*[role.role for role in role_profile.roles]) + + def validate_allowed_modules(self): + if self.module_profile: + module_profile = frappe.get_doc('Module Profile', self.module_profile) + self.set('block_modules', []) + for d in module_profile.get('block_modules'): + self.append('block_modules', { + 'module': d.module + }) def validate_user_image(self): if self.user_image and len(self.user_image) > 2000: @@ -98,15 +108,20 @@ class User(Document): self.share_with_self() clear_notifications(user=self.name) frappe.clear_cache(user=self.name) + now=frappe.flags.in_test or frappe.flags.in_install self.send_password_notification(self.__new_password) frappe.enqueue( 'frappe.core.doctype.user.user.create_contact', user=self, ignore_mandatory=True, - now=frappe.flags.in_test or frappe.flags.in_install + now=now ) if self.name not in ('Administrator', 'Guest') and not self.user_image: - frappe.enqueue('frappe.core.doctype.user.user.update_gravatar', name=self.name) + frappe.enqueue('frappe.core.doctype.user.user.update_gravatar', name=self.name, now=now) + + # Set user selected timezone + if self.time_zone: + frappe.defaults.set_default("time_zone", self.time_zone, self.name) def has_website_permission(self, ptype, user, verbose=False): """Returns true if current user is the session user""" @@ -182,20 +197,17 @@ class User(Document): def share_with_self(self): - if self.user_type=="System User": - frappe.share.add(self.doctype, self.name, self.name, write=1, share=1, - flags={"ignore_share_permission": True}) - else: - frappe.share.remove(self.doctype, self.name, self.name, - flags={"ignore_share_permission": True, "ignore_permissions": True}) + frappe.share.add(self.doctype, self.name, self.name, write=1, share=1, + flags={"ignore_share_permission": True}) def validate_share(self, docshare): - if docshare.user == self.name: - if self.user_type=="System User": - if docshare.share != 1: - frappe.throw(_("Sorry! User should have complete access to their own record.")) - else: - frappe.throw(_("Sorry! Sharing with Website User is prohibited.")) + pass + # if docshare.user == self.name: + # if self.user_type=="System User": + # if docshare.share != 1: + # frappe.throw(_("Sorry! User should have complete access to their own record.")) + # else: + # frappe.throw(_("Sorry! Sharing with Website User is prohibited.")) def send_password_notification(self, new_password): try: @@ -287,16 +299,16 @@ class User(Document): from frappe.utils.user import get_user_fullname from frappe.utils import get_url - full_name = get_user_fullname(frappe.session['user']) - if full_name == "Guest": - full_name = "Administrator" + created_by = get_user_fullname(frappe.session['user']) + if created_by == "Guest": + created_by = "Administrator" args = { 'first_name': self.first_name or self.last_name or "user", 'user': self.name, 'title': subject, 'login_url': get_url(), - 'user_fullname': full_name + 'created_by': created_by } args.update(add_args) @@ -515,6 +527,27 @@ class User(Document): return [i.strip() for i in self.restrict_ip.split(",")] + @classmethod + def find_by_credentials(cls, user_name: str, password: str, validate_password: bool = True): + """Find the user by credentials. + """ + login_with_mobile = cint(frappe.db.get_value("System Settings", "System Settings", "allow_login_using_mobile_number")) + filter = {"mobile_no": user_name} if login_with_mobile else {"name": user_name} + + user = frappe.db.get_value("User", filters=filter, fieldname=['name', 'enabled'], as_dict=True) or {} + if not user: + return + + user['is_authenticated'] = True + if validate_password: + try: + check_password(user_name, password) + except frappe.AuthenticationError: + user['is_authenticated'] = False + + return user + + @frappe.whitelist() def get_timezones(): import pytz @@ -550,6 +583,10 @@ def get_perm_info(role): @frappe.whitelist(allow_guest=True) def update_password(new_password, logout_all_sessions=0, key=None, old_password=None): + #validate key to avoid key input like ['like', '%'], '', ['in', ['']] + if key and not isinstance(key, str): + frappe.throw(_('Invalid key type')) + result = test_password_strength(new_password, key, old_password) feedback = result.get("feedback", None) @@ -580,7 +617,7 @@ def update_password(new_password, logout_all_sessions=0, key=None, old_password= frappe.db.set_value("User", user, "reset_password_key", "") if user_doc.user_type == "System User": - return "/desk" + return "/app" else: return redirect_url if redirect_url else "/" @@ -1001,9 +1038,14 @@ def send_token_via_email(tmp_id,token=None): hotp = pyotp.HOTP(otpsecret) frappe.sendmail( - recipients=user_email, sender=None, subject='Verification Code', - message='

    Your verification code is {0}

    '.format(hotp.at(int(count))), - delayed=False, retry=3) + recipients=user_email, + sender=None, + subject="Verification Code", + template="verification_code", + args=dict(code=hotp.at(int(count))), + delayed=False, + retry=3 + ) return True @@ -1037,6 +1079,11 @@ def get_role_profile(role_profile): roles = frappe.get_doc('Role Profile', {'role_profile': role_profile}) return roles.roles +@frappe.whitelist() +def get_module_profile(module_profile): + module_profile = frappe.get_doc('Module Profile', {'module_profile_name': module_profile}) + return module_profile.get('block_modules') + def update_roles(role_profile): users = frappe.get_all('User', filters={'role_profile_name': role_profile}) role_profile = frappe.get_doc('Role Profile', role_profile) @@ -1097,7 +1144,6 @@ def create_contact(user, ignore_links=False, ignore_mandatory=False): contact.save(ignore_permissions=True) - @frappe.whitelist() def generate_keys(user): """ @@ -1118,6 +1164,11 @@ def generate_keys(user): return {"api_secret": api_secret} frappe.throw(frappe._("Not Permitted"), frappe.PermissionError) +@frappe.whitelist() +def switch_theme(theme): + if theme in ["Dark", "Light"]: + frappe.db.set_value("User", frappe.session.user, "desk_theme", theme) + def update_password_reset_limit(user): generated_link_count = get_generated_link_count(user) generated_link_count += 1 @@ -1129,4 +1180,4 @@ def check_password_reset_limit(user, rate_limit): frappe.throw(_("You have reached the hourly limit for generating password reset links. Please try again later.")) def get_generated_link_count(user): - return cint(frappe.cache().hget("password_reset_link_count", user)) or 0 \ No newline at end of file + return cint(frappe.cache().hget("password_reset_link_count", user)) or 0 diff --git a/frappe/core/doctype/user_permission/test_user_permission.py b/frappe/core/doctype/user_permission/test_user_permission.py index 82dd2ab27e..7e0b4a49c6 100644 --- a/frappe/core/doctype/user_permission/test_user_permission.py +++ b/frappe/core/doctype/user_permission/test_user_permission.py @@ -3,6 +3,7 @@ # See license.txt from __future__ import unicode_literals from frappe.core.doctype.user_permission.user_permission import add_user_permissions +from frappe.permissions import has_user_permission import frappe import unittest @@ -10,7 +11,12 @@ import unittest class TestUserPermission(unittest.TestCase): def setUp(self): frappe.db.sql("""DELETE FROM `tabUser Permission` - WHERE `user` in ('test_bulk_creation_update@example.com', 'test_user_perm1@example.com')""") + WHERE `user` in ( + 'test_bulk_creation_update@example.com', + 'test_user_perm1@example.com', + 'nested_doc_user@example.com')""") + frappe.delete_doc_if_exists("DocType", "Person") + frappe.db.sql_ddl("DROP TABLE IF EXISTS `tabPerson`") def test_default_user_permission_validation(self): user = create_user('test_default_permission@example.com') @@ -108,6 +114,45 @@ class TestUserPermission(unittest.TestCase): self.assertIsNone(removed_applicable_second) self.assertEquals(is_created, 1) + def test_user_perm_for_nested_doctype(self): + """Test if descendants' visibility is controlled for a nested DocType.""" + from frappe.core.doctype.doctype.test_doctype import new_doctype + + user = create_user("nested_doc_user@example.com", "Blogger") + if not frappe.db.exists("DocType", "Person"): + doc = new_doctype("Person", + fields=[ + { + "label": "Person Name", + "fieldname": "person_name", + "fieldtype": "Data" + } + ], unique=0) + doc.is_tree = 1 + doc.insert() + + parent_record = frappe.get_doc( + {"doctype": "Person", "person_name": "Parent", "is_group": 1} + ).insert() + + child_record = frappe.get_doc( + {"doctype": "Person", "person_name": "Child", "is_group": 0, "parent_person": parent_record.name} + ).insert() + + add_user_permissions(get_params(user, "Person", parent_record.name)) + + # check if adding perm on a group record, makes child record visible + self.assertTrue(has_user_permission(frappe.get_doc("Person", parent_record.name), user.name)) + self.assertTrue(has_user_permission(frappe.get_doc("Person", child_record.name), user.name)) + + frappe.db.set_value("User Permission", {"allow": "Person", "for_value": parent_record.name}, "hide_descendants", 1) + frappe.cache().delete_value("user_permissions") + + # check if adding perm on a group record with hide_descendants enabled, + # hides child records + self.assertTrue(has_user_permission(frappe.get_doc("Person", parent_record.name), user.name)) + self.assertFalse(has_user_permission(frappe.get_doc("Person", child_record.name), user.name)) + def create_user(email, role="System Manager"): ''' create user with role system manager ''' if frappe.db.exists('User', email): @@ -119,7 +164,7 @@ def create_user(email, role="System Manager"): user.add_roles(role) return user -def get_params(user, doctype, docname, is_default=0, applicable=None): +def get_params(user, doctype, docname, is_default=0, hide_descendants=0, applicable=None): ''' Return param to insert ''' param = { "user": user.name, @@ -127,7 +172,8 @@ def get_params(user, doctype, docname, is_default=0, applicable=None): "docname":docname, "is_default": is_default, "apply_to_all_doctypes": 1, - "applicable_doctypes": [] + "applicable_doctypes": [], + "hide_descendants": hide_descendants } if applicable: param.update({"apply_to_all_doctypes": 0}) diff --git a/frappe/core/doctype/user_permission/user_permission.js b/frappe/core/doctype/user_permission/user_permission.js index 9f824b1350..4c3f5b4eb8 100644 --- a/frappe/core/doctype/user_permission/user_permission.js +++ b/frappe/core/doctype/user_permission/user_permission.js @@ -26,11 +26,15 @@ frappe.ui.form.on('User Permission', { () => frappe.set_route('query-report', 'Permitted Documents For User', { user: frm.doc.user })); frm.trigger('set_applicable_for_constraint'); + frm.trigger('toggle_hide_descendants'); }, allow: frm => { - if(frm.doc.for_value) { - frm.set_value('for_value', null); + if (frm.doc.allow) { + if (frm.doc.for_value) { + frm.set_value('for_value', null); + } + frm.trigger('toggle_hide_descendants'); } }, @@ -43,6 +47,11 @@ frappe.ui.form.on('User Permission', { if (frm.doc.apply_to_all_doctypes) { frm.set_value('applicable_for', null); } + }, + + toggle_hide_descendants: frm => { + let show = frappe.boot.nested_set_doctypes.includes(frm.doc.allow); + frm.toggle_display('hide_descendants', show); } diff --git a/frappe/core/doctype/user_permission/user_permission.json b/frappe/core/doctype/user_permission/user_permission.json index 33a8d58bbb..9cea0856c9 100644 --- a/frappe/core/doctype/user_permission/user_permission.json +++ b/frappe/core/doctype/user_permission/user_permission.json @@ -1,330 +1,116 @@ { - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, + "actions": [], "allow_import": 1, - "allow_rename": 0, - "beta": 0, "creation": "2017-07-17 14:25:27.881871", - "custom": 0, - "docstatus": 0, "doctype": "DocType", - "document_type": "", "editable_grid": 1, "engine": "InnoDB", + "field_order": [ + "user", + "allow", + "column_break_3", + "for_value", + "is_default", + "advanced_control_section", + "apply_to_all_doctypes", + "applicable_for", + "column_break_9", + "hide_descendants" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, "fieldname": "user", "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, "in_standard_filter": 1, "label": "User", - "length": 0, - "no_copy": 0, "options": "User", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, "reqd": 1, - "search_index": 1, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "search_index": 1 }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, "fieldname": "allow", "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, "in_standard_filter": 1, "label": "Allow", - "length": 0, - "no_copy": 0, "options": "DocType", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "reqd": 1 }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, "fieldname": "column_break_3", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldtype": "Column Break" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, "fieldname": "for_value", "fieldtype": "Dynamic Link", - "hidden": 0, "ignore_user_permissions": 1, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, "in_standard_filter": 1, "label": "For Value", - "length": 0, - "no_copy": 0, "options": "allow", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "reqd": 1 }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, + "default": "0", "fieldname": "is_default", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Is Default", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Is Default" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fetch_if_empty": 0, "fieldname": "advanced_control_section", "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Advanced Control", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Advanced Control" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "default": "1", - "fetch_if_empty": 0, "fieldname": "apply_to_all_doctypes", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Apply To All Document Types", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Apply To All Document Types" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "depends_on": "eval:!doc.apply_to_all_doctypes", - "fetch_if_empty": 0, "fieldname": "applicable_for", "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, "label": "Applicable For", - "length": 0, - "no_copy": 0, - "options": "DocType", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "options": "DocType" + }, + { + "fieldname": "column_break_9", + "fieldtype": "Column Break" + }, + { + "default": "0", + "description": "Hide descendant records of For Value.", + "fieldname": "hide_descendants", + "fieldtype": "Check", + "hidden": 1, + "label": "Hide Descendants" } ], - "has_web_view": 0, - "hide_toolbar": 0, - "idx": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2019-04-16 19:17:23.644724", + "links": [], + "modified": "2021-01-21 18:14:10.839381", "modified_by": "Administrator", "module": "Core", "name": "User Permission", - "name_case": "", "owner": "Administrator", "permissions": [ { - "amend": 0, - "cancel": 0, "create": 1, "delete": 1, "email": 1, "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, "print": 1, "read": 1, "report": 1, "role": "System Manager", - "set_user_permissions": 0, "share": 1, - "submit": 0, "write": 1 } ], - "quick_entry": 0, - "read_only": 0, - "show_name_in_global_search": 0, "sort_field": "modified", "sort_order": "DESC", "title_field": "user", - "track_changes": 1, - "track_seen": 0, - "track_views": 0 + "track_changes": 1 } \ No newline at end of file diff --git a/frappe/core/doctype/user_permission/user_permission.py b/frappe/core/doctype/user_permission/user_permission.py index ba14583c2f..fbc788f6bf 100644 --- a/frappe/core/doctype/user_permission/user_permission.py +++ b/frappe/core/doctype/user_permission/user_permission.py @@ -49,13 +49,14 @@ class UserPermission(Document): 'name': ['!=', self.name] }, or_filters={ 'applicable_for': cstr(self.applicable_for), - 'apply_to_all_doctypes': 1 + 'apply_to_all_doctypes': 1, + 'hide_descendants': cstr(self.hide_descendants) }, limit=1) if overlap_exists: ref_link = frappe.get_desk_link(self.doctype, overlap_exists[0].name) frappe.throw(_("{0} has already assigned default value for {1}.").format(ref_link, self.allow)) -@frappe.whitelist() +@frappe.whitelist(allow_guest=True) def get_user_permissions(user=None): '''Get all users permissions for the user as a dict of doctype''' # if this is called from client-side, @@ -66,7 +67,7 @@ def get_user_permissions(user=None): if not user: user = frappe.session.user - if not user or user == "Administrator": + if not user or user in ("Administrator", "Guest"): return {} cached_user_permissions = frappe.cache().hget("user_permissions", user) @@ -91,13 +92,13 @@ def get_user_permissions(user=None): try: for perm in frappe.get_all('User Permission', - fields=['allow', 'for_value', 'applicable_for', 'is_default'], + fields=['allow', 'for_value', 'applicable_for', 'is_default', 'hide_descendants'], filters=dict(user=user)): meta = frappe.get_meta(perm.allow) add_doc_to_perm(perm, perm.for_value, perm.is_default) - if meta.is_nested_set(): + if meta.is_nested_set() and not perm.hide_descendants: decendants = frappe.db.get_descendants(perm.allow, perm.for_value) for doc in decendants: add_doc_to_perm(perm, doc, False) @@ -172,8 +173,8 @@ def check_applicable_doc_perm(user, doctype, docname): "allow": doctype, "for_value":docname, }) - for d in data: - applicable.append(d.applicable_for) + for permission in data: + applicable.append(permission.applicable_for) return applicable @@ -194,7 +195,8 @@ def add_user_permissions(data): data = json.loads(data) data = frappe._dict(data) - d = check_applicable_doc_perm(data.user, data.doctype, data.docname) + # get all doctypes on whom this permission is applied + perm_applied_docs = check_applicable_doc_perm(data.user, data.doctype, data.docname) exists = frappe.db.exists("User Permission", { "user": data.user, "allow": data.doctype, @@ -202,26 +204,27 @@ def add_user_permissions(data): "apply_to_all_doctypes": 1 }) if data.apply_to_all_doctypes == 1 and not exists: - remove_applicable(d, data.user, data.doctype, data.docname) - insert_user_perm(data.user, data.doctype, data.docname, data.is_default, apply_to_all = 1) + remove_applicable(perm_applied_docs, data.user, data.doctype, data.docname) + insert_user_perm(data.user, data.doctype, data.docname, data.is_default, data.hide_descendants, apply_to_all=1) return 1 elif len(data.applicable_doctypes) > 0 and data.apply_to_all_doctypes != 1: remove_apply_to_all(data.user, data.doctype, data.docname) - update_applicable(d, data.applicable_doctypes, data.user, data.doctype, data.docname) + update_applicable(perm_applied_docs, data.applicable_doctypes, data.user, data.doctype, data.docname) for applicable in data.applicable_doctypes : - if applicable not in d: - insert_user_perm(data.user, data.doctype, data.docname, data.is_default, applicable = applicable) + if applicable not in perm_applied_docs: + insert_user_perm(data.user, data.doctype, data.docname, data.is_default, data.hide_descendants, applicable=applicable) elif exists: - insert_user_perm(data.user, data.doctype, data.docname, data.is_default, applicable = applicable) + insert_user_perm(data.user, data.doctype, data.docname, data.is_default, data.hide_descendants, applicable=applicable) return 1 return 0 -def insert_user_perm(user, doctype, docname, is_default=0, apply_to_all=None, applicable=None): +def insert_user_perm(user, doctype, docname, is_default=0, hide_descendants=0, apply_to_all=None, applicable=None): user_perm = frappe.new_doc("User Permission") user_perm.user = user user_perm.allow = doctype user_perm.for_value = docname user_perm.is_default = is_default + user_perm.hide_descendants = hide_descendants if applicable: user_perm.applicable_for = applicable user_perm.apply_to_all_doctypes = 0 @@ -229,8 +232,8 @@ def insert_user_perm(user, doctype, docname, is_default=0, apply_to_all=None, ap user_perm.apply_to_all_doctypes = 1 user_perm.insert() -def remove_applicable(d, user, doctype, docname): - for applicable_for in d: +def remove_applicable(perm_applied_docs, user, doctype, docname): + for applicable_for in perm_applied_docs: frappe.db.sql("""DELETE FROM `tabUser Permission` WHERE `user`=%s AND `applicable_for`=%s diff --git a/frappe/core/doctype/user_permission/user_permission_list.js b/frappe/core/doctype/user_permission/user_permission_list.js index 3e822f0007..0ce66fa8e3 100644 --- a/frappe/core/doctype/user_permission/user_permission_list.js +++ b/frappe/core/doctype/user_permission/user_permission_list.js @@ -19,6 +19,7 @@ frappe.listview_settings['User Permission'] = { dialog.set_df_property("is_default", "hidden", 1); dialog.set_df_property("apply_to_all_doctypes", "hidden", 1); dialog.set_df_property("applicable_doctypes", "hidden", 1); + dialog.set_df_property("hide_descendants", "hidden", 1); } }, { @@ -54,6 +55,10 @@ frappe.listview_settings['User Permission'] = { } } }, + { + fieldtype: "Section Break", + hide_border: 1 + }, { fieldname: 'is_default', label: __('Is Default'), @@ -74,6 +79,19 @@ frappe.listview_settings['User Permission'] = { } } }, + { + fieldtype: "Column Break" + }, + { + fieldname: 'hide_descendants', + label: __('Hide Descendants'), + fieldtype: 'Check', + hidden: 1 + }, + { + fieldtype: "Section Break", + hide_border: 1 + }, { label: __("Applicable Document Types"), fieldname: "applicable_doctypes", @@ -145,7 +163,7 @@ frappe.listview_settings['User Permission'] = { } frappe.show_alert({ message, - indicator: 'green' + indicator: 'info' }); list_view.refresh(); }); @@ -214,6 +232,9 @@ frappe.listview_settings['User Permission'] = { dialog.set_df_property("is_default", "hidden", 0); dialog.set_df_property("apply_to_all_doctypes", "hidden", 0); dialog.set_value("apply_to_all_doctypes", "checked", 1); + let show = frappe.boot.nested_set_doctypes.includes(dialog.get_value("doctype")); + dialog.set_df_property("hide_descendants", "hidden", !show); + dialog.refresh(); }, on_docname_change: function(dialog, options, applicable) { @@ -233,6 +254,7 @@ frappe.listview_settings['User Permission'] = { dialog.set_df_property("applicable_doctypes", "options", options); dialog.set_df_property("applicable_doctypes", "hidden", 1); } + dialog.refresh(); }, on_apply_to_all_doctypes_change: function(dialog, options) { @@ -243,5 +265,6 @@ frappe.listview_settings['User Permission'] = { dialog.set_df_property("applicable_doctypes", "options", options); dialog.set_df_property("applicable_doctypes", "hidden", 1); } + dialog.refresh_sections(); } -}; \ No newline at end of file +}; diff --git a/frappe/core/doctype/version/version_view.html b/frappe/core/doctype/version/version_view.html index 5383be82a1..67f005ed4c 100644 --- a/frappe/core/doctype/version/version_view.html +++ b/frappe/core/doctype/version/version_view.html @@ -21,7 +21,7 @@ {{ item[1] }} {{ item[2] }} - {% endif %} + {% endfor %} {% endif %} @@ -58,7 +58,7 @@ - {% endif %} + {% endfor %} @@ -93,4 +93,4 @@ {% endfor %} {% endif %} -
    \ No newline at end of file +
    diff --git a/frappe/core/page/background_jobs/background_jobs.css b/frappe/core/page/background_jobs/background_jobs.css new file mode 100644 index 0000000000..0c77522cb3 --- /dev/null +++ b/frappe/core/page/background_jobs/background_jobs.css @@ -0,0 +1,60 @@ +.list-jobs { + font-size: var(--text-base); +} + +.table { + margin-bottom: 0px; + margin-top: 0px; +} + +thead { + background-color: var(--control-bg); + border-radius: var(--border-radius-sm); +} + +thead > tr { + border-radius: var(--border-radius-sm); +} + +thead > tr > th:first-child { + border-radius: var(--border-radius-sm) 0 0 var(--border-radius-sm); +} +thead > tr > th:last-child { + border-radius: 0 var(--border-radius-sm) var(--border-radius-sm) 0; +} + +.worker-name { + display: flex; + align-items: center; +} + +.job-name { + font-size: var(--text-md); + font-family: "Courier New", Courier, monospace; + /* background-color: var(--control-bg); */ + /* padding: var(--padding-xs) var(--padding-sm); */ + /* border-radius: var(--border-radius-md); */ +} + +.background-job-row:hover { + background-color: var(--bg-color); +} + +.no-background-jobs { + min-height: 320px; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; +} + +.no-background-jobs > img { + margin-bottom: var(--margin-md); + max-height: 100px; +} + +.footer { + align-items: flex-end; + margin-top: var(--margin-md); + font-size: var(--text-base); +} diff --git a/frappe/core/page/background_jobs/background_jobs.html b/frappe/core/page/background_jobs/background_jobs.html index c5d598ccd3..1b00ec3106 100644 --- a/frappe/core/page/background_jobs/background_jobs.html +++ b/frappe/core/page/background_jobs/background_jobs.html @@ -1,6 +1,6 @@
    {% if jobs.length %} - +
    @@ -11,30 +11,41 @@ {% for j in jobs %} - + - + {% endfor %}
    {{ __("Queue / Worker") }}
    {{ j.queue.split(".").slice(-1)[0] }} + + {{ j.queue.split(".").slice(-1)[0] }} +
    - {{ frappe.utils.encode_tags(j.job_name) }} + + {{ frappe.utils.encode_tags(j.job_name) }} +
    {% if j.exc_info %} -
    +
    {{ frappe.utils.encode_tags(j.exc_info) }}
    {% endif %}
    {{ j.creation }}{{ j.creation }}
    -

    - {{ __("Started") }} - {{ __("Queued") }} - {{ __("Failed") }} - {{ __("Finished") }} -

    {% else %} -

    {{ __("No pending or current jobs for this site") }}

    +
    + Empty State +

    {{ __("No pending or current jobs for this site") }}

    +
    {% endif %} -

    {{ __("Last refreshed") }} {{ frappe.datetime.now_datetime() }}

    -
    + +
    \ No newline at end of file diff --git a/frappe/core/page/background_jobs/background_jobs.js b/frappe/core/page/background_jobs/background_jobs.js index bbc8bf049b..cabe91375f 100644 --- a/frappe/core/page/background_jobs/background_jobs.js +++ b/frappe/core/page/background_jobs/background_jobs.js @@ -1,45 +1,65 @@ -frappe.pages['background_jobs'].on_page_load = function(wrapper) { - var page = frappe.ui.make_app_page({ - parent: wrapper, - title: __('Background Jobs'), - single_column: true +frappe.pages["background_jobs"].on_page_load = (wrapper) => { + const background_job = new BackgroundJobs(wrapper); + + $(wrapper).bind('show', () => { + background_job.show(); }); - $(frappe.render_template('background_jobs_outer')).appendTo(page.body); - page.content = $(page.body).find('.table-area'); + window.background_jobs = background_job; +}; - frappe.pages.background_jobs.page = page; -} +class BackgroundJobs { + constructor(wrapper) { + this.page = frappe.ui.make_app_page({ + parent: wrapper, + title: __('Background Jobs'), + single_column: true + }); -frappe.pages['background_jobs'].on_page_show = function(wrapper) { - frappe.pages.background_jobs.refresh_jobs(); - frappe.call({ - method: 'frappe.core.page.background_jobs.background_jobs.get_scheduler_status', - callback: function(r) { - frappe.pages.background_jobs.page.set_indicator(...r.message); - } - }); -} + this.called = false; + this.show_failed = false; -frappe.pages.background_jobs.refresh_jobs = function() { - var page = frappe.pages.background_jobs.page; - - // don't call if already waiting for a response - if(page.called) return; - page.called = true; - frappe.call({ - method: 'frappe.core.page.background_jobs.background_jobs.get_info', - args: { - show_failed: page.body.find('.show-failed').prop('checked') ? 1 : 0 - }, - callback: function(r) { - page.called = false; - page.body.find('.list-jobs').remove(); - $(frappe.render_template('background_jobs', {jobs:r.message || []})).appendTo(page.content); - - if(frappe.get_route()[0]==='background_jobs') { - frappe.background_jobs_timeout = setTimeout(frappe.pages.background_jobs.refresh_jobs, 2000); + this.show_failed_button = this.page.add_inner_button(__("Show Failed Jobs"), () => { + this.show_failed = !this.show_failed; + if (this.show_failed_button) { + this.show_failed_button.text( + this.show_failed ? __("Hide Failed Jobs") : __("Show Failed Jobs") + ); } - } - }); -} + }); + + $(frappe.render_template('background_jobs_outer')).appendTo(this.page.body); + this.content = $(this.page.body).find('.table-area'); + } + + show() { + this.refresh_jobs(); + frappe.call({ + method: 'frappe.core.page.background_jobs.background_jobs.get_scheduler_status', + callback: res => { + this.page.set_indicator(...res.message); + } + }); + } + + refresh_jobs() { + if (this.called) return; + this.called = true; + + frappe.call({ + method: 'frappe.core.page.background_jobs.background_jobs.get_info', + args: { + show_failed: this.show_failed + }, + callback: (res) => { + this.called = false; + this.page.body.find('.list-jobs').remove(); + $(frappe.render_template('background_jobs', { jobs: res.message || [] })).appendTo(this.content); + + if (frappe.get_route()[0] === 'background_jobs') { + setTimeout(() => this.refresh_jobs(), 2000); + } + } + }); + } +} \ No newline at end of file diff --git a/frappe/core/page/background_jobs/background_jobs_outer.html b/frappe/core/page/background_jobs/background_jobs_outer.html index 4da4498304..4ca3a32906 100644 --- a/frappe/core/page/background_jobs/background_jobs_outer.html +++ b/frappe/core/page/background_jobs/background_jobs_outer.html @@ -1,11 +1,4 @@ -
    -

    -

    - -
    -

    +
    diff --git a/frappe/custom/doctype/package_document_type/__init__.py b/frappe/core/page/dashboard_view/__init__.py similarity index 100% rename from frappe/custom/doctype/package_document_type/__init__.py rename to frappe/core/page/dashboard_view/__init__.py diff --git a/frappe/core/page/dashboard/dashboard.js b/frappe/core/page/dashboard_view/dashboard_view.js similarity index 92% rename from frappe/core/page/dashboard/dashboard.js rename to frappe/core/page/dashboard_view/dashboard_view.js index 7e45163a7e..686d11c6bf 100644 --- a/frappe/core/page/dashboard/dashboard.js +++ b/frappe/core/page/dashboard_view/dashboard_view.js @@ -5,7 +5,7 @@ frappe.provide('frappe.dashboards'); frappe.provide('frappe.dashboards.chart_sources'); -frappe.pages['dashboard'].on_page_load = function(wrapper) { +frappe.pages['dashboard-view'].on_page_load = function(wrapper) { frappe.ui.make_app_page({ parent: wrapper, title: __("Dashboard"), @@ -21,7 +21,7 @@ frappe.pages['dashboard'].on_page_load = function(wrapper) { class Dashboard { constructor(wrapper) { this.wrapper = $(wrapper); - $(`
    + $(`
    `).appendTo(this.wrapper.find(".page-content").empty()); this.container = this.wrapper.find(".dashboard-graph"); @@ -36,17 +36,17 @@ class Dashboard { } else { // last opened if (frappe.last_dashboard) { - frappe.set_route('dashboard', frappe.last_dashboard); + frappe.set_route('dashboard-view', frappe.last_dashboard); } else { // default dashboard frappe.db.get_list('Dashboard', {filters: {is_default: 1}}).then(data => { if (data && data.length) { - frappe.set_route('dashboard', data[0].name); + frappe.set_route('dashboard-view', data[0].name); } else { // no default, get the latest one frappe.db.get_list('Dashboard', {limit: 1}).then(data => { if (data && data.length) { - frappe.set_route('dashboard', data[0].name); + frappe.set_route('dashboard-view', data[0].name); } else { // create a new dashboard! frappe.new_doc('Dashboard'); @@ -183,8 +183,8 @@ class Dashboard { frappe.db.get_list('Dashboard').then(dashboards => { dashboards.map(dashboard => { let name = dashboard.name; - if(name != this.dashboard_name){ - this.page.add_menu_item(name, () => frappe.set_route("dashboard", name), 1); + if (name != this.dashboard_name) { + this.page.add_menu_item(name, () => frappe.set_route("dashboard-view", name), 1); } }); }); diff --git a/frappe/core/page/dashboard/dashboard.json b/frappe/core/page/dashboard_view/dashboard_view.json similarity index 74% rename from frappe/core/page/dashboard/dashboard.json rename to frappe/core/page/dashboard_view/dashboard_view.json index 58fda5a34c..4ece98a779 100644 --- a/frappe/core/page/dashboard/dashboard.json +++ b/frappe/core/page/dashboard_view/dashboard_view.json @@ -4,12 +4,12 @@ "docstatus": 0, "doctype": "Page", "idx": 0, - "modified": "2020-03-26 13:30:44.603948", + "modified": "2020-12-16 12:29:08.610352", "modified_by": "Administrator", "module": "Core", - "name": "dashboard", + "name": "dashboard-view", "owner": "Administrator", - "page_name": "Dashboard", + "page_name": "dashboard-view", "roles": [], "script": null, "standard": "Yes", diff --git a/frappe/core/page/desktop/desktop.js b/frappe/core/page/desktop/desktop.js deleted file mode 100644 index cc36a5a4e9..0000000000 --- a/frappe/core/page/desktop/desktop.js +++ /dev/null @@ -1,3 +0,0 @@ -frappe.pages['desktop'].on_page_load = function() { - frappe.utils.set_title(__("Home")); -}; \ No newline at end of file diff --git a/frappe/core/page/desktop/desktop.json b/frappe/core/page/desktop/desktop.json deleted file mode 100644 index 66bbfbfd40..0000000000 --- a/frappe/core/page/desktop/desktop.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "content": null, - "creation": "2019-01-29 13:11:48.872579", - "docstatus": 0, - "doctype": "Page", - "icon": "icon-th", - "idx": 0, - "modified": "2019-01-29 13:11:48.872579", - "modified_by": "Administrator", - "module": "Core", - "name": "desktop", - "owner": "Administrator", - "page_name": "desktop", - "roles": [ - { - "role": "All" - } - ], - "script": null, - "standard": "Yes", - "style": null, - "system_page": 0, - "title": "Desktop" -} \ No newline at end of file diff --git a/frappe/core/page/permission_manager/permission_manager.css b/frappe/core/page/permission_manager/permission_manager.css new file mode 100644 index 0000000000..fec486aa81 --- /dev/null +++ b/frappe/core/page/permission_manager/permission_manager.css @@ -0,0 +1,51 @@ +.table { + margin-bottom: 0px; + margin-top: 0px; + border-radius: var(--border-radius-md); +} + +thead { + border: none; + background-color: var(--control-bg); + border-radius: var(--border-radius-md); +} + +thead > tr { + border-radius: var(--border-radius-md); +} + +thead > tr > th:first-child { + border-radius: var(--border-radius-md) 0 0 var(--border-radius-md); +} +thead > tr > th:last-child { + border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0; +} + +/* Space between thead and tbody */ +/* tbody:before { + content: "@"; + display: block; + line-height: var(--margin-md); + text-indent: -99999px; +} */ + +td[data-fieldname="permissions"] > .row > .col-md-4 { + margin-bottom: var(--margin-sm); +} + +tbody > tr { + border-top: 1px solid var(--border-color); +} + +tbody > tr:first-child { + border-top: none; +} + +button.btn-remove-perm { + box-shadow: none; + padding: var(--padding-xs) var(--padding-xs); +} + +button.btn-remove-perm > svg > use { + stroke: var(--white); +} diff --git a/frappe/core/page/permission_manager/permission_manager.js b/frappe/core/page/permission_manager/permission_manager.js index 0d3267c7d5..41cc900a97 100644 --- a/frappe/core/page/permission_manager/permission_manager.js +++ b/frappe/core/page/permission_manager/permission_manager.js @@ -1,8 +1,8 @@ frappe.pages['permission-manager'].on_page_load = (wrapper) => { - var page = frappe.ui.make_app_page({ + let page = frappe.ui.make_app_page({ parent: wrapper, title: __('Role Permissions Manager'), - icon: "fa fa-lock", + card_layout: true, single_column: true }); @@ -14,233 +14,253 @@ frappe.pages['permission-manager'].on_page_load = (wrapper) => { }; -frappe.pages['permission-manager'].refresh = function(wrapper) { +frappe.pages['permission-manager'].refresh = function (wrapper) { wrapper.permission_engine.set_from_route(); }; -frappe.PermissionEngine = Class.extend({ - init: function(wrapper) { +frappe.PermissionEngine = class PermissionEngine { + constructor(wrapper) { this.wrapper = wrapper; this.page = wrapper.page; this.body = $(this.wrapper).find(".perm-engine"); this.make(); this.refresh(); this.add_check_events(); - }, - make: function() { - var me = this; + } - me.make_reset_button(); - return frappe.call({ - module:"frappe.core", - page:"permission_manager", - method: "get_roles_and_doctypes", - callback: function(r) { - me.options = r.message; - me.setup_page(); - } + make() { + this.make_reset_button(); + frappe.call({ + module: "frappe.core", + page: "permission_manager", + method: "get_roles_and_doctypes" + }).then((res) => { + this.options = res.message; + this.setup_page(); }); + } - }, - setup_page: function() { - var me = this; + setup_page() { this.doctype_select = this.wrapper.page.add_select(__("Document Type"), - [{value: "", label: __("Select Document Type")+"..."}].concat(this.options.doctypes)) - .change(function() { + [{ value: "", label: __("Select Document Type") + "..." }].concat(this.options.doctypes)) + .change(function () { frappe.set_route("permission-manager", $(this).val()); }); + this.role_select = this.wrapper.page.add_select(__("Roles"), - [__("Select Role")+"..."].concat(this.options.roles)) - .change(function() { - me.refresh(); + [__("Select Role") + "..."].concat(this.options.roles)) + .change(() => { + this.refresh(); }); this.page.add_inner_button(__('Set User Permissions'), () => { return frappe.set_route('List', 'User Permission'); }); this.set_from_route(); - }, - set_from_route: function() { - var me = this; - if(!this.doctype_select) { + } + + set_from_route() { + if (!this.doctype_select) { // selects not yet loaded, call again after a bit setTimeout(() => { - me.set_from_route(); + this.set_from_route(); }, 500); return; } - if(frappe.get_route()[1]) { + if (frappe.get_route()[1]) { this.doctype_select.val(frappe.get_route()[1]); - } else if(frappe.route_options) { - if(frappe.route_options.doctype) { + } else if (frappe.route_options) { + if (frappe.route_options.doctype) { this.doctype_select.val(frappe.route_options.doctype); } - if(frappe.route_options.role) { + if (frappe.route_options.role) { this.role_select.val(frappe.route_options.role); } frappe.route_options = null; } this.refresh(); - }, - get_standard_permissions: function(callback) { - var doctype = this.get_doctype(); - if(doctype) { + } + + get_standard_permissions(callback) { + let doctype = this.get_doctype(); + if (doctype) { return frappe.call({ - module:"frappe.core", - page:"permission_manager", + module: "frappe.core", + page: "permission_manager", method: "get_standard_permissions", - args: {doctype: doctype}, + args: { doctype: doctype }, callback: callback }); } return false; - }, - reset_std_permissions: function(data) { - var me = this; - var d = frappe.confirm(__("Reset Permissions for {0}?", [me.get_doctype()]), function() { + } + + reset_std_permissions(data) { + let doctype = this.get_doctype(); + let d = frappe.confirm(__("Reset Permissions for {0}?", [doctype]), () => { return frappe.call({ - module:"frappe.core", - page:"permission_manager", - method:"reset", - args: { - doctype: me.get_doctype(), - }, - callback: function() { - me.refresh(); - } + module: "frappe.core", + page: "permission_manager", + method: "reset", + args: { doctype } + }).then(() => { + this.refresh(); }); }); // show standard permissions - var $d = $(d.wrapper).find(".frappe-confirm-message").append("

    Standard Permissions:


    "); - var $wrapper = $("

    ").appendTo($d); - $.each(data.message, function(i, d) { - d.rights = []; - $.each(me.rights, function(i, r) { - if(d[r]===1) { - d.rights.push(__(toTitle(r.replace("_", " ")))); - } - }); - d.rights = d.rights.join(", "); - $wrapper.append(repl('
    \ -
    %(role)s, Level %(permlevel)s
    \ -
    %(rights)s
    \ -

    ', d)); - }); + let $d = $(d.wrapper).find(".frappe-confirm-message").append("
    Standard Permissions:

    "); + let $wrapper = $("

    ").appendTo($d); + data.message.forEach((d) => { + let rights = this.rights + .filter((r) => d[r]) + .map((r) => { + return __(toTitle(frappe.unscrub(r))); + }); - }, - get_doctype: function() { - var doctype = this.doctype_select.val(); - return this.doctype_select.get(0).selectedIndex==0 ? null : doctype; - }, - get_role: function() { - var role = this.role_select.val(); - return this.role_select.get(0).selectedIndex==0 ? null : role; - }, - refresh: function() { - var me = this; - if(!me.doctype_select) { - this.body.html("

    " + __("Loading") + "...

    "); - return; + d.rights = rights.join(", "); + + $wrapper.append(`
    \ +
    ${d.role}, Level ${d.permlevel || 0}
    \ +
    ${d.rights}
    \ +

    `); + }); + } + + get_doctype() { + let doctype = this.doctype_select.val(); + return this.doctype_select.get(0).selectedIndex == 0 ? null : doctype; + } + + get_role() { + let role = this.role_select.val(); + return this.role_select.get(0).selectedIndex == 0 ? null : role; + } + + set_empty_message(message) { + this.body.html(` +
    +

    + ${message} +

    +
    `); + } + + refresh() { + this.page.clear_secondary_action(); + this.page.clear_primary_action(); + + if (!this.doctype_select) { + return this.set_empty_message(__("Loading")); } - if(!me.get_doctype() && !me.get_role()) { - this.body.html("

    "+__("Select Document Type or Role to start.")+"

    "); - return; + + let doctype = this.get_doctype(); + let role = this.get_role(); + + if (!doctype && !role) { + return this.set_empty_message(__("Select Document Type or Role to start.")); } + // get permissions frappe.call({ module: "frappe.core", page: "permission_manager", method: "get_permissions", - args: { - doctype: me.get_doctype(), - role: me.get_role() - }, - callback: function(r) { - me.render(r.message); - } + args: { doctype, role } + }).then((r) => { + this.render(r.message); }); - }, - render: function(perm_list) { + } + + render(perm_list) { this.body.empty(); this.perm_list = perm_list || []; - if(!this.perm_list.length) { - this.body.html("

    " - +__("No Permissions set for this criteria.")+"

    "); + if (!this.perm_list.length) { + this.set_empty_message(__("No Permissions set for this criteria.")); } else { this.show_permission_table(this.perm_list); } this.show_add_rule(); - this.make_reset_button(); - }, - show_permission_table: function(perm_list) { + this.get_doctype() && this.make_reset_button(); + } - var me = this; + show_permission_table(perm_list) { this.table = $("
    \ - \ +
    \ \ \
    \
    ").appendTo(this.body); - $.each([[__("Document Type"), 150], [__("Role"), 170], [__("Level"), 40], - [__("Permissions"), 350], ["", 40]], function(i, col) { - $("").html(col[0]).css("width", col[1]+"px") - .appendTo(me.table.find("thead tr")); + const table_columns = [ + [__("Document Type"), 150], + [__("Role"), 170], + [__("Level"), 40], + [__("Permissions"), 350], + ["", 40] + ]; + + table_columns.forEach((col) => { + $("") + .html(col[0]) + .css("width", col[1] + "px") + .appendTo(this.table.find("thead tr")); }); - $.each(perm_list, function(i, d) { - if(d.parent==="DocType") { + perm_list.forEach((d) => { + if (d.parent === "DocType") { return; } - if(!d.permlevel) d.permlevel = 0; - var row = $("").appendTo(me.table.find("tbody")); - me.add_cell(row, d, "parent"); - var role_cell = me.add_cell(row, d, "role"); - me.set_show_users(role_cell, d.role); - if (d.permlevel===0) { - // me.setup_user_permissions(d, role_cell); - me.setup_if_owner(d, role_cell); + if (!d.permlevel) d.permlevel = 0; + + let row = $("").appendTo(this.table.find("tbody")); + this.add_cell(row, d, "parent"); + let role_cell = this.add_cell(row, d, "role"); + + this.set_show_users(role_cell, d.role); + + if (d.permlevel === 0) { + // this.setup_user_permissions(d, role_cell); + this.setup_if_owner(d, role_cell); } - var cell = me.add_cell(row, d, "permlevel"); - if(d.permlevel==0) { + let cell = this.add_cell(row, d, "permlevel"); + + if (d.permlevel == 0) { cell.css("font-weight", "bold"); - row.addClass("warning"); } - var perm_cell = me.add_cell(row, d, "permissions").css("padding-top", 0); - var perm_container = $("
    ").appendTo(perm_cell); + let perm_cell = this.add_cell(row, d, "permissions"); + let perm_container = $("
    ").appendTo(perm_cell); - me.rights.forEach(r => { + this.rights.forEach(r => { if (!d.is_submittable && ['submit', 'cancel', 'amend'].includes(r)) return; if (d.in_create && ['create', 'write', 'delete'].includes(r)) return; - me.add_check(perm_container, d, r); + this.add_check(perm_container, d, r); }); // buttons - me.add_delete_button(row, d); + this.add_delete_button(row, d); }); - }, + } - add_cell: function(row, d, fieldname) { + add_cell(row, d, fieldname) { return $("").appendTo(row) .attr("data-fieldname", fieldname) + .addClass("pt-4") .html(__(d[fieldname])); - }, + } - add_check: (cell, d, fieldname, label, description="") => { - var me = this; - - if(!label) label = toTitle(fieldname.replace(/_/g, " ")); - if(d.permlevel > 0 && ["read", "write"].indexOf(fieldname)==-1) { + add_check(cell, d, fieldname, label, description = "") { + if (!label) label = toTitle(fieldname.replace(/_/g, " ")); + if (d.permlevel > 0 && ["read", "write"].indexOf(fieldname) == -1) { return; } - var checkbox = $( + let checkbox = $( `
    @@ -251,7 +271,7 @@ frappe.PermissionEngine = Class.extend({ .attr("data-fieldname", fieldname); checkbox.find("input") - .prop("checked", d[fieldname] ? true: false) + .prop("checked", d[fieldname] ? true : false) .attr("data-ptype", fieldname) .attr("data-role", d.role) .attr("data-permlevel", d.permlevel) @@ -261,23 +281,25 @@ frappe.PermissionEngine = Class.extend({ .css("text-transform", "capitalize"); return checkbox; - }, + } - setup_if_owner: function(d, role_cell) { + setup_if_owner(d, role_cell) { this.add_check(role_cell, d, "if_owner", "Only If Creator") .removeClass("col-md-4") - .css({"margin-top": "15px"}); - }, + .css({ "margin-top": "15px" }); + } - rights: ["read", "write", "create", "delete", "submit", "cancel", "amend", - "print", "email", "report", "import", "export", "set_user_permissions", "share"], + get rights() { + return ["select", "read", "write", "create", "delete", "submit", "cancel", "amend", + "print", "email", "report", "import", "export", "set_user_permissions", "share"]; + } - set_show_users: function(cell, role) { - cell.html(""+__(role)+"") + set_show_users(cell, role) { + cell.html("" + __(role) + "") .find("a") .attr("data-role", role) - .click(function() { - var role = $(this).attr("data-role"); + .click(function () { + let role = $(this).attr("data-role"); frappe.call({ module: "frappe.core", page: "permission_manager", @@ -285,9 +307,9 @@ frappe.PermissionEngine = Class.extend({ args: { role: role }, - callback: function(r) { - r.message = $.map(r.message, function(p) { - return $.format('{1}', [p, p]); + callback: function (r) { + r.message = $.map(r.message, function (p) { + return $.format('{1}', [p, p]); }); frappe.msgprint(__("Users with role {0}:", [__(role)]) + "
    " + r.message.join("
    ")); @@ -295,16 +317,15 @@ frappe.PermissionEngine = Class.extend({ }); return false; }); - }, + } - add_delete_button: function(row, d) { - var me = this; - $("") - .appendTo($("").appendTo(row)) + add_delete_button(row, d) { + $(``) + .appendTo($(``).appendTo(row)) .attr("data-doctype", d.parent) .attr("data-role", d.role) .attr("data-permlevel", d.permlevel) - .click(function() { + .click(function () { return frappe.call({ module: "frappe.core", page: "permission_manager", @@ -314,29 +335,27 @@ frappe.PermissionEngine = Class.extend({ role: $(this).attr("data-role"), permlevel: $(this).attr("data-permlevel") }, - callback: function(r) { - if(r.exc) { + callback: (r) => { + if (r.exc) { frappe.msgprint(__("Did not remove")); } else { - me.refresh(); + this.refresh(); } } }); }); - }, + } - add_check_events: function() { - var me = this; - - this.body.on("click", ".show-user-permissions", function() { - frappe.route_options = { allow: me.get_doctype() || "" }; + add_check_events() { + this.body.on("click", ".show-user-permissions", () => { + frappe.route_options = { allow: this.get_doctype() || "" }; frappe.set_route('List', 'User Permission'); }); - this.body.on("click", "input[type='checkbox']", function() { + this.body.on("click", "input[type='checkbox']", function () { frappe.dom.freeze(); - var chk = $(this); - var args = { + let chk = $(this); + let args = { role: chk.attr("data-role"), permlevel: chk.attr("data-permlevel"), doctype: chk.attr("data-doctype"), @@ -348,49 +367,53 @@ frappe.PermissionEngine = Class.extend({ page: "permission_manager", method: "update", args: args, - callback: function(r) { + callback: (r) => { frappe.dom.unfreeze(); - if(r.exc) { + if (r.exc) { // exception: reverse chk.prop("checked", !chk.prop("checked")); } else { - me.get_perm(args.role)[args.ptype]=args.value; + this.get_perm(args.role)[args.ptype] = args.value; } } }); }); - }, + } - show_add_rule: function() { - var me = this; - $("") - .appendTo($("

    ").appendTo(this.body)) - .click(function() { - var d = new frappe.ui.Dialog({ + show_add_rule() { + this.page.set_primary_action( + __("Add A New Rule"), + () => { + let d = new frappe.ui.Dialog({ title: __("Add New Permission Rule"), fields: [ - {fieldtype:"Select", label:__("Document Type"), - options:me.options.doctypes, reqd:1, fieldname:"parent"}, - {fieldtype:"Select", label:__("Role"), - options:me.options.roles, reqd:1,fieldname:"role"}, - {fieldtype:"Select", label:__("Permission Level"), - options:[0,1,2,3,4,5,6,7,8,9], reqd:1, fieldname: "permlevel", - description: __("Level 0 is for document level permissions, higher levels for field level permissions.")} + { + fieldtype: "Select", label: __("Document Type"), + options: this.options.doctypes, reqd: 1, fieldname: "parent" + }, + { + fieldtype: "Select", label: __("Role"), + options: this.options.roles, reqd: 1, fieldname: "role" + }, + { + fieldtype: "Select", label: __("Permission Level"), + options: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], reqd: 1, fieldname: "permlevel", + description: __("Level 0 is for document level permissions, higher levels for field level permissions.") + } ] }); - if(me.get_doctype()) { - d.set_value("parent", me.get_doctype()); + if (this.get_doctype()) { + d.set_value("parent", this.get_doctype()); d.get_input("parent").prop("disabled", true); } - if(me.get_role()) { - d.set_value("role", me.get_role()); + if (this.get_role()) { + d.set_value("role", this.get_role()); d.get_input("role").prop("disabled", true); } d.set_value("permlevel", "0"); - d.set_primary_action(__('Add'), function() { - var args = d.get_values(); - if(!args) { + d.set_primary_action(__('Add'), () => { + let args = d.get_values(); + if (!args) { return; } frappe.call({ @@ -398,40 +421,40 @@ frappe.PermissionEngine = Class.extend({ page: "permission_manager", method: "add", args: args, - callback: function(r) { - if(r.exc) { + callback: (r) => { + if (r.exc) { frappe.msgprint(__("Did not add")); } else { - me.refresh(); + this.refresh(); } } }); d.hide(); }); d.show(); - }); - }, + }, + "small-add" + ); + } - make_reset_button: function() { - var me = this; - $('') - .appendTo(this.body.find(".permission-toolbar")) - .on("click", function() { - me.get_standard_permissions(function(data) { - me.reset_std_permissions(data); + make_reset_button() { + this.page.set_secondary_action( + __("Restore Original Permissions"), + () => { + this.get_standard_permissions((data) => { + this.reset_std_permissions(data); }); }); - }, - - get_perm: function(role) { - return $.map(this.perm_list, function(d) { - if(d.role==role) return d; - })[0]; - }, - - get_link_fields: function(doctype) { - return frappe.get_children("DocType", doctype, "fields", - {fieldtype:"Link", options:["not in", ["User", '[Select]']]}); } -}); + + get_perm(role) { + return $.map(this.perm_list, function (d) { + if (d.role == role) return d; + })[0]; + } + + get_link_fields(doctype) { + return frappe.get_children("DocType", doctype, "fields", + { fieldtype: "Link", options: ["not in", ["User", '[Select]']] }); + } +}; diff --git a/frappe/core/page/permission_manager/permission_manager.py b/frappe/core/page/permission_manager/permission_manager.py index 637b526d5c..be8921e2ff 100644 --- a/frappe/core/page/permission_manager/permission_manager.py +++ b/frappe/core/page/permission_manager/permission_manager.py @@ -77,6 +77,18 @@ def add(parent, role, permlevel): @frappe.whitelist() def update(doctype, role, permlevel, ptype, value=None): + """Update role permission params + + Args: + doctype (str): Name of the DocType to update params for + role (str): Role to be updated for, eg "Website Manager". + permlevel (int): perm level the provided rule applies to + ptype (str): permission type, example "read", "delete", etc. + value (None, optional): value for ptype, None indicates False + + Returns: + str: Refresh flag is permission is updated successfully + """ frappe.only_for("System Manager") out = update_permission_property(doctype, role, permlevel, ptype, value) return 'refresh' if out else None @@ -92,7 +104,7 @@ def remove(doctype, role, permlevel): if not frappe.get_all('Custom DocPerm', dict(parent=doctype)): frappe.throw(_('There must be atleast one permission rule.'), title=_('Cannot Remove')) - validate_permissions_for_doctype(doctype, for_remove=True) + validate_permissions_for_doctype(doctype, for_remove=True, alert=True) @frappe.whitelist() def reset(doctype): diff --git a/frappe/core/page/permission_manager/permission_manager_help.html b/frappe/core/page/permission_manager/permission_manager_help.html index f192244fc9..0613713e81 100644 --- a/frappe/core/page/permission_manager/permission_manager_help.html +++ b/frappe/core/page/permission_manager/permission_manager_help.html @@ -1,12 +1,12 @@


    -
    +

    {%= __("Quick Help for Setting Permissions") %}:

    1. {%= __("Permissions are set on Roles and Document Types (called DocTypes) by setting rights like Read, Write, Create, Delete, Submit, Cancel, Amend, Report, Import, Export, Print, Email and Set User Permissions.") %}
    2. {%= __("Permissions get applied on Users based on what Roles they are assigned.") %}
    3. {%= __("Roles can be set for users from their User page.") %} - {%= __("Setup > User") %}
    4. -
    5. {%= __("The system provides many pre-defined roles. You can add new roles to set finer permissions.") %} {%= __("Add a New Role") %}
    6. + {%= __("Setup > User") %} +
    7. {%= __("The system provides many pre-defined roles. You can add new roles to set finer permissions.") %} {%= __("Add a New Role") %}
    8. {%= __("Permissions are automatically applied to Standard Reports and searches.") %}
    9. {%= __("As a best practice, do not assign the same set of permission rule to different Roles. Instead, set multiple Roles to the same User.") %}
    @@ -24,13 +24,13 @@
  • {%= __("Permissions at level 0 are Document Level permissions, i.e. they are primary for access to the document.") %}
  • {%= __("If a Role does not have access at Level 0, then higher levels are meaningless.") %}
  • {%= __("Permissions at higher levels are Field Level permissions. All Fields have a Permission Level set against them and the rules defined at that permissions apply to the field. This is useful in case you want to hide or make certain field read-only for certain Roles.") %}
  • -
  • {%= __("You can use Customize Form to set levels on fields.") %} {%= __("Setup > Customize Form") %}
  • +
  • {%= __("You can use Customize Form to set levels on fields.") %} {%= __("Setup > Customize Form") %}

  • {%= __("User Permissions") %}:

    1. {%= __("User Permissions are used to limit users to specific records.") %} - {%= __("Setup > User Permissions") %}
    2. + {%= __("Setup > User Permissions") %}
    3. {%= __("Select Document Types to set which User Permissions are used to limit access.") %}
    4. {%= __("Once you have set this, the users will only be able access documents (eg. Blog Post) where the link exists (eg. Blogger).") %}
    5. {%= __("Apart from System Manager, roles with Set User Permissions right can set permissions for other users for that Document Type.") %}
    6. diff --git a/frappe/core/page/recorder/recorder.js b/frappe/core/page/recorder/recorder.js index f38af41af0..4d6d6aa84c 100644 --- a/frappe/core/page/recorder/recorder.js +++ b/frappe/core/page/recorder/recorder.js @@ -2,7 +2,8 @@ frappe.pages['recorder'].on_page_load = function(wrapper) { frappe.ui.make_app_page({ parent: wrapper, title: 'Recorder', - single_column: true + single_column: true, + card_layout: true }); frappe.recorder = new Recorder(wrapper); diff --git a/frappe/core/page/workspace/workspace.js b/frappe/core/page/workspace/workspace.js deleted file mode 100644 index 78256e3acd..0000000000 --- a/frappe/core/page/workspace/workspace.js +++ /dev/null @@ -1,3 +0,0 @@ -frappe.pages['workspace'].on_page_load = function(wrapper) { - frappe.utils.set_title(__("Home")); -} \ No newline at end of file diff --git a/frappe/core/page/workspace/workspace.json b/frappe/core/page/workspace/workspace.json deleted file mode 100644 index 49de4a8e03..0000000000 --- a/frappe/core/page/workspace/workspace.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "content": null, - "creation": "2020-02-27 15:07:57.124916", - "docstatus": 0, - "doctype": "Page", - "icon": "icon-th", - "idx": 0, - "modified": "2020-02-27 15:07:57.124916", - "modified_by": "Administrator", - "module": "Core", - "name": "workspace", - "owner": "Administrator", - "page_name": "workspace", - "roles": [ - { - "role": "All" - } - ], - "script": null, - "standard": "Yes", - "style": null, - "system_page": 0 -} \ No newline at end of file diff --git a/frappe/core/report/permitted_documents_for_user/permitted_documents_for_user.py b/frappe/core/report/permitted_documents_for_user/permitted_documents_for_user.py index 97209cd8ea..c928939119 100644 --- a/frappe/core/report/permitted_documents_for_user/permitted_documents_for_user.py +++ b/frappe/core/report/permitted_documents_for_user/permitted_documents_for_user.py @@ -47,7 +47,7 @@ def query_doctypes(doctype, txt, searchfield, start, page_len, filters): user = filters.get("user") user_perms = frappe.utils.user.UserPermissions(user) user_perms.build_permissions() - can_read = user_perms.can_read + can_read = user_perms.can_read # Does not include child tables single_doctypes = [d[0] for d in frappe.db.get_values("DocType", {"issingle": 1})] diff --git a/frappe/core/workspace/build/build.json b/frappe/core/workspace/build/build.json new file mode 100644 index 0000000000..aefda698b1 --- /dev/null +++ b/frappe/core/workspace/build/build.json @@ -0,0 +1,212 @@ +{ + "cards_label": "Elements", + "category": "Modules", + "charts": [], + "creation": "2021-01-02 10:51:16.579957", + "developer_mode_only": 0, + "disable_user_customization": 0, + "docstatus": 0, + "doctype": "Workspace", + "extends_another_page": 0, + "hide_custom": 0, + "icon": "tool", + "idx": 0, + "is_default": 0, + "is_standard": 1, + "label": "Build", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Modules", + "link_type": "DocType", + "onboard": 0, + "only_for": "", + "type": "Card Break" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Module Def", + "link_to": "Module Def", + "link_type": "DocType", + "onboard": 0, + "only_for": "", + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Workspace", + "link_to": "Workspace", + "link_type": "DocType", + "onboard": 0, + "only_for": "", + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Module Onboarding", + "link_to": "Module Onboarding", + "link_type": "DocType", + "onboard": 0, + "only_for": "", + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Block Module", + "link_to": "Block Module", + "link_type": "DocType", + "onboard": 0, + "only_for": "", + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Models", + "link_type": "DocType", + "onboard": 0, + "only_for": "", + "type": "Card Break" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "DocType", + "link_to": "DocType", + "link_type": "DocType", + "onboard": 0, + "only_for": "", + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Workflow", + "link_to": "Workflow", + "link_type": "DocType", + "onboard": 0, + "only_for": "", + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Views", + "link_type": "DocType", + "onboard": 0, + "only_for": "", + "type": "Card Break" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Report", + "link_to": "Report", + "link_type": "DocType", + "onboard": 0, + "only_for": "", + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Print Format", + "link_to": "Print Format", + "link_type": "DocType", + "onboard": 0, + "only_for": "", + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Workspace", + "link_to": "Workspace", + "link_type": "DocType", + "onboard": 0, + "only_for": "", + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Dashboard", + "link_to": "Dashboard", + "link_type": "DocType", + "onboard": 0, + "only_for": "", + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Scripting", + "link_type": "DocType", + "onboard": 0, + "only_for": "", + "type": "Card Break" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Server Script", + "link_to": "Server Script", + "link_type": "DocType", + "onboard": 0, + "only_for": "", + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Client Script", + "link_to": "Client Script", + "link_type": "DocType", + "onboard": 0, + "only_for": "", + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Scheduled Job Type", + "link_to": "Scheduled Job Type", + "link_type": "DocType", + "onboard": 0, + "only_for": "", + "type": "Link" + } + ], + "modified": "2021-02-04 13:48:48.493146", + "modified_by": "Administrator", + "module": "Core", + "name": "Build", + "owner": "Administrator", + "pin_to_bottom": 0, + "pin_to_top": 0, + "shortcuts": [ + { + "doc_view": "", + "label": "DocType", + "link_to": "DocType", + "type": "DocType" + }, + { + "doc_view": "", + "label": "Workspace", + "link_to": "Workspace", + "type": "DocType" + }, + { + "doc_view": "", + "label": "Report", + "link_to": "Report", + "type": "DocType" + } + ] +} \ No newline at end of file diff --git a/frappe/core/workspace/settings/settings.json b/frappe/core/workspace/settings/settings.json new file mode 100644 index 0000000000..fb26b73cfc --- /dev/null +++ b/frappe/core/workspace/settings/settings.json @@ -0,0 +1,367 @@ +{ + "category": "Modules", + "charts": [], + "creation": "2020-03-02 15:09:40.527211", + "developer_mode_only": 0, + "disable_user_customization": 1, + "docstatus": 0, + "doctype": "Workspace", + "extends_another_page": 0, + "hide_custom": 0, + "icon": "setting", + "idx": 0, + "is_standard": 1, + "label": "Settings", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Data", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Import Data", + "link_to": "Data Import", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Export Data", + "link_to": "Data Export", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Bulk Update", + "link_to": "Bulk Update", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Download Backups", + "link_to": "backups", + "link_type": "Page", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Deleted Documents", + "link_to": "Deleted Document", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Email / Notifications", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Email Account", + "link_to": "Email Account", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Email Domain", + "link_to": "Email Domain", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Notification", + "link_to": "Notification", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Email Template", + "link_to": "Email Template", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Auto Email Report", + "link_to": "Auto Email Report", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Newsletter", + "link_to": "Newsletter", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Notification Settings", + "link_to": "Notification Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Website", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Website Settings", + "link_to": "Website Settings", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Website Theme", + "link_to": "Website Theme", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Website Script", + "link_to": "Website Script", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "About Us Settings", + "link_to": "About Us Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Contact Us Settings", + "link_to": "Contact Us Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Core", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "System Settings", + "link_to": "System Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Error Log", + "link_to": "Error Log", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Error Snapshot", + "link_to": "Error Snapshot", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Domain Settings", + "link_to": "Domain Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Printing", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Print Format Builder", + "link_to": "print-format-builder", + "link_type": "Page", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Print Settings", + "link_to": "Print Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Print Format", + "link_to": "Print Format", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Print Style", + "link_to": "Print Style", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Workflow", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Workflow", + "link_to": "Workflow", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Workflow State", + "link_to": "Workflow State", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Workflow Action", + "link_to": "Workflow Action", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2020-12-01 13:38:40.235323", + "modified_by": "Administrator", + "module": "Core", + "name": "Settings", + "owner": "Administrator", + "pin_to_bottom": 1, + "pin_to_top": 0, + "shortcuts": [ + { + "icon": "setting", + "label": "System Settings", + "link_to": "System Settings", + "type": "DocType" + }, + { + "icon": "printer", + "label": "Print Settings", + "link_to": "Print Settings", + "type": "DocType" + }, + { + "icon": "website", + "label": "Website Settings", + "link_to": "Website Settings", + "type": "DocType" + } + ], + "shortcuts_label": "Settings" +} \ No newline at end of file diff --git a/frappe/core/workspace/users/users.json b/frappe/core/workspace/users/users.json new file mode 100644 index 0000000000..05746a00c2 --- /dev/null +++ b/frappe/core/workspace/users/users.json @@ -0,0 +1,167 @@ +{ + "category": "Administration", + "charts": [], + "creation": "2020-03-02 15:12:16.754449", + "developer_mode_only": 0, + "disable_user_customization": 0, + "docstatus": 0, + "doctype": "Workspace", + "extends_another_page": 0, + "hide_custom": 0, + "icon": "users", + "idx": 0, + "is_standard": 1, + "label": "Users", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Users", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "User", + "link_to": "User", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Role", + "link_to": "Role", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Role Profile", + "link_to": "Role Profile", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Logs", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Activity Log", + "link_to": "Activity Log", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Access Log", + "link_to": "Access Log", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Permissions", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Role Permissions Manager", + "link_to": "permission-manager", + "link_type": "Page", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "User Permissions", + "link_to": "User Permission", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Role Permission for Page and Report", + "link_to": "Role Permission for Page and Report", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "User", + "hidden": 0, + "is_query_report": 1, + "label": "Permitted Documents For User", + "link_to": "Permitted Documents For User", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "DocShare", + "hidden": 0, + "is_query_report": 0, + "label": "Document Share Report", + "link_to": "Document Share Report", + "link_type": "Report", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2020-12-01 13:38:40.085519", + "modified_by": "Administrator", + "module": "Core", + "name": "Users", + "owner": "Administrator", + "pin_to_bottom": 0, + "pin_to_top": 0, + "shortcuts": [ + { + "label": "User", + "link_to": "User", + "type": "DocType" + }, + { + "label": "Role", + "link_to": "Role", + "type": "DocType" + }, + { + "label": "Permission Manager", + "link_to": "permission-manager", + "type": "Page" + }, + { + "label": "User Profile", + "link_to": "user-profile", + "type": "Page" + } + ] +} \ No newline at end of file diff --git a/frappe/custom/desk_page/customization/customization.json b/frappe/custom/desk_page/customization/customization.json deleted file mode 100644 index 29f4cb745f..0000000000 --- a/frappe/custom/desk_page/customization/customization.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "cards": [ - { - "hidden": 0, - "label": "Dashboards", - "links": "[\n {\n \"label\": \"Dashboard\",\n \"name\": \"Dashboard\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Dashboard Chart\",\n \"name\": \"Dashboard Chart\",\n \"type\": \"doctype\"\n },\n {\n \"label\": \"Dashboard Chart Source\",\n \"name\": \"Dashboard Chart Source\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Form Customization", - "links": "[\n {\n \"description\": \"Change field properties (hide, readonly, permission etc.)\",\n \"label\": \"Customize Form\",\n \"name\": \"Customize Form\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Add fields to forms.\",\n \"label\": \"Custom Field\",\n \"name\": \"Custom Field\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Add custom javascript to forms.\",\n \"label\": \"Custom Script\",\n \"name\": \"Custom Script\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Add custom forms.\",\n \"label\": \"DocType\",\n \"name\": \"DocType\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Other", - "links": "[\n {\n \"description\": \"Add your own translations\",\n \"label\": \"Custom Translations\",\n \"name\": \"Translation\",\n \"type\": \"doctype\"\n }\n]" - } - ], - "category": "Administration", - "charts": [], - "creation": "2020-03-02 15:15:03.839594", - "developer_mode_only": 0, - "disable_user_customization": 0, - "docstatus": 0, - "doctype": "Desk Page", - "extends_another_page": 0, - "idx": 0, - "is_standard": 1, - "label": "Customization", - "modified": "2020-04-01 11:24:40.787109", - "modified_by": "Administrator", - "module": "Custom", - "name": "Customization", - "owner": "Administrator", - "pin_to_bottom": 0, - "pin_to_top": 0, - "shortcuts": [ - { - "label": "Customize Form", - "link_to": "Customize Form", - "type": "DocType" - }, - { - "label": "Custom Role", - "link_to": "Custom Role", - "type": "DocType" - }, - { - "label": "Custom Script", - "link_to": "Custom Script", - "type": "DocType" - } - ] -} \ No newline at end of file diff --git a/frappe/custom/doctype/custom_script/README.md b/frappe/custom/doctype/client_script/README.md similarity index 100% rename from frappe/custom/doctype/custom_script/README.md rename to frappe/custom/doctype/client_script/README.md diff --git a/frappe/custom/doctype/custom_script/__init__.py b/frappe/custom/doctype/client_script/__init__.py similarity index 100% rename from frappe/custom/doctype/custom_script/__init__.py rename to frappe/custom/doctype/client_script/__init__.py diff --git a/frappe/custom/doctype/custom_script/custom_script.js b/frappe/custom/doctype/client_script/client_script.js similarity index 95% rename from frappe/custom/doctype/custom_script/custom_script.js rename to frappe/custom/doctype/client_script/client_script.js index dc449c506a..21e7334b82 100644 --- a/frappe/custom/doctype/custom_script/custom_script.js +++ b/frappe/custom/doctype/client_script/client_script.js @@ -1,7 +1,7 @@ // Copyright (c) 2016, Frappe Technologies and contributors // For license information, please see license.txt -frappe.ui.form.on('Custom Script', { +frappe.ui.form.on('Client Script', { refresh(frm) { if (frm.doc.dt && frm.doc.script) { frm.add_custom_button(__('Go to {0}', [frm.doc.dt]), @@ -33,6 +33,7 @@ frappe.ui.form.on('Custom Script', { } ], primary_action: ({ cdt }) => { + cdt = d.get_field('cdt').value; frm.events.add_script_for_doctype(frm, cdt); d.hide(); } diff --git a/frappe/custom/doctype/custom_script/custom_script.json b/frappe/custom/doctype/client_script/client_script.json similarity index 86% rename from frappe/custom/doctype/custom_script/custom_script.json rename to frappe/custom/doctype/client_script/client_script.json index 328b247c49..57e6c68094 100644 --- a/frappe/custom/doctype/custom_script/custom_script.json +++ b/frappe/custom/doctype/client_script/client_script.json @@ -2,7 +2,7 @@ "actions": [], "allow_import": 1, "creation": "2013-01-10 16:34:01", - "description": "Adds a client custom script to a DocType", + "description": "Adds a custom client script to a DocType", "doctype": "DocType", "document_type": "Document", "engine": "InnoDB", @@ -22,9 +22,7 @@ "oldfieldname": "dt", "oldfieldtype": "Link", "options": "DocType", - "reqd": 1, - "show_days": 1, - "show_seconds": 1 + "reqd": 1 }, { "fieldname": "script", @@ -32,35 +30,29 @@ "label": "Script", "oldfieldname": "script", "oldfieldtype": "Code", - "options": "JS", - "show_days": 1, - "show_seconds": 1 + "options": "JS" }, { "fieldname": "sample", "fieldtype": "HTML", "label": "Sample", - "options": "

      Custom Script Help

      \n

      Custom Scripts are executed only on the client-side (i.e. in Forms). Here are some examples to get you started

      \n
      \n\n// fetch local_tax_no on selection of customer \n// cur_frm.add_fetch(link_field,  source_fieldname,  target_fieldname); \ncur_frm.add_fetch('customer',  'local_tax_no',  'local_tax_no');\n\n// additional validation on dates \nfrappe.ui.form.on('Task',  'validate',  function(frm) {\n    if (frm.doc.from_date < get_today()) {\n        msgprint('You can not select past date in From Date');\n        validated = false;\n    } \n});\n\n// make a field read-only after saving \nfrappe.ui.form.on('Task',  {\n    refresh: function(frm) {\n        // use the __islocal value of doc,  to check if the doc is saved or not\n        frm.set_df_property('myfield',  'read_only',  frm.doc.__islocal ? 0 : 1);\n    } \n});\n\n// additional permission check\nfrappe.ui.form.on('Task',  {\n    validate: function(frm) {\n        if(user=='user1@example.com' && frm.doc.purpose!='Material Receipt') {\n            msgprint('You are only allowed Material Receipt');\n            validated = false;\n        }\n    } \n});\n\n// calculate sales incentive\nfrappe.ui.form.on('Sales Invoice',  {\n    validate: function(frm) {\n        // calculate incentives for each person on the deal\n        total_incentive = 0\n        $.each(frm.doc.sales_team,  function(i,  d) {\n            // calculate incentive\n            var incentive_percent = 2;\n            if(frm.doc.base_grand_total > 400) incentive_percent = 4;\n            // actual incentive\n            d.incentives = flt(frm.doc.base_grand_total) * incentive_percent / 100;\n            total_incentive += flt(d.incentives)\n        });\n        frm.doc.total_incentive = total_incentive;\n    } \n})\n\n
      ", - "show_days": 1, - "show_seconds": 1 + "options": "

      Client Script Help

      \n

      Client Scripts are executed only on the client-side (i.e. in Forms). Here are some examples to get you started

      \n
      \n\n// fetch local_tax_no on selection of customer \n// cur_frm.add_fetch(link_field,  source_fieldname,  target_fieldname); \ncur_frm.add_fetch('customer',  'local_tax_no',  'local_tax_no');\n\n// additional validation on dates \nfrappe.ui.form.on('Task',  'validate',  function(frm) {\n    if (frm.doc.from_date < get_today()) {\n        msgprint('You can not select past date in From Date');\n        validated = false;\n    } \n});\n\n// make a field read-only after saving \nfrappe.ui.form.on('Task',  {\n    refresh: function(frm) {\n        // use the __islocal value of doc,  to check if the doc is saved or not\n        frm.set_df_property('myfield',  'read_only',  frm.doc.__islocal ? 0 : 1);\n    } \n});\n\n// additional permission check\nfrappe.ui.form.on('Task',  {\n    validate: function(frm) {\n        if(user=='user1@example.com' && frm.doc.purpose!='Material Receipt') {\n            msgprint('You are only allowed Material Receipt');\n            validated = false;\n        }\n    } \n});\n\n// calculate sales incentive\nfrappe.ui.form.on('Sales Invoice',  {\n    validate: function(frm) {\n        // calculate incentives for each person on the deal\n        total_incentive = 0\n        $.each(frm.doc.sales_team,  function(i,  d) {\n            // calculate incentive\n            var incentive_percent = 2;\n            if(frm.doc.base_grand_total > 400) incentive_percent = 4;\n            // actual incentive\n            d.incentives = flt(frm.doc.base_grand_total) * incentive_percent / 100;\n            total_incentive += flt(d.incentives)\n        });\n        frm.doc.total_incentive = total_incentive;\n    } \n})\n\n
      " }, { "default": "0", "fieldname": "enabled", "fieldtype": "Check", - "label": "Enabled", - "show_days": 1, - "show_seconds": 1 + "label": "Enabled" } ], "icon": "fa fa-glass", "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2020-08-24 21:56:07.719579", + "modified": "2021-02-04 13:57:56.509437", "modified_by": "Administrator", "module": "Custom", - "name": "Custom Script", + "name": "Client Script", "owner": "Administrator", "permissions": [ { @@ -86,6 +78,7 @@ "write": 1 } ], + "sort_field": "modified", "sort_order": "ASC", "track_changes": 1 } \ No newline at end of file diff --git a/frappe/custom/doctype/custom_script/custom_script.py b/frappe/custom/doctype/client_script/client_script.py similarity index 84% rename from frappe/custom/doctype/custom_script/custom_script.py rename to frappe/custom/doctype/client_script/client_script.py index e15819de65..e252e2a750 100644 --- a/frappe/custom/doctype/custom_script/custom_script.py +++ b/frappe/custom/doctype/client_script/client_script.py @@ -5,9 +5,9 @@ import frappe from frappe.model.document import Document -class CustomScript(Document): +class ClientScript(Document): def autoname(self): - self.name = self.dt + "-Client" + self.name = self.dt def on_update(self): frappe.clear_cache(doctype=self.dt) diff --git a/frappe/custom/doctype/custom_script/test_custom_script.py b/frappe/custom/doctype/client_script/test_client_script.py similarity index 65% rename from frappe/custom/doctype/custom_script/test_custom_script.py rename to frappe/custom/doctype/client_script/test_client_script.py index 6947e6060d..de113c1ce7 100644 --- a/frappe/custom/doctype/custom_script/test_custom_script.py +++ b/frappe/custom/doctype/client_script/test_client_script.py @@ -6,7 +6,7 @@ from __future__ import unicode_literals import frappe import unittest -# test_records = frappe.get_test_records('Custom Script') +# test_records = frappe.get_test_records('Client Script') -class TestCustomScript(unittest.TestCase): +class TestClientScript(unittest.TestCase): pass diff --git a/frappe/custom/doctype/custom_field/custom_field.json b/frappe/custom/doctype/custom_field/custom_field.json index 3946568bb6..2f0819ab68 100644 --- a/frappe/custom/doctype/custom_field/custom_field.json +++ b/frappe/custom/doctype/custom_field/custom_field.json @@ -30,6 +30,7 @@ "mandatory_depends_on", "read_only_depends_on", "properties", + "non_negative", "reqd", "unique", "read_only", @@ -403,13 +404,20 @@ "fieldname": "hide_border", "fieldtype": "Check", "label": "Hide Border" + }, + { + "default": "0", + "depends_on": "eval:in_list([\"Int\", \"Float\", \"Currency\"], doc.fieldtype)", + "fieldname": "non_negative", + "fieldtype": "Check", + "label": "Non Negative" } ], "icon": "fa fa-glass", "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2020-08-28 11:28:44.377753", + "modified": "2020-10-29 06:14:43.073329", "modified_by": "Administrator", "module": "Custom", "name": "Custom Field", diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py index bc325b654e..ee6e3b9c61 100644 --- a/frappe/custom/doctype/custom_field/custom_field.py +++ b/frappe/custom/doctype/custom_field/custom_field.py @@ -32,6 +32,7 @@ class CustomField(Document): self.fieldname = self.fieldname.lower() def before_insert(self): + self.set_fieldname() meta = frappe.get_meta(self.dt, cached=False) fieldnames = [df.fieldname for df in meta.get("fields")] diff --git a/frappe/custom/doctype/custom_link/custom_link.js b/frappe/custom/doctype/custom_link/custom_link.js deleted file mode 100644 index 8662724b1a..0000000000 --- a/frappe/custom/doctype/custom_link/custom_link.js +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) 2020, Frappe Technologies and contributors -// For license information, please see license.txt - -frappe.ui.form.on('Custom Link', { - refresh: function(frm) { - frm.set_query("document_type", function () { - return { - filters: { - custom: 0, - istable: 0, - module: ['not in', ["Email", "Core", "Custom", "Event Streaming", "Social", "Data Migration", "Geo", "Desk"]] - } - }; - }); - - frm.add_custom_button(__('Go to {0} List', [frm.doc.document_type]), function() { - frappe.set_route('List', frm.doc.document_type); - }); - } -}); diff --git a/frappe/custom/doctype/customize_form/customize_form.js b/frappe/custom/doctype/customize_form/customize_form.js index b1743a96a5..79978a49d7 100644 --- a/frappe/custom/doctype/customize_form/customize_form.js +++ b/frappe/custom/doctype/customize_form/customize_form.js @@ -4,15 +4,35 @@ frappe.provide("frappe.customize_form"); frappe.ui.form.on("Customize Form", { + setup: function(frm) { + // save the last setting if refreshing + window.addEventListener("beforeunload", () => { + if (frm.doc.doc_type && frm.doc.doc_type != "undefined") { + localStorage["customize_doctype"] = frm.doc.doc_type; + } + }); + }, + onload: function(frm) { + frm.disable_save(); frm.set_query("doc_type", function() { return { translate_values: false, filters: [ - ['DocType', 'issingle', '=', 0], - ['DocType', 'custom', '=', 0], - ['DocType', 'name', 'not in', frappe.model.core_doctypes_list], - ['DocType', 'restrict_to_domain', 'in', frappe.boot.active_domains] + ["DocType", "issingle", "=", 0], + ["DocType", "custom", "=", 0], + [ + "DocType", + "name", + "not in", + frappe.model.core_doctypes_list + ], + [ + "DocType", + "restrict_to_domain", + "in", + frappe.boot.active_domains + ] ] }; }); @@ -20,15 +40,15 @@ frappe.ui.form.on("Customize Form", { frm.set_query("default_print_format", function() { return { filters: { - 'print_format_type': ['!=', 'JS'], - 'doc_type': ['=', frm.doc.doc_type] + print_format_type: ["!=", "JS"], + doc_type: ["=", frm.doc.doc_type] } - } + }; }); $(frm.wrapper).on("grid-row-render", function(e, grid_row) { - if(grid_row.doc && grid_row.doc.fieldtype=="Section Break") { - $(grid_row.row).css({"font-weight": "bold"}); + if (grid_row.doc && grid_row.doc.fieldtype == "Section Break") { + $(grid_row.row).css({ "font-weight": "bold" }); } }); @@ -39,18 +59,17 @@ frappe.ui.form.on("Customize Form", { $(frm.wrapper).on("grid-move-row", function(e, frm) { frm.trigger("setup_sortable"); }); - }, doc_type: function(frm) { - if(frm.doc.doc_type) { + if (frm.doc.doc_type) { return frm.call({ method: "fetch_to_customize", doc: frm.doc, freeze: true, callback: function(r) { - if(r) { - if(r._server_messages && r._server_messages.length) { + if (r) { + if (r._server_messages && r._server_messages.length) { frm.set_value("doc_type", ""); } else { frm.refresh(); @@ -65,15 +84,26 @@ frappe.ui.form.on("Customize Form", { }, setup_sortable: function(frm) { - frm.page.body.find('.highlight').removeClass('highlight'); + frm.page.body.find(".highlight").removeClass("highlight"); frm.doc.fields.forEach(function(f, i) { - var data_row = frm.page.body.find('[data-fieldname="fields"] [data-idx="'+ f.idx +'"] .data-row'); + var data_row = frm.page.body.find( + '[data-fieldname="fields"] [data-idx="' + f.idx + '"] .data-row' + ); - if(f.is_custom_field) { + if (f.is_custom_field) { data_row.addClass("highlight"); } else { f._sortable = false; } + if (f.fieldtype == "Table") { + frm.add_custom_button( + f.options, + function() { + frm.set_value("doc_type", f.options); + }, + __("Customize Child Table") + ); + } }); frm.fields_dict.fields.grid.refresh(); }, @@ -82,39 +112,94 @@ frappe.ui.form.on("Customize Form", { frm.disable_save(); frm.page.clear_icons(); - if(frm.doc.doc_type) { + if (frm.doc.doc_type) { frappe.customize_form.set_primary_action(frm); - frm.add_custom_button(__('Go to {0} List', [frm.doc.doc_type]), function() { - frappe.set_route('List', frm.doc.doc_type); - }); + frm.add_custom_button( + __("Go to {0} List", [frm.doc.doc_type]), + function() { + frappe.set_route("List", frm.doc.doc_type); + }, + __("Actions") + ); - frm.add_custom_button(__('Refresh Form'), function() { - frm.script_manager.trigger("doc_type"); - }, "fa fa-refresh", "btn-default"); + frm.add_custom_button( + __("Reload"), + function() { + frm.script_manager.trigger("doc_type"); + }, + __("Actions") + ); - frm.add_custom_button(__('Reset to defaults'), function() { - frappe.customize_form.confirm(__('Remove all customizations?'), frm); - }, "fa fa-eraser", "btn-default"); + frm.add_custom_button( + __("Reset to defaults"), + function() { + frappe.customize_form.confirm( + __("Remove all customizations?"), + frm + ); + }, + __("Actions") + ); - frm.add_custom_button(__('Set Permissions'), function() { - frappe.set_route('permission-manager', frm.doc.doc_type); - }, "fa fa-lock", "btn-default"); + frm.add_custom_button( + __("Set Permissions"), + function() { + frappe.set_route("permission-manager", frm.doc.doc_type); + }, + __("Actions") + ); + } - if(frappe.boot.developer_mode) { - frm.add_custom_button(__('Export Customizations'), function() { + frm.events.setup_export(frm); + frm.events.setup_sort_order(frm); + frm.events.set_default_doc_type(frm); + }, + + set_default_doc_type(frm) { + let doc_type; + if (frappe.route_options && frappe.route_options.doc_type) { + doc_type = frappe.route_options.doc_type; + frappe.route_options = null; + localStorage.removeItem("customize_doctype"); + } + if (!doc_type) { + doc_type = localStorage.getItem("customize_doctype"); + } + if (doc_type) { + setTimeout(() => frm.set_value("doc_type", doc_type), 1000); + } + }, + + setup_export(frm) { + if (frappe.boot.developer_mode) { + frm.add_custom_button( + __("Export Customizations"), + function() { frappe.prompt( [ - {fieldtype:'Link', fieldname:'module', options:'Module Def', - label: __('Module to Export')}, - {fieldtype:'Check', fieldname:'sync_on_migrate', - label: __('Sync on Migrate'), 'default': 1}, - {fieldtype:'Check', fieldname:'with_permissions', - label: __('Export Custom Permissions'), 'default': 1}, + { + fieldtype: "Link", + fieldname: "module", + options: "Module Def", + label: __("Module to Export") + }, + { + fieldtype: "Check", + fieldname: "sync_on_migrate", + label: __("Sync on Migrate"), + default: 1 + }, + { + fieldtype: "Check", + fieldname: "with_permissions", + label: __("Export Custom Permissions"), + default: 1 + } ], function(data) { frappe.call({ - method: 'frappe.modules.utils.export_customizations', + method: "frappe.modules.utils.export_customizations", args: { doctype: frm.doc.doc_type, module: data.module, @@ -123,35 +208,35 @@ frappe.ui.form.on("Customize Form", { } }); }, - __("Select Module")); - }); - } + __("Select Module") + ); + }, + __("Actions") + ); } + }, + setup_sort_order(frm) { // sort order select - if(frm.doc.doc_type) { - var fields = $.map(frm.doc.fields, - function(df) { return frappe.model.is_value_type(df.fieldtype) ? df.fieldname : null; }); + if (frm.doc.doc_type) { + var fields = $.map(frm.doc.fields, function(df) { + return frappe.model.is_value_type(df.fieldtype) + ? df.fieldname + : null; + }); fields = ["", "name", "modified"].concat(fields); frm.set_df_property("sort_field", "options", fields); } - - if(frappe.route_options && frappe.route_options.doc_type) { - setTimeout(function() { - frm.set_value("doc_type", frappe.route_options.doc_type); - frappe.route_options = null; - }, 1000); - } - } }); +// can't delete standard fields frappe.ui.form.on("Customize Form Field", { before_fields_remove: function(frm, doctype, name) { var row = frappe.get_doc(doctype, name); - if(!(row.is_custom_field || row.__islocal)) { + if (!(row.is_custom_field || row.__islocal)) { frappe.msgprint(__("Cannot delete standard field. You can hide it if you want")); - throw "cannot delete custom field"; + throw "cannot delete standard field"; } }, fields_add: function(frm, cdt, cdn) { @@ -160,16 +245,46 @@ frappe.ui.form.on("Customize Form Field", { } }); +// can't delete standard links +frappe.ui.form.on("DocType Link", { + before_links_remove: function(frm, doctype, name) { + let row = frappe.get_doc(doctype, name); + if (!(row.custom || row.__islocal)) { + frappe.msgprint(__("Cannot delete standard link. You can hide it if you want")); + throw "cannot delete standard link"; + } + }, + links_add: function(frm, cdt, cdn) { + let f = frappe.model.get_doc(cdt, cdn); + f.custom = 1; + } +}); + +// can't delete standard actions +frappe.ui.form.on("DocType Action", { + before_actions_remove: function(frm, doctype, name) { + let row = frappe.get_doc(doctype, name); + if (!(row.custom || row.__islocal)) { + frappe.msgprint(__("Cannot delete standard action. You can hide it if you want")); + throw "cannot delete standard action"; + } + }, + actions_add: function(frm, cdt, cdn) { + let f = frappe.model.get_doc(cdt, cdn); + f.custom = 1; + } +}); + frappe.customize_form.set_primary_action = function(frm) { frm.page.set_primary_action(__("Update"), function() { - if(frm.doc.doc_type) { + if (frm.doc.doc_type) { return frm.call({ doc: frm.doc, freeze: true, btn: frm.page.btn_primary, method: "save_customization", callback: function(r) { - if(!r.exc) { + if (!r.exc) { frappe.customize_form.clear_locals_and_refresh(frm); frm.script_manager.trigger("doc_type"); } @@ -180,7 +295,7 @@ frappe.customize_form.set_primary_action = function(frm) { }; frappe.customize_form.confirm = function(msg, frm) { - if(!frm.doc.doc_type) return; + if (!frm.doc.doc_type) return; var d = new frappe.ui.Dialog({ title: 'Reset To Defaults', @@ -192,7 +307,7 @@ frappe.customize_form.confirm = function(msg, frm) { doc: frm.doc, method: "reset_to_defaults", callback: function(r) { - if(r.exc) { + if (r.exc) { frappe.msgprint(r.exc); } else { d.hide(); diff --git a/frappe/custom/doctype/customize_form/customize_form.json b/frappe/custom/doctype/customize_form/customize_form.json index cd57aa23fe..ff102b3c08 100644 --- a/frappe/custom/doctype/customize_form/customize_form.json +++ b/frappe/custom/doctype/customize_form/customize_form.json @@ -10,8 +10,9 @@ "doc_type", "properties", "label", - "default_print_format", "max_attachments", + "search_fields", + "column_break_5", "allow_copy", "istable", "editable_grid", @@ -20,22 +21,27 @@ "track_views", "allow_auto_repeat", "allow_import", - "show_preview_popup", - "image_view", - "column_break_5", + "fields_section_break", + "fields", + "view_settings_section", "title_field", "image_field", - "search_fields", - "section_break_8", - "sort_field", - "column_break_10", - "sort_order", - "section_break_23", + "default_print_format", + "column_break_29", + "show_preview_popup", + "image_view", + "email_settings_section", "email_append_to", "sender_field", "subject_field", - "fields_section_break", - "fields" + "document_actions_section", + "actions", + "document_links_section", + "links", + "section_break_8", + "sort_field", + "column_break_10", + "sort_order" ], "fields": [ { @@ -130,9 +136,11 @@ "label": "Search Fields" }, { + "collapsible": 1, "depends_on": "doc_type", "fieldname": "section_break_8", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "label": "List Settings" }, { "fieldname": "sort_field", @@ -161,7 +169,8 @@ "fieldname": "fields", "fieldtype": "Table", "label": "Fields", - "options": "Customize Form Field" + "options": "Customize Form Field", + "reqd": 1 }, { "default": "0", @@ -200,24 +209,67 @@ "fieldtype": "Check", "label": "Allow document creation via Email" }, - { - "depends_on": "doc_type", - "fieldname": "section_break_23", - "fieldtype": "Section Break" - }, { "default": "0", "fieldname": "show_preview_popup", "fieldtype": "Check", "label": "Show Preview Popup" + }, + { + "collapsible": 1, + "depends_on": "doc_type", + "fieldname": "view_settings_section", + "fieldtype": "Section Break", + "label": "View Settings" + }, + { + "fieldname": "column_break_29", + "fieldtype": "Column Break" + }, + { + "collapsible": 1, + "collapsible_depends_on": "email_append_to", + "depends_on": "doc_type", + "fieldname": "email_settings_section", + "fieldtype": "Section Break", + "label": "Email Settings" + }, + { + "collapsible": 1, + "collapsible_depends_on": "links", + "depends_on": "doc_type", + "fieldname": "document_links_section", + "fieldtype": "Section Break", + "label": "Document Links" + }, + { + "fieldname": "links", + "fieldtype": "Table", + "label": "Links", + "options": "DocType Link" + }, + { + "collapsible": 1, + "collapsible_depends_on": "actions", + "depends_on": "doc_type", + "fieldname": "document_actions_section", + "fieldtype": "Section Break", + "label": "Document Actions" + }, + { + "fieldname": "actions", + "fieldtype": "Table", + "label": "Actions", + "options": "DocType Action" } ], "hide_toolbar": 1, "icon": "fa fa-glass", "idx": 1, + "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2020-04-10 12:16:01.320411", + "modified": "2020-09-24 14:16:49.594012", "modified_by": "Administrator", "module": "Custom", "name": "Customize Form", diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py index d4eeba3f93..50acab46b5 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -6,6 +6,7 @@ from __future__ import unicode_literals Customize Form is a Single DocType used to mask the Property Setter Thus providing a better UI from user perspective """ +import json import frappe import frappe.translate from frappe import _ @@ -14,80 +15,9 @@ from frappe.model.document import Document from frappe.model import no_value_fields, core_doctypes_list from frappe.core.doctype.doctype.doctype import validate_fields_for_doctype, check_email_append_to from frappe.custom.doctype.custom_field.custom_field import create_custom_field +from frappe.custom.doctype.property_setter.property_setter import delete_property_setter from frappe.model.docfield import supports_translation -doctype_properties = { - 'search_fields': 'Data', - 'title_field': 'Data', - 'image_field': 'Data', - 'sort_field': 'Data', - 'sort_order': 'Data', - 'default_print_format': 'Data', - 'allow_copy': 'Check', - 'istable': 'Check', - 'quick_entry': 'Check', - 'editable_grid': 'Check', - 'max_attachments': 'Int', - 'track_changes': 'Check', - 'track_views': 'Check', - 'allow_auto_repeat': 'Check', - 'allow_import': 'Check', - 'show_preview_popup': 'Check', - 'email_append_to': 'Check', - 'subject_field': 'Data', - 'sender_field': 'Data' -} - -docfield_properties = { - 'idx': 'Int', - 'label': 'Data', - 'fieldtype': 'Select', - 'options': 'Text', - 'fetch_from': 'Small Text', - 'fetch_if_empty': 'Check', - 'permlevel': 'Int', - 'width': 'Data', - 'print_width': 'Data', - 'reqd': 'Check', - 'unique': 'Check', - 'ignore_user_permissions': 'Check', - 'in_list_view': 'Check', - 'in_standard_filter': 'Check', - 'in_global_search': 'Check', - 'in_preview': 'Check', - 'bold': 'Check', - 'hidden': 'Check', - 'collapsible': 'Check', - 'collapsible_depends_on': 'Data', - 'print_hide': 'Check', - 'print_hide_if_no_value': 'Check', - 'report_hide': 'Check', - 'allow_on_submit': 'Check', - 'translatable': 'Check', - 'mandatory_depends_on': 'Data', - 'read_only_depends_on': 'Data', - 'depends_on': 'Data', - 'description': 'Text', - 'default': 'Text', - 'precision': 'Select', - 'read_only': 'Check', - 'length': 'Int', - 'columns': 'Int', - 'remember_last_selected_value': 'Check', - 'allow_bulk_edit': 'Check', - 'auto_repeat': 'Link', - 'allow_in_quick_entry': 'Check', - 'hide_border': 'Check', - 'hide_days': 'Check', - 'hide_seconds': 'Check' -} - -allowed_fieldtype_change = (('Currency', 'Float', 'Percent'), ('Small Text', 'Data'), - ('Text', 'Data'), ('Text', 'Text Editor', 'Code', 'Signature', 'HTML Editor'), ('Data', 'Select'), - ('Text', 'Small Text'), ('Text', 'Data', 'Barcode'), ('Code', 'Geolocation'), ('Table', 'Table MultiSelect')) - -allowed_fieldtype_for_options_change = ('Read Only', 'HTML', 'Select', 'Data') - class CustomizeForm(Document): def on_update(self): frappe.db.sql("delete from tabSingles where doctype='Customize Form'") @@ -100,37 +30,70 @@ class CustomizeForm(Document): meta = frappe.get_meta(self.doc_type) - if self.doc_type in core_doctypes_list: - return frappe.msgprint(_("Core DocTypes cannot be customized.")) + self.validate_doctype(meta) - if meta.issingle: - return frappe.msgprint(_("Single DocTypes cannot be customized.")) - - if meta.custom: - return frappe.msgprint(_("Only standard DocTypes are allowed to be customized from Customize Form.")) - - # doctype properties - for property in doctype_properties: - self.set(property, meta.get(property)) - - for d in meta.get("fields"): - new_d = {"fieldname": d.fieldname, "is_custom_field": d.get("is_custom_field"), "name": d.name} - for property in docfield_properties: - new_d[property] = d.get(property) - self.append("fields", new_d) + # load the meta properties on the customize (self) object + self.load_properties(meta) # load custom translation translation = self.get_name_translation() self.label = translation.translated_text if translation else '' - #If allow_auto_repeat is set, add auto_repeat custom field. - if self.allow_auto_repeat: - if not frappe.db.exists('Custom Field', {'fieldname': 'auto_repeat', 'dt': self.doc_type}): - insert_after = self.fields[len(self.fields) - 1].fieldname - df = dict(fieldname='auto_repeat', label='Auto Repeat', fieldtype='Link', options='Auto Repeat', insert_after=insert_after, read_only=1, no_copy=1, print_hide=1) - create_custom_field(self.doc_type, df) + self.create_auto_repeat_custom_field_if_required(meta) + + # NOTE doc (self) is sent to clientside by run_method + + def validate_doctype(self, meta): + ''' + Check if the doctype is allowed to be customized. + ''' + if self.doc_type in core_doctypes_list: + frappe.throw(_("Core DocTypes cannot be customized.")) + + if meta.issingle: + frappe.throw(_("Single DocTypes cannot be customized.")) + + if meta.custom: + frappe.throw(_("Only standard DocTypes are allowed to be customized from Customize Form.")) + + def load_properties(self, meta): + ''' + Load the customize object (this) with the metadata properties + ''' + # doctype properties + for prop in doctype_properties: + self.set(prop, meta.get(prop)) + + for d in meta.get("fields"): + new_d = {"fieldname": d.fieldname, "is_custom_field": d.get("is_custom_field"), "name": d.name} + for prop in docfield_properties: + new_d[prop] = d.get(prop) + self.append("fields", new_d) + + for fieldname in ('links', 'actions'): + for d in meta.get(fieldname): + self.append(fieldname, d) + + def create_auto_repeat_custom_field_if_required(self, meta): + ''' + Create auto repeat custom field if it's not already present + ''' + if self.allow_auto_repeat: + all_fields = [df.fieldname for df in meta.fields] + + if "auto_repeat" in all_fields: + return + + insert_after = self.fields[len(self.fields) - 1].fieldname + create_custom_field(self.doc_type, dict( + fieldname='auto_repeat', + label='Auto Repeat', + fieldtype='Link', + options='Auto Repeat', + insert_after=insert_after, + read_only=1, no_copy=1, print_hide=1 + )) - # NOTE doc is sent to clientside by run_method def get_name_translation(self): '''Get translation object if exists of current doctype name in the default language''' @@ -195,72 +158,142 @@ class CustomizeForm(Document): def set_property_setters(self): meta = frappe.get_meta(self.doc_type) - # doctype property setters - for property in doctype_properties: - if self.get(property) != meta.get(property): - self.make_property_setter(property=property, value=self.get(property), - property_type=doctype_properties[property]) + # doctype + self.set_property_setters_for_doctype(meta) + # docfield for df in self.get("fields"): meta_df = meta.get("fields", {"fieldname": df.fieldname}) - if not meta_df or meta_df[0].get("is_custom_field"): continue + self.set_property_setters_for_docfield(meta, df, meta_df) - for property in docfield_properties: - if property != "idx" and (df.get(property) or '') != (meta_df[0].get(property) or ''): - if property == "fieldtype": - self.validate_fieldtype_change(df, meta_df[0].get(property), df.get(property)) + # action and links + self.set_property_setters_for_actions_and_links(meta) - elif property == "allow_on_submit" and df.get(property): - if not frappe.db.get_value("DocField", - {"parent": self.doc_type, "fieldname": df.fieldname}, "allow_on_submit"): - frappe.msgprint(_("Row {0}: Not allowed to enable Allow on Submit for standard fields")\ - .format(df.idx)) - continue + def set_property_setters_for_doctype(self, meta): + for prop, prop_type in doctype_properties.items(): + if self.get(prop) != meta.get(prop): + self.make_property_setter(prop, self.get(prop), prop_type) - elif property == "reqd" and \ - ((frappe.db.get_value("DocField", - {"parent":self.doc_type,"fieldname":df.fieldname}, "reqd") == 1) \ - and (df.get(property) == 0)): - frappe.msgprint(_("Row {0}: Not allowed to disable Mandatory for standard fields")\ - .format(df.idx)) - continue + def set_property_setters_for_docfield(self, meta, df, meta_df): + for prop, prop_type in docfield_properties.items(): + if prop != "idx" and (df.get(prop) or '') != (meta_df[0].get(prop) or ''): + if not self.allow_property_change(prop, meta_df, df): + continue - elif property == "in_list_view" and df.get(property) \ - and df.fieldtype!="Attach Image" and df.fieldtype in no_value_fields: - frappe.msgprint(_("'In List View' not allowed for type {0} in row {1}") - .format(df.fieldtype, df.idx)) - continue + self.make_property_setter(prop, df.get(prop), prop_type, + fieldname=df.fieldname) - elif property == "precision" and cint(df.get("precision")) > 6 \ - and cint(df.get("precision")) > cint(meta_df[0].get("precision")): - self.flags.update_db = True + def allow_property_change(self, prop, meta_df, df): + if prop == "fieldtype": + self.validate_fieldtype_change(df, meta_df[0].get(prop), df.get(prop)) - elif property == "unique": - self.flags.update_db = True + elif prop == "allow_on_submit" and df.get(prop): + if not frappe.db.get_value("DocField", + {"parent": self.doc_type, "fieldname": df.fieldname}, "allow_on_submit"): + frappe.msgprint(_("Row {0}: Not allowed to enable Allow on Submit for standard fields")\ + .format(df.idx)) + return False - elif (property == "read_only" and cint(df.get("read_only"))==0 - and frappe.db.get_value("DocField", {"parent": self.doc_type, "fieldname": df.fieldname}, "read_only")==1): - # if docfield has read_only checked and user is trying to make it editable, don't allow it - frappe.msgprint(_("You cannot unset 'Read Only' for field {0}").format(df.label)) - continue + elif prop == "reqd" and \ + ((frappe.db.get_value("DocField", + {"parent":self.doc_type,"fieldname":df.fieldname}, "reqd") == 1) \ + and (df.get(prop) == 0)): + frappe.msgprint(_("Row {0}: Not allowed to disable Mandatory for standard fields")\ + .format(df.idx)) + return False - elif property == "options" and df.get("fieldtype") not in allowed_fieldtype_for_options_change: - frappe.msgprint(_("You can't set 'Options' for field {0}").format(df.label)) - continue + elif prop == "in_list_view" and df.get(prop) \ + and df.fieldtype!="Attach Image" and df.fieldtype in no_value_fields: + frappe.msgprint(_("'In List View' not allowed for type {0} in row {1}") + .format(df.fieldtype, df.idx)) + return False - elif property == 'translatable' and not supports_translation(df.get('fieldtype')): - frappe.msgprint(_("You can't set 'Translatable' for field {0}").format(df.label)) - continue + elif prop == "precision" and cint(df.get("precision")) > 6 \ + and cint(df.get("precision")) > cint(meta_df[0].get("precision")): + self.flags.update_db = True - elif (property == 'in_global_search' and - df.in_global_search != meta_df[0].get("in_global_search")): - self.flags.rebuild_doctype_for_global_search = True + elif prop == "unique": + self.flags.update_db = True - self.make_property_setter(property=property, value=df.get(property), - property_type=docfield_properties[property], fieldname=df.fieldname) + elif (prop == "read_only" and cint(df.get("read_only"))==0 + and frappe.db.get_value("DocField", {"parent": self.doc_type, + "fieldname": df.fieldname}, "read_only")==1): + # if docfield has read_only checked and user is trying to make it editable, don't allow it + frappe.msgprint(_("You cannot unset 'Read Only' for field {0}").format(df.label)) + return False + + elif prop == "options" and df.get("fieldtype") not in ALLOWED_OPTIONS_CHANGE: + frappe.msgprint(_("You can't set 'Options' for field {0}").format(df.label)) + return False + + elif prop == 'translatable' and not supports_translation(df.get('fieldtype')): + frappe.msgprint(_("You can't set 'Translatable' for field {0}").format(df.label)) + return False + + elif (prop == 'in_global_search' and + df.in_global_search != meta_df[0].get("in_global_search")): + self.flags.rebuild_doctype_for_global_search = True + + return True + + def set_property_setters_for_actions_and_links(self, meta): + ''' + Apply property setters or create custom records for DocType Action and DocType Link + ''' + for doctype, fieldname, field_map in ( + ('DocType Link', 'links', doctype_link_properties), + ('DocType Action', 'actions', doctype_action_properties) + ): + has_custom = False + items = [] + for i, d in enumerate(self.get(fieldname) or []): + d.idx = i + if frappe.db.exists(doctype, d.name) and not d.custom: + # check property and apply property setter + original = frappe.get_doc(doctype, d.name) + for prop, prop_type in field_map.items(): + if d.get(prop) != original.get(prop): + self.make_property_setter(prop, d.get(prop), prop_type, + apply_on=doctype, row_name=d.name) + items.append(d.name) + else: + # custom - just insert/update + d.parent = self.doc_type + d.custom = 1 + d.save(ignore_permissions=True) + has_custom = True + items.append(d.name) + + self.update_order_property_setter(has_custom, fieldname) + self.clear_removed_items(doctype, items) + + def update_order_property_setter(self, has_custom, fieldname): + ''' + We need to maintain the order of the link/actions if the user has shuffled them. + So we create a new property (ex `links_order`) to keep a list of items. + ''' + property_name = '{}_order'.format(fieldname) + if has_custom: + # save the order of the actions and links + self.make_property_setter(property_name, + json.dumps([d.name for d in self.get(fieldname)]), 'Small Text') + else: + frappe.db.delete('Property Setter', dict(property=property_name, + doc_type=self.doc_type)) + + + def clear_removed_items(self, doctype, items): + ''' + Clear rows that do not appear in `items`. These have been removed by the user. + ''' + if items: + frappe.db.delete(doctype, dict(parent=self.doc_type, custom=1, + name=('not in', items))) + else: + frappe.db.delete(doctype, dict(parent=self.doc_type, custom=1)) def update_custom_fields(self): for i, df in enumerate(self.get("fields")): @@ -278,8 +311,8 @@ class CustomizeForm(Document): d.dt = self.doc_type - for property in docfield_properties: - d.set(property, df.get(property)) + for prop in docfield_properties: + d.set(prop, df.get(prop)) if i!=0: d.insert_after = self.fields[i-1].fieldname @@ -297,12 +330,12 @@ class CustomizeForm(Document): custom_field = frappe.get_doc("Custom Field", meta_df[0].name) changed = False - for property in docfield_properties: - if df.get(property) != custom_field.get(property): - if property == "fieldtype": - self.validate_fieldtype_change(df, meta_df[0].get(property), df.get(property)) + for prop in docfield_properties: + if df.get(prop) != custom_field.get(prop): + if prop == "fieldtype": + self.validate_fieldtype_change(df, meta_df[0].get(prop), df.get(prop)) - custom_field.set(property, df.get(property)) + custom_field.set(prop, df.get(prop)) changed = True # check and update `insert_after` property @@ -328,32 +361,28 @@ class CustomizeForm(Document): if df.get("is_custom_field"): frappe.delete_doc("Custom Field", df.name) - def make_property_setter(self, property, value, property_type, fieldname=None): - self.delete_existing_property_setter(property, fieldname) + def make_property_setter(self, prop, value, property_type, fieldname=None, + apply_on=None, row_name = None): + delete_property_setter(self.doc_type, prop, fieldname) - property_value = self.get_existing_property_value(property, fieldname) + property_value = self.get_existing_property_value(prop, fieldname) if property_value==value: return + if not apply_on: + apply_on = "DocField" if fieldname else "DocType" + # create a new property setter - # ignore validation becuase it will be done at end frappe.make_property_setter({ "doctype": self.doc_type, - "doctype_or_field": "DocField" if fieldname else "DocType", + "doctype_or_field": apply_on, "fieldname": fieldname, - "property": property, + "row_name": row_name, + "property": prop, "value": value, "property_type": property_type - }, ignore_validate=True) - - def delete_existing_property_setter(self, property, fieldname=None): - # first delete existing property setter - existing_property_setter = frappe.db.get_value("Property Setter", {"doc_type": self.doc_type, - "property": property, "field_name['']": fieldname or ''}) - - if existing_property_setter: - frappe.db.sql("delete from `tabProperty Setter` where name=%s", existing_property_setter) + }) def get_existing_property_value(self, property_name, fieldname=None): # check if there is any need to make property setter! @@ -361,20 +390,17 @@ class CustomizeForm(Document): property_value = frappe.db.get_value("DocField", {"parent": self.doc_type, "fieldname": fieldname}, property_name) else: - try: + if frappe.db.has_column("DocType", property_name): property_value = frappe.db.get_value("DocType", self.doc_type, property_name) - except Exception as e: - if frappe.db.is_column_missing(e): - property_value = None - else: - raise + else: + property_value = None return property_value def validate_fieldtype_change(self, df, old_value, new_value): allowed = False self.check_length_for_fieldtypes = [] - for allowed_changes in allowed_fieldtype_change: + for allowed_changes in ALLOWED_FIELDTYPE_CHANGE: if (old_value in allowed_changes and new_value in allowed_changes): allowed = True old_value_length = cint(frappe.db.type_map.get(old_value)[1]) @@ -425,8 +451,113 @@ class CustomizeForm(Document): if not self.doc_type: return - frappe.db.sql("""DELETE FROM `tabProperty Setter` WHERE doc_type=%s - and `field_name`!='naming_series' - and `property`!='options'""", self.doc_type) - frappe.clear_cache(doctype=self.doc_type) + reset_customization(self.doc_type) self.fetch_to_customize() + +def reset_customization(doctype): + setters = frappe.get_all("Property Setter", filters={ + 'doc_type': doctype, + 'field_name': ['!=', 'naming_series'], + 'property': ['!=', 'options'] + }, pluck='name') + + for setter in setters: + frappe.delete_doc("Property Setter", setter) + + frappe.clear_cache(doctype=doctype) + +doctype_properties = { + 'search_fields': 'Data', + 'title_field': 'Data', + 'image_field': 'Data', + 'sort_field': 'Data', + 'sort_order': 'Data', + 'default_print_format': 'Data', + 'allow_copy': 'Check', + 'istable': 'Check', + 'quick_entry': 'Check', + 'editable_grid': 'Check', + 'max_attachments': 'Int', + 'track_changes': 'Check', + 'track_views': 'Check', + 'allow_auto_repeat': 'Check', + 'allow_import': 'Check', + 'show_preview_popup': 'Check', + 'email_append_to': 'Check', + 'subject_field': 'Data', + 'sender_field': 'Data' +} + +docfield_properties = { + 'idx': 'Int', + 'label': 'Data', + 'fieldtype': 'Select', + 'options': 'Text', + 'fetch_from': 'Small Text', + 'fetch_if_empty': 'Check', + 'permlevel': 'Int', + 'width': 'Data', + 'print_width': 'Data', + 'non_negative': 'Check', + 'reqd': 'Check', + 'unique': 'Check', + 'ignore_user_permissions': 'Check', + 'in_list_view': 'Check', + 'in_standard_filter': 'Check', + 'in_global_search': 'Check', + 'in_preview': 'Check', + 'bold': 'Check', + 'hidden': 'Check', + 'collapsible': 'Check', + 'collapsible_depends_on': 'Data', + 'print_hide': 'Check', + 'print_hide_if_no_value': 'Check', + 'report_hide': 'Check', + 'allow_on_submit': 'Check', + 'translatable': 'Check', + 'mandatory_depends_on': 'Data', + 'read_only_depends_on': 'Data', + 'depends_on': 'Data', + 'description': 'Text', + 'default': 'Text', + 'precision': 'Select', + 'read_only': 'Check', + 'length': 'Int', + 'columns': 'Int', + 'remember_last_selected_value': 'Check', + 'allow_bulk_edit': 'Check', + 'auto_repeat': 'Link', + 'allow_in_quick_entry': 'Check', + 'hide_border': 'Check', + 'hide_days': 'Check', + 'hide_seconds': 'Check' +} + +doctype_link_properties = { + 'link_doctype': 'Link', + 'link_fieldname': 'Data', + 'group': 'Data', + 'hidden': 'Check' +} + +doctype_action_properties = { + 'label': 'Link', + 'action_type': 'Select', + 'action': 'Small Text', + 'group': 'Data', + 'hidden': 'Check' +} + + +ALLOWED_FIELDTYPE_CHANGE = ( + ('Currency', 'Float', 'Percent'), + ('Small Text', 'Data'), + ('Text', 'Data'), + ('Text', 'Text Editor', 'Code', 'Signature', 'HTML Editor'), + ('Data', 'Select'), + ('Text', 'Small Text'), + ('Text', 'Data', 'Barcode'), + ('Code', 'Geolocation'), + ('Table', 'Table MultiSelect')) + +ALLOWED_OPTIONS_CHANGE = ('Read Only', 'HTML', 'Select', 'Data') diff --git a/frappe/custom/doctype/customize_form/test_customize_form.py b/frappe/custom/doctype/customize_form/test_customize_form.py index cace25a03d..f5e0371c1f 100644 --- a/frappe/custom/doctype/customize_form/test_customize_form.py +++ b/frappe/custom/doctype/customize_form/test_customize_form.py @@ -5,6 +5,7 @@ from __future__ import unicode_literals import frappe, unittest, json from frappe.test_runner import make_test_records_for_doctype from frappe.core.doctype.doctype.doctype import InvalidFieldNameError +from frappe.core.doctype.doctype.test_doctype import new_doctype test_dependencies = ["Custom Field", "Property Setter"] class TestCustomizeForm(unittest.TestCase): @@ -24,6 +25,7 @@ class TestCustomizeForm(unittest.TestCase): def setUp(self): self.insert_custom_field() + frappe.db.delete('Property Setter', dict(doc_type='Event')) frappe.db.commit() frappe.clear_cache(doctype="Event") @@ -185,9 +187,75 @@ class TestCustomizeForm(unittest.TestCase): d.run_method("save_customization") def test_core_doctype_customization(self): - d = self.get_customize_form('User') - e = self.get_customize_form('Custom Field') + self.assertRaises(frappe.ValidationError, self.get_customize_form, 'User') - # core doctype is invalid, hence no attributes are set - self.assertEquals(d.get("fields"), []) - self.assertEquals(e.get("fields"), []) + def test_custom_link(self): + try: + # create a dummy doctype linked to Event + testdt_name = 'Test Link for Event' + testdt = new_doctype(testdt_name, fields=[ + dict(fieldtype='Link', fieldname='event', options='Event') + ]).insert() + + testdt_name1 = 'Test Link for Event 1' + testdt1 = new_doctype(testdt_name1, fields=[ + dict(fieldtype='Link', fieldname='event', options='Event') + ]).insert() + + # add a custom link + d = self.get_customize_form("Event") + + d.append('links', dict(link_doctype=testdt_name, link_fieldname='event', group='Tests')) + d.append('links', dict(link_doctype=testdt_name1, link_fieldname='event', group='Tests')) + + d.run_method("save_customization") + + frappe.clear_cache() + event = frappe.get_meta('Event') + + # check links exist + self.assertTrue([d.name for d in event.links if d.link_doctype == testdt_name]) + self.assertTrue([d.name for d in event.links if d.link_doctype == testdt_name1]) + + # check order + order = json.loads(event.links_order) + self.assertListEqual(order, [d.name for d in event.links]) + + # remove the link + d = self.get_customize_form("Event") + d.links = [] + d.run_method("save_customization") + + frappe.clear_cache() + event = frappe.get_meta('Event') + self.assertFalse([d.name for d in (event.links or []) if d.link_doctype == testdt_name]) + finally: + testdt.delete() + testdt1.delete() + + def test_custom_action(self): + test_route = '/app/List/DocType' + + # create a dummy action (route) + d = self.get_customize_form("Event") + d.append('actions', dict(label='Test Action', action_type='Route', action=test_route)) + d.run_method("save_customization") + + frappe.clear_cache() + event = frappe.get_meta('Event') + + # check if added to meta + action = [d for d in event.actions if d.label=='Test Action'] + self.assertEqual(len(action), 1) + self.assertEqual(action[0].action, test_route) + + # clear the action + d = self.get_customize_form("Event") + d.actions = [] + d.run_method("save_customization") + + frappe.clear_cache() + event = frappe.get_meta('Event') + + action = [d for d in event.actions if d.label=='Test Action'] + self.assertEqual(len(action), 0) diff --git a/frappe/custom/doctype/customize_form_field/customize_form_field.json b/frappe/custom/doctype/customize_form_field/customize_form_field.json index 1c7349ef01..227114137c 100644 --- a/frappe/custom/doctype/customize_form_field/customize_form_field.json +++ b/frappe/custom/doctype/customize_form_field/customize_form_field.json @@ -11,8 +11,7 @@ "label", "fieldtype", "fieldname", - "hide_seconds", - "hide_days", + "non_negative", "reqd", "unique", "in_list_view", @@ -23,6 +22,7 @@ "allow_in_quick_entry", "translatable", "column_break_7", + "default", "precision", "length", "options", @@ -47,8 +47,9 @@ "column_break_33", "read_only_depends_on", "display", - "default", "in_filter", + "hide_seconds", + "hide_days", "column_break_21", "description", "print_hide", @@ -100,6 +101,7 @@ "depends_on": "eval:!in_list([\"Section Break\", \"Column Break\", \"Button\", \"HTML\"], doc.fieldtype)", "fieldname": "reqd", "fieldtype": "Check", + "in_list_view": 1, "label": "Mandatory", "oldfieldname": "reqd", "oldfieldtype": "Check", @@ -283,7 +285,7 @@ }, { "fieldname": "default", - "fieldtype": "Text", + "fieldtype": "Small Text", "label": "Default", "oldfieldname": "default", "oldfieldtype": "Text" @@ -413,13 +415,20 @@ "fieldname": "hide_border", "fieldtype": "Check", "label": "Hide Border" + }, + { + "default": "0", + "depends_on": "eval:in_list([\"Int\", \"Float\", \"Currency\"], doc.fieldtype)", + "fieldname": "non_negative", + "fieldtype": "Check", + "label": "Non Negative" } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-08-28 11:28:59.084060", + "modified": "2020-10-29 06:11:57.661039", "modified_by": "Administrator", "module": "Custom", "name": "Customize Form Field", diff --git a/frappe/custom/doctype/package_publish_target/__init__.py b/frappe/custom/doctype/doctype_layout/__init__.py similarity index 100% rename from frappe/custom/doctype/package_publish_target/__init__.py rename to frappe/custom/doctype/doctype_layout/__init__.py diff --git a/frappe/custom/doctype/doctype_layout/doctype_layout.js b/frappe/custom/doctype/doctype_layout/doctype_layout.js new file mode 100644 index 0000000000..679330e065 --- /dev/null +++ b/frappe/custom/doctype/doctype_layout/doctype_layout.js @@ -0,0 +1,30 @@ +// Copyright (c) 2020, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('DocType Layout', { + refresh: function(frm) { + frm.trigger('document_type'); + frm.events.set_button(frm); + }, + + document_type(frm) { + frm.set_fields_as_options('fields', frm.doc.document_type, null, [], 'fieldname').then(() => { + // child table empty? then show all fields as default + if (frm.doc.document_type) { + if (!(frm.doc.fields || []).length) { + for (let f of frappe.get_doc('DocType', frm.doc.document_type).fields) { + frm.add_child('fields', { fieldname: f.fieldname, label: f.label }); + } + } + } + }); + }, + + set_button(frm) { + if (!frm.is_new()) { + frm.add_custom_button(__('Go to {0} List', [frm.doc.name]), () => { + window.open(`/app/list/${frappe.router.slug(frm.doc.name)}/list`); + }); + } + } +}); diff --git a/frappe/custom/doctype/doctype_layout/doctype_layout.json b/frappe/custom/doctype/doctype_layout/doctype_layout.json new file mode 100644 index 0000000000..e47c9e03e0 --- /dev/null +++ b/frappe/custom/doctype/doctype_layout/doctype_layout.json @@ -0,0 +1,72 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "Prompt", + "creation": "2020-11-16 17:05:35.306846", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "document_type", + "route", + "fields", + "client_script" + ], + "fields": [ + { + "fieldname": "document_type", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Document Type", + "options": "DocType", + "reqd": 1 + }, + { + "fieldname": "fields", + "fieldtype": "Table", + "label": "Fields", + "options": "DocType Layout Field", + "reqd": 1 + }, + { + "fieldname": "client_script", + "fieldtype": "Code", + "label": "Client Script" + }, + { + "fieldname": "route", + "fieldtype": "Data", + "label": "Route", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2020-12-10 15:01:04.352184", + "modified_by": "Administrator", + "module": "Custom", + "name": "DocType Layout", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "read": 1, + "role": "Guest" + } + ], + "route": "doctype-layout", + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/custom/doctype/doctype_layout/doctype_layout.py b/frappe/custom/doctype/doctype_layout/doctype_layout.py new file mode 100644 index 0000000000..a4fe9a9bce --- /dev/null +++ b/frappe/custom/doctype/doctype_layout/doctype_layout.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals + +from frappe.model.document import Document + +from frappe.desk.utils import slug + +class DocTypeLayout(Document): + def validate(self): + if not self.route: + self.route = slug(self.name) diff --git a/frappe/custom/doctype/doctype_layout/patches/convert_web_forms_to_doctype_layout.py b/frappe/custom/doctype/doctype_layout/patches/convert_web_forms_to_doctype_layout.py new file mode 100644 index 0000000000..4e44743b48 --- /dev/null +++ b/frappe/custom/doctype/doctype_layout/patches/convert_web_forms_to_doctype_layout.py @@ -0,0 +1,13 @@ +import frappe + +def execute(): + for web_form_name in frappe.db.get_all('Web Form', pluck='name'): + web_form = frappe.get_doc('Web Form', web_form_name) + doctype_layout = frappe.get_doc(dict( + doctype = 'DocType Layout', + document_type = web_form.doc_type, + name = web_form.title, + route = web_form.route, + fields = [dict(fieldname = d.fieldname, label=d.label) for d in web_form.web_form_fields if d.fieldname] + )).insert() + print(doctype_layout.name) \ No newline at end of file diff --git a/frappe/custom/doctype/custom_link/test_custom_link.py b/frappe/custom/doctype/doctype_layout/test_doctype_layout.py similarity index 80% rename from frappe/custom/doctype/custom_link/test_custom_link.py rename to frappe/custom/doctype/doctype_layout/test_doctype_layout.py index a292f73ad0..5765c86262 100644 --- a/frappe/custom/doctype/custom_link/test_custom_link.py +++ b/frappe/custom/doctype/doctype_layout/test_doctype_layout.py @@ -6,5 +6,5 @@ from __future__ import unicode_literals # import frappe import unittest -class TestCustomLink(unittest.TestCase): +class TestDocTypeLayout(unittest.TestCase): pass diff --git a/frappe/custom/doctype/package_publish_tool/__init__.py b/frappe/custom/doctype/doctype_layout_field/__init__.py similarity index 100% rename from frappe/custom/doctype/package_publish_tool/__init__.py rename to frappe/custom/doctype/doctype_layout_field/__init__.py diff --git a/frappe/custom/doctype/doctype_layout_field/doctype_layout_field.json b/frappe/custom/doctype/doctype_layout_field/doctype_layout_field.json new file mode 100644 index 0000000000..a1a36216c3 --- /dev/null +++ b/frappe/custom/doctype/doctype_layout_field/doctype_layout_field.json @@ -0,0 +1,39 @@ +{ + "actions": [], + "creation": "2020-11-16 16:03:43.771801", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "label", + "fieldname" + ], + "fields": [ + { + "fieldname": "fieldname", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Fieldname", + "reqd": 1 + }, + { + "fieldname": "label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Label", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2020-11-16 17:13:01.892345", + "modified_by": "Administrator", + "module": "Custom", + "name": "DocType Layout Field", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/custom/doctype/doctype_layout_field/doctype_layout_field.py b/frappe/custom/doctype/doctype_layout_field/doctype_layout_field.py new file mode 100644 index 0000000000..7f8c8edfce --- /dev/null +++ b/frappe/custom/doctype/doctype_layout_field/doctype_layout_field.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class DocTypeLayoutField(Document): + pass diff --git a/frappe/custom/doctype/package_document_type/package_document_type.json b/frappe/custom/doctype/package_document_type/package_document_type.json deleted file mode 100644 index 6d011bd4e4..0000000000 --- a/frappe/custom/doctype/package_document_type/package_document_type.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "actions": [], - "creation": "2020-05-14 16:45:47.196395", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "document_type", - "column_break_2", - "attachments", - "overwrite", - "section_break_4", - "filters_json" - ], - "fields": [ - { - "fieldname": "document_type", - "fieldtype": "Link", - "in_list_view": 1, - "label": "Document Type", - "options": "DocType", - "reqd": 1 - }, - { - "fieldname": "column_break_2", - "fieldtype": "Column Break" - }, - { - "default": "0", - "fieldname": "attachments", - "fieldtype": "Check", - "in_list_view": 1, - "label": "Include Attachments" - }, - { - "default": "0", - "fieldname": "overwrite", - "fieldtype": "Check", - "in_list_view": 1, - "label": "Overwrite" - }, - { - "fieldname": "section_break_4", - "fieldtype": "Section Break" - }, - { - "fieldname": "filters_json", - "fieldtype": "Code", - "label": "Filters", - "options": "JSON" - } - ], - "istable": 1, - "links": [], - "modified": "2020-05-14 16:45:47.196395", - "modified_by": "Administrator", - "module": "Custom", - "name": "Package Document Type", - "owner": "Administrator", - "permissions": [], - "quick_entry": 1, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1 -} \ No newline at end of file diff --git a/frappe/custom/doctype/package_publish_target/package_publish_target.json b/frappe/custom/doctype/package_publish_target/package_publish_target.json deleted file mode 100644 index baeb7cb8bc..0000000000 --- a/frappe/custom/doctype/package_publish_target/package_publish_target.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "actions": [], - "creation": "2020-05-13 16:04:32.724663", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "instance_url", - "username", - "password" - ], - "fields": [ - { - "fieldname": "instance_url", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Site URL", - "reqd": 1 - }, - { - "fieldname": "username", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Username", - "reqd": 1 - }, - { - "fieldname": "password", - "fieldtype": "Password", - "in_list_view": 1, - "label": "Password", - "reqd": 1 - } - ], - "istable": 1, - "links": [], - "modified": "2020-05-15 17:35:16.282235", - "modified_by": "Administrator", - "module": "Custom", - "name": "Package Publish Target", - "owner": "Administrator", - "permissions": [], - "quick_entry": 1, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1 -} \ No newline at end of file diff --git a/frappe/custom/doctype/package_publish_tool/package_publish_tool.js b/frappe/custom/doctype/package_publish_tool/package_publish_tool.js deleted file mode 100644 index a0190a8d8c..0000000000 --- a/frappe/custom/doctype/package_publish_tool/package_publish_tool.js +++ /dev/null @@ -1,159 +0,0 @@ -// Copyright (c) 2020, Frappe Technologies and contributors -// For license information, please see license.txt - -frappe.ui.form.on('Package Publish Tool', { - refresh: function(frm) { - frm.set_query("document_type", "package_details", function () { - return { - filters: { - "istable": 0, - } - }; - }); - - frappe.realtime.on("package", (data) => { - frm.dashboard.show_progress(data.prefix, data.progress / data.total * 100, __("{0}", [data.message])); - if ((data.progress+1) != data.total) { - frm.dashboard.show_progress(data.prefix, data.progress / data.total * 100, __("{0}", [data.message])); - } else { - frm.dashboard.hide_progress(); - } - }); - - frm.trigger("show_instructions"); - frm.trigger("last_deployed_on"); - frm.trigger("set_dirty_trigger"); - frm.trigger("set_deploy_primary_action"); - }, - last_deployed_on: function(frm) { - if (frm.doc.last_deployed_on) { - frm.trigger("show_indicator"); - } - }, - show_indicator: function(frm) { - let pretty_date = frappe.datetime.prettyDate(frm.doc.last_deployed_on); - frm.page.set_indicator(__("Last published {0}", [pretty_date]), "blue"); - }, - set_dirty_trigger: function(frm) { - $(frm.wrapper).on("dirty", function() { - frm.page.set_primary_action(__('Save'), () => frm.save()); - }); - }, - set_deploy_primary_action: function(frm) { - if (frm.doc.package_details.length && frm.doc.instances.length) { - frm.page.set_primary_action(__("Publish"), function () { - frappe.show_alert({ - message: __("Publishing documents..."), - indicator: "green" - }); - - frappe.call({ - method: "frappe.custom.doctype.package_publish_tool.package_publish_tool.deploy_package", - callback: function() { - frm.reload_doc(); - frappe.msgprint(__("Documents have been published.")); - } - }); - }); - } - }, - show_instructions: function(frm) { - let field = frm.get_field("html_info"); - field.html(` -

      - Package Publish Tool let's you copy documents from your site to any other remote site. - Follow the steps below to publish. -

      -
        -
      1. Add Document Types that you want to copy from the table below. You can also add filters by expanding the row.
      2. -
      3. Add the Sites URL where you want to copy these documents, and enter the Username and Password.
      4. -
      5. Click on Save. Now, you can click on Publish and the documents will be copied.
      6. -
      - `); - } -}); - -frappe.ui.form.on('Package Document Type', { - form_render: function (frm, cdt, cdn) { - function _show_filters(filters, table) { - table.find('tbody').empty(); - - if (filters.length > 0) { - filters.forEach(filter => { - const filter_row = - $(` - ${filter[1]} - ${filter[2] || ""} - ${filter[3]} - `); - - table.find('tbody').append(filter_row); - }); - } else { - const filter_row = $(` - ${__("Click to Set Filters")}`); - table.find('tbody').append(filter_row); - } - } - - let row = frappe.get_doc(cdt, cdn); - - let wrapper = $(`[data-fieldname="filters_json"]`).empty(); - let table = $(` - - - - - - - - - -
      ${__('Filter')}${__('Condition')}${__('Value')}
      `).appendTo(wrapper); - $(`

      ${__("Click table to edit")}

      `).appendTo(wrapper); - - let filters = JSON.parse(row.filters_json || '[]'); - _show_filters(filters, table); - - table.on('click', () => { - if (!row.document_type) { - frappe.msgprint(__("Select Document Type.")); - return; - } - - frappe.model.with_doctype(row.document_type, function() { - let dialog = new frappe.ui.Dialog({ - title: __('Set Filters'), - fields: [ - { - fieldtype: 'HTML', - label: 'Filters', - fieldname: 'filter_area', - } - ], - primary_action: function() { - let values = filter_group.get_filters(); - let flt = []; - if (values) { - values.forEach(function(value) { - flt.push([value[0], value[1], value[2], value[3]]); - }); - } - row.filters_json = JSON.stringify(flt); - _show_filters(flt, table); - dialog.hide(); - }, - primary_action_label: "Set" - }); - - let filter_group = new frappe.ui.FilterGroup({ - parent: dialog.get_field('filter_area').$wrapper, - doctype: row.document_type, - on_change: () => {}, - }); - filter_group.add_filters_to_filter_group(filters); - dialog.show(); - }); - }); - }, -}); diff --git a/frappe/custom/doctype/package_publish_tool/package_publish_tool.json b/frappe/custom/doctype/package_publish_tool/package_publish_tool.json deleted file mode 100644 index 0f85ae0348..0000000000 --- a/frappe/custom/doctype/package_publish_tool/package_publish_tool.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "actions": [], - "creation": "2020-05-13 15:54:38.082657", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "html_info", - "sb_00", - "package_details", - "sb_01", - "instances", - "last_deployed_on" - ], - "fields": [ - { - "description": "Click on the row for accessing filters.", - "fieldname": "package_details", - "fieldtype": "Table", - "label": "Document Types", - "options": "Package Document Type", - "reqd": 1 - }, - { - "fieldname": "instances", - "fieldtype": "Table", - "label": "Sites", - "options": "Package Publish Target", - "reqd": 1 - }, - { - "fieldname": "html_info", - "fieldtype": "HTML" - }, - { - "fieldname": "last_deployed_on", - "fieldtype": "Datetime", - "hidden": 1, - "label": "Last Deployed On", - "read_only": 1 - }, - { - "fieldname": "sb_00", - "fieldtype": "Section Break" - }, - { - "fieldname": "sb_01", - "fieldtype": "Section Break" - } - ], - "issingle": 1, - "links": [], - "modified": "2020-05-15 17:31:37.060199", - "modified_by": "Administrator", - "module": "Custom", - "name": "Package Publish Tool", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "role": "System Manager", - "share": 1, - "write": 1 - }, - { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "role": "All", - "share": 1, - "write": 1 - } - ], - "quick_entry": 1, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1 -} \ No newline at end of file diff --git a/frappe/custom/doctype/package_publish_tool/package_publish_tool.py b/frappe/custom/doctype/package_publish_tool/package_publish_tool.py deleted file mode 100644 index b73f93a628..0000000000 --- a/frappe/custom/doctype/package_publish_tool/package_publish_tool.py +++ /dev/null @@ -1,178 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt - -from __future__ import unicode_literals -import frappe -import json -import datetime -import base64 -from frappe.model.document import Document -from frappe.utils.file_manager import save_file, get_file -from frappe import _ -from six import string_types -from frappe.frappeclient import FrappeClient -from frappe.utils import get_datetime_str, get_datetime -from frappe.utils.password import get_decrypted_password - -class PackagePublishTool(Document): - pass - -@frappe.whitelist() -def deploy_package(): - package, doc = export_package() - - file_name = "Package-" + get_datetime_str(get_datetime()) - - length = len(doc.instances) - for idx, instance in enumerate(doc.instances): - frappe.publish_realtime("package", {"progress": idx, "total": length, "message": instance.instance_url, "prefix": _("Deploying")}, - user=frappe.session.user) - - install_package_to_remote(package, instance) - - frappe.db.set_value("Package Publish Tool", "Package Publish Tool", "last_deployed_on", frappe.utils.now_datetime()) - -def install_package_to_remote(package, instance): - try: - connection = FrappeClient(instance.instance_url, instance.username, get_decrypted_password(instance.doctype, instance.name)) - except Exception: - frappe.log_error(frappe.get_traceback()) - frappe.throw(_("Couldn't connect to site {0}. Please check Error Logs.").format(instance.instance_url)) - - try: - connection.post_request({ - "cmd": "frappe.custom.doctype.package_publish_tool.package_publish_tool.import_package", - "package": json.dumps(package) - }) - except Exception: - frappe.log_error(frappe.get_traceback()) - frappe.throw(_("Error while installing package to site {0}. Please check Error Logs.").format(instance.instance_url)) - -@frappe.whitelist() -def export_package(): - """Export package as JSON.""" - package_doc = frappe.get_single("Package Publish Tool") - package = [] - - for doctype in package_doc.package_details: - filters = [] - - if doctype.get("filters_json"): - filters = json.loads(doctype.get("filters_json")) - - docs = frappe.get_all(doctype.get("document_type"), filters=filters) - length = len(docs) - - for idx, doc in enumerate(docs): - frappe.publish_realtime("package", { - "progress":idx, "total":length, - "message":doctype.get("document_type"), - "prefix": _("Exporting") - }, - user=frappe.session.user) - - document = frappe.get_doc(doctype.get("document_type"), doc.name).as_dict() - attachments = [] - - if doctype.attachments: - filters = { - "attached_to_doctype": document.get("doctype"), - "attached_to_name": document.get("name") - } - - for f in frappe.get_list("File", filters=filters): - fname, fcontents = get_file(f.name) - attachments.append({ - "fname": fname, - "content": base64.b64encode(fcontents).decode('ascii') - }) - - document.update({ - "__attachments": attachments, - "__overwrite": True if doctype.overwrite else False - }) - - package.append(document) - - return post_process(package), package_doc - -@frappe.whitelist() -def import_package(package=None): - """Import package from JSON.""" - frappe.only_for("System Manager") - if isinstance(package, string_types): - package = json.loads(package) - - for doc in package: - modified = doc.pop("modified") - overwrite = doc.pop("__overwrite") - attachments = doc.pop("__attachments") - exists = frappe.db.exists(doc.get("doctype"), doc.get("name")) - - if not exists: - d = frappe.get_doc(doc).insert(ignore_permissions=True, ignore_if_duplicate=True) - if attachments: - add_attachment(attachments, d) - else: - docname = doc.pop("name") - document = frappe.get_doc(doc.get("doctype"), docname) - - if overwrite: - update_document(document, doc, attachments) - - else: - if frappe.utils.get_datetime(document.modified) < frappe.utils.get_datetime(modified): - update_document(document, doc, attachments) - -def update_document(document, doc, attachments): - document.update(doc) - document.save() - if attachments: - add_attachment(attachments, document) - -def add_attachment(attachments, doc): - for attachment in attachments: - save_file(attachment.get("fname"), base64.b64decode(attachment.get("content")), doc.get("doctype"), doc.get("name")) - -def post_process(package): - """Remove the keys from Document and Child Document. Convert datetime, date, time to str.""" - del_keys = ('modified_by', 'creation', 'owner', 'idx', 'docstatus') - child_del_keys = ('modified_by', 'creation', 'owner', 'idx', 'docstatus', 'name') - - for doc in package: - for key in del_keys: - if key in doc: - del doc[key] - - for key, value in doc.items(): - stringified_value = get_stringified_value(value) - if stringified_value: - doc[key] = stringified_value - - if not isinstance(value, list): - continue - - for child in value: - for child_key in child_del_keys: - if child_key in child: - del child[child_key] - - for child_key, child_value in child.items(): - stringified_value = get_stringified_value(child_value) - if stringified_value: - child[child_key] = stringified_value - - return package - -def get_stringified_value(value): - if isinstance(value, datetime.datetime): - return frappe.utils.get_datetime_str(value) - - if isinstance(value, datetime.date): - return frappe.utils.get_date_str(value) - - if isinstance(value, datetime.timedelta): - return frappe.utils.get_time_str(value) - - return None diff --git a/frappe/custom/doctype/property_setter/property_setter.json b/frappe/custom/doctype/property_setter/property_setter.json index 5888e11969..b318d92c5a 100644 --- a/frappe/custom/doctype/property_setter/property_setter.json +++ b/frappe/custom/doctype/property_setter/property_setter.json @@ -1,358 +1,133 @@ { - "allow_copy": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2013-01-10 16:34:04", - "custom": 0, - "description": "Property Setter overrides a standard DocType or Field property", - "docstatus": 0, - "doctype": "DocType", - "document_type": "Setup", - "editable_grid": 0, - "engine": "InnoDB", + "actions": [], + "creation": "2013-01-10 16:34:04", + "description": "Property Setter overrides a standard DocType or Field property", + "doctype": "DocType", + "document_type": "Setup", + "engine": "InnoDB", + "field_order": [ + "help", + "sb0", + "doctype_or_field", + "doc_type", + "field_name", + "row_name", + "column_break0", + "property", + "property_type", + "value", + "default_value" + ], "fields": [ { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "help", - "fieldtype": "HTML", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Help", - "length": 0, - "no_copy": 0, - "options": "
      Please don't update it as it can mess up your form. Use the Customize Form View and Custom Fields to set properties!
      ", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "help", + "fieldtype": "HTML", + "label": "Help", + "options": "
      Please don't update it as it can mess up your form. Use the Customize Form View and Custom Fields to set properties!
      " + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "sb0", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "sb0", + "fieldtype": "Section Break" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "eval:doc.__islocal", - "fieldname": "doctype_or_field", - "fieldtype": "Select", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "in_standard_filter": 1, - "label": "DocType or Field", - "length": 0, - "no_copy": 0, - "options": "\nDocField\nDocType", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "doctype_or_field", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Applied On", + "options": "\nDocField\nDocType\nDocType Link\nDocType Action", + "read_only_depends_on": "eval:!doc.__islocal", + "reqd": 1 + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "description": "New value to be set", - "fieldname": "value", - "fieldtype": "Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Set Value", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "description": "New value to be set", + "fieldname": "value", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "Set Value" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break0", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "column_break0", + "fieldtype": "Column Break" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "doc_type", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "in_standard_filter": 1, - "label": "DocType", - "length": 0, - "no_copy": 0, - "options": "DocType", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 1, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "doc_type", + "fieldtype": "Link", + "in_standard_filter": 1, + "label": "DocType", + "options": "DocType", + "reqd": 1, + "search_index": 1 + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "eval:doc.doctype_or_field=='DocField'", - "description": "ID (name) of the entity whose property is to be set", - "fieldname": "field_name", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "in_standard_filter": 1, - "label": "Field Name", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 1, - "set_only_once": 0, - "unique": 0 - }, + "depends_on": "eval:doc.doctype_or_field=='DocField'", + "description": "ID (name) of the entity whose property is to be set", + "fieldname": "field_name", + "fieldtype": "Data", + "in_standard_filter": 1, + "label": "Field Name", + "search_index": 1 + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "property", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "in_standard_filter": 1, - "label": "Property", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 1, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "property", + "fieldtype": "Data", + "in_standard_filter": 1, + "label": "Property", + "reqd": 1, + "search_index": 1 + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "property_type", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Property Type", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "property_type", + "fieldtype": "Data", + "label": "Property Type" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "default_value", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Default Value", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 + "fieldname": "default_value", + "fieldtype": "Data", + "label": "Default Value" + }, + { + "description": "For DocType Link / DocType Action", + "fieldname": "row_name", + "fieldtype": "Data", + "label": "Row Name" } - ], - "hide_heading": 0, - "hide_toolbar": 0, - "icon": "fa fa-glass", - "idx": 1, - "image_view": 0, - "in_create": 0, - - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2016-12-29 14:39:50.172883", - "modified_by": "Administrator", - "module": "Custom", - "name": "Property Setter", - "owner": "Administrator", + ], + "icon": "fa fa-glass", + "idx": 1, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2020-09-24 14:42:38.599684", + "modified_by": "Administrator", + "module": "Custom", + "name": "Property Setter", + "owner": "Administrator", "permissions": [ { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "is_custom": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Administrator", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Administrator", + "share": 1, "write": 1 - }, + }, { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "is_custom": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, "write": 1 } - ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "search_fields": "doc_type,property", - "sort_order": "DESC", - "track_changes": 1, - "track_seen": 0 + ], + "search_fields": "doc_type,property", + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 } \ No newline at end of file diff --git a/frappe/custom/doctype/property_setter/property_setter.py b/frappe/custom/doctype/property_setter/property_setter.py index d8ab5ede73..56e5829271 100644 --- a/frappe/custom/doctype/property_setter/property_setter.py +++ b/frappe/custom/doctype/property_setter/property_setter.py @@ -11,13 +11,16 @@ not_allowed_fieldtype_change = ['naming_series'] class PropertySetter(Document): def autoname(self): - self.name = self.doc_type + "-" \ - + (self.field_name and (self.field_name + "-") or "") \ - + self.property + self.name = '{doctype}-{field}-{property}'.format( + doctype = self.doc_type, + field = self.field_name or self.row_name or 'main', + property = self.property + ) def validate(self): self.validate_fieldtype_change() - self.delete_property_setter() + if self.is_new(): + delete_property_setter(self.doc_type, self.property, self.field_name) # clear cache frappe.clear_cache(doctype = self.doc_type) @@ -27,15 +30,6 @@ class PropertySetter(Document): self.property == 'fieldtype': frappe.throw(_("Field type cannot be changed for {0}").format(self.field_name)) - def delete_property_setter(self): - """delete other property setters on this, if this is new""" - if self.get('__islocal'): - frappe.db.sql("""delete from `tabProperty Setter` where - doctype_or_field = %(doctype_or_field)s - and doc_type = %(doc_type)s - and coalesce(field_name,'') = coalesce(%(field_name)s, '') - and property = %(property)s""", self.get_valid_dict()) - def get_property_list(self, dt): return frappe.db.get_all('DocField', fields=['fieldname', 'label', 'fieldtype'], @@ -89,3 +83,12 @@ def make_property_setter(doctype, fieldname, property, value, property_type, for property_setter.flags.validate_fields_for_doctype = validate_fields_for_doctype property_setter.insert() return property_setter + +def delete_property_setter(doc_type, property, field_name=None): + """delete other property setters on this, if this is new""" + filters = dict(doc_type = doc_type, property=property) + if field_name: + filters['field_name'] = field_name + + frappe.db.delete('Property Setter', filters) + diff --git a/frappe/desk/doctype/desk_card/__init__.py b/frappe/custom/doctype/test_rename_new/__init__.py similarity index 100% rename from frappe/desk/doctype/desk_card/__init__.py rename to frappe/custom/doctype/test_rename_new/__init__.py diff --git a/frappe/custom/doctype/test_rename_new/test_rename_new.js b/frappe/custom/doctype/test_rename_new/test_rename_new.js new file mode 100644 index 0000000000..f38f9486f9 --- /dev/null +++ b/frappe/custom/doctype/test_rename_new/test_rename_new.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Test rename new', { + // refresh: function(frm) { + + // } +}); diff --git a/frappe/custom/doctype/test_rename_new/test_rename_new.json b/frappe/custom/doctype/test_rename_new/test_rename_new.json new file mode 100644 index 0000000000..0b089091a1 --- /dev/null +++ b/frappe/custom/doctype/test_rename_new/test_rename_new.json @@ -0,0 +1,42 @@ +{ + "actions": [], + "creation": "2021-01-13 12:47:03.572640", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "random" + ], + "fields": [ + { + "fieldname": "random", + "fieldtype": "Data", + "label": "random" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-01-13 12:47:03.572640", + "modified_by": "Administrator", + "module": "Custom", + "name": "Test rename new", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "route": "test-rename", + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/custom/doctype/test_rename_new/test_rename_new.py b/frappe/custom/doctype/test_rename_new/test_rename_new.py new file mode 100644 index 0000000000..aa5984e466 --- /dev/null +++ b/frappe/custom/doctype/test_rename_new/test_rename_new.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class Testrenamenew(Document): + pass diff --git a/frappe/custom/doctype/test_rename_new/test_test_rename_new.py b/frappe/custom/doctype/test_rename_new/test_test_rename_new.py new file mode 100644 index 0000000000..554efbae45 --- /dev/null +++ b/frappe/custom/doctype/test_rename_new/test_test_rename_new.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class Testrenamenew(unittest.TestCase): + pass diff --git a/frappe/custom/workspace/customization/customization.json b/frappe/custom/workspace/customization/customization.json new file mode 100644 index 0000000000..cdc3b73366 --- /dev/null +++ b/frappe/custom/workspace/customization/customization.json @@ -0,0 +1,149 @@ +{ + "category": "Administration", + "charts": [], + "creation": "2020-03-02 15:15:03.839594", + "developer_mode_only": 0, + "disable_user_customization": 0, + "docstatus": 0, + "doctype": "Workspace", + "extends_another_page": 0, + "hide_custom": 0, + "icon": "customization", + "idx": 0, + "is_default": 0, + "is_standard": 1, + "label": "Customization", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Dashboards", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Dashboard", + "link_to": "Dashboard", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Dashboard Chart", + "link_to": "Dashboard Chart", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Dashboard Chart Source", + "link_to": "Dashboard Chart Source", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Form Customization", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Customize Form", + "link_to": "Customize Form", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Custom Field", + "link_to": "Custom Field", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Client Script", + "link_to": "Client Script", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "DocType", + "link_to": "DocType", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Other", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Custom Translations", + "link_to": "Translation", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2021-02-04 13:50:35.750463", + "modified_by": "Administrator", + "module": "Custom", + "name": "Customization", + "owner": "Administrator", + "pin_to_bottom": 0, + "pin_to_top": 0, + "shortcuts": [ + { + "label": "Customize Form", + "link_to": "Customize Form", + "type": "DocType" + }, + { + "label": "Custom Role", + "link_to": "Custom Role", + "type": "DocType" + }, + { + "label": "Client Script", + "link_to": "Client Script", + "type": "DocType" + }, + { + "doc_view": "", + "label": "Server Script", + "link_to": "Server Script", + "type": "DocType" + } + ] +} \ No newline at end of file diff --git a/frappe/data/sample_site_config.json b/frappe/data/sample_site_config.json index 36818ef286..715cd7b9fa 100644 --- a/frappe/data/sample_site_config.json +++ b/frappe/data/sample_site_config.json @@ -22,6 +22,9 @@ "use_ssl": 0, "auto_email_id": "hello@example.com", + "google_analytics_id": "google_analytics_id", + "google_analytics_anonymize_ip": 1, + "google_login": { "client_id": "google_client_id", "client_secret": "google_client_secret" diff --git a/frappe/data_migration/doctype/data_migration_connector/data_migration_connector.js b/frappe/data_migration/doctype/data_migration_connector/data_migration_connector.js index c3cf701d92..0898fcf4e7 100644 --- a/frappe/data_migration/doctype/data_migration_connector/data_migration_connector.js +++ b/frappe/data_migration/doctype/data_migration_connector/data_migration_connector.js @@ -30,7 +30,7 @@ frappe.ui.form.on('Data Migration Connector', { frm.set_value('connector_type', 'Custom'); frm.set_value('python_module', r.message); frm.save(); - frappe.show_alert(__(`New module created ${r.message}`)); + frappe.show_alert(__("New module created {0}", [r.message])); d.hide(); } }); diff --git a/frappe/database/database.py b/frappe/database/database.py index d9755abd33..4fcf10efda 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -16,7 +16,6 @@ import frappe.model.meta from frappe import _ from time import time from frappe.utils import now, getdate, cast_fieldtype, get_datetime -from frappe.utils.background_jobs import execute_job, get_queue from frappe.model.utils.link_count import flush_local_link_count from frappe.utils import cint @@ -319,8 +318,7 @@ class Database(object): nres.append(nr) return nres - @staticmethod - def build_conditions(filters): + def build_conditions(self, filters): """Convert filters sent as dict, lists to SQL conditions. filter's key is passed by map function, build conditions like: @@ -341,18 +339,12 @@ class Database(object): value = filters.get(key) values[key] = value if isinstance(value, (list, tuple)): - # value is a tuble like ("!=", 0) + # value is a tuple like ("!=", 0) _operator = value[0] values[key] = value[1] if isinstance(value[1], (tuple, list)): # value is a list in tuple ("in", ("A", "B")) - inner_list = [] - for i, v in enumerate(value[1]): - inner_key = "{0}_{1}".format(key, i) - values[inner_key] = v - inner_list.append("%({0})s".format(inner_key)) - - _rhs = " ({0})".format(", ".join(inner_list)) + _rhs = " ({0})".format(", ".join([self.escape(v) for v in value[1]])) del values[key] if _operator not in ["=", "!=", ">", ">=", "<", "<=", "like", "in", "not in", "not like"]: @@ -753,6 +745,9 @@ class Database(object): def commit(self): """Commit current transaction. Calls SQL `COMMIT`.""" + for method in frappe.local.before_commit: + frappe.call(method[0], *(method[1] or []), **(method[2] or {})) + self.sql("commit") frappe.local.rollback_observers = [] @@ -760,6 +755,9 @@ class Database(object): enqueue_jobs_after_commit() flush_local_link_count() + def add_before_commit(self, method, args=None, kwargs=None): + frappe.local.before_commit.append([method, args, kwargs]) + @staticmethod def flush_realtime_log(): for args in frappe.local.realtime_log: @@ -787,6 +785,9 @@ class Database(object): """Returns True if table for given doctype exists.""" return ("tab" + doctype) in self.get_tables() + def has_table(self, doctype): + return self.table_exists(doctype) + def get_tables(self): tables = frappe.cache().get_value('db_tables') if not tables: @@ -959,13 +960,13 @@ class Database(object): query = sql_dict.get(current_dialect) return self.sql(query, values, **kwargs) - def delete(self, doctype, conditions): + def delete(self, doctype, conditions, debug=False): if conditions: conditions, values = self.build_conditions(conditions) return self.sql("DELETE FROM `tab{doctype}` where {conditions}".format( doctype=doctype, conditions=conditions - ), values) + ), values, debug=debug) else: frappe.throw(_('No conditions provided')) @@ -1030,6 +1031,8 @@ class Database(object): insert_list = [] def enqueue_jobs_after_commit(): + from frappe.utils.background_jobs import execute_job, get_queue + if frappe.flags.enqueue_after_commit and len(frappe.flags.enqueue_after_commit) > 0: for job in frappe.flags.enqueue_after_commit: q = get_queue(job.get("queue"), is_async=job.get("is_async")) diff --git a/frappe/database/db_manager.py b/frappe/database/db_manager.py index 3345fce735..b8ffae519b 100644 --- a/frappe/database/db_manager.py +++ b/frappe/database/db_manager.py @@ -3,7 +3,6 @@ import frappe class DbManager: - def __init__(self, db): """ Pass root_conn here for access to all databases. @@ -66,10 +65,10 @@ class DbManager: esc = make_esc('$ ') from distutils.spawn import find_executable - pipe = find_executable('pv') - if pipe: - pipe = '{pipe} {source} |'.format( - pipe=pipe, + pv = find_executable('pv') + if pv: + pipe = '{pv} {source} |'.format( + pv=pv, source=source ) source = '' @@ -78,7 +77,7 @@ class DbManager: source = '< {source}'.format(source=source) if pipe: - print('Creating Database...') + print('Restoring Database file...') command = '{pipe} mysql -u {user} -p{password} -h{host} ' + ('-P{port}' if frappe.db.port else '') + ' {target} {source}' command = command.format( diff --git a/frappe/database/mariadb/database.py b/frappe/database/mariadb/database.py index 3cbb2e4f0e..f9997d1526 100644 --- a/frappe/database/mariadb/database.py +++ b/frappe/database/mariadb/database.py @@ -8,8 +8,7 @@ from pymysql.times import TimeDelta from pymysql.constants import ER, FIELD_TYPE from pymysql.converters import conversions -from frappe.utils import get_datetime, cstr -from markdown2 import UnicodeWithAttrs +from frappe.utils import get_datetime, cstr, UnicodeWithAttrs from frappe.database.database import Database from six import PY2, binary_type, text_type, string_types from frappe.database.mariadb.schema import MariaDBTable diff --git a/frappe/database/mariadb/framework_mariadb.sql b/frappe/database/mariadb/framework_mariadb.sql index 15b0bed699..a52efd01e3 100644 --- a/frappe/database/mariadb/framework_mariadb.sql +++ b/frappe/database/mariadb/framework_mariadb.sql @@ -233,7 +233,7 @@ CREATE TABLE `tabDocType` ( DROP TABLE IF EXISTS `tabSeries`; CREATE TABLE `tabSeries` ( - `name` varchar(100) DEFAULT NULL, + `name` varchar(100), `current` int(10) NOT NULL DEFAULT 0, PRIMARY KEY(`name`) ) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/frappe/database/mariadb/setup_db.py b/frappe/database/mariadb/setup_db.py index a4e4d624ae..9b73d77171 100644 --- a/frappe/database/mariadb/setup_db.py +++ b/frappe/database/mariadb/setup_db.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals import frappe -import os, sys +import os from frappe.database.db_manager import DbManager expected_settings_10_2_earlier = { @@ -86,6 +86,8 @@ def drop_user_and_database(db_name, root_login, root_password): dbman.drop_database(db_name) def bootstrap_database(db_name, verbose, source_sql=None): + import sys + frappe.connect(db_name=db_name) if not check_database_settings(): print('Database settings do not match expected values; stopping database setup.') @@ -94,9 +96,17 @@ def bootstrap_database(db_name, verbose, source_sql=None): import_db_from_sql(source_sql, verbose) frappe.connect(db_name=db_name) - if not 'tabDefaultValue' in frappe.db.get_tables(): - print('''Database not installed, this can due to lack of permission, or that the database name exists. - Check your mysql root password, or use --force to reinstall''') + if 'tabDefaultValue' not in frappe.db.get_tables(): + from click import secho + + secho( + "Table 'tabDefaultValue' missing in the restored site. " + "Database not installed correctly, this can due to lack of " + "permission, or that the database name exists. Check your mysql" + " root password, validity of the backup file or use --force to" + " reinstall", + fg="red" + ) sys.exit(1) def import_db_from_sql(source_sql=None, verbose=False): diff --git a/frappe/database/postgres/database.py b/frappe/database/postgres/database.py index 3d997864e4..4faea78551 100644 --- a/frappe/database/postgres/database.py +++ b/frappe/database/postgres/database.py @@ -140,11 +140,11 @@ class PostgresDatabase(Database): @staticmethod def is_table_missing(e): - return e.pgcode == '42P01' + return getattr(e, 'pgcode', None) == '42P01' @staticmethod def is_missing_column(e): - return e.pgcode == '42703' + return getattr(e, 'pgcode', None) == '42703' @staticmethod def is_access_denied(e): diff --git a/frappe/database/postgres/setup_db.py b/frappe/database/postgres/setup_db.py index 1dc1ea4c97..3ee6b6a286 100644 --- a/frappe/database/postgres/setup_db.py +++ b/frappe/database/postgres/setup_db.py @@ -1,5 +1,7 @@ -import frappe, subprocess, os -from six.moves import input +import os + +import frappe + def setup_database(force, source_sql=None, verbose=False): root_conn = get_root_connection() @@ -10,21 +12,62 @@ def setup_database(force, source_sql=None, verbose=False): root_conn.sql("CREATE user {0} password '{1}'".format(frappe.conf.db_name, frappe.conf.db_password)) root_conn.sql("GRANT ALL PRIVILEGES ON DATABASE `{0}` TO {0}".format(frappe.conf.db_name)) + root_conn.close() + + bootstrap_database(frappe.conf.db_name, verbose, source_sql=source_sql) + frappe.connect() + +def bootstrap_database(db_name, verbose, source_sql=None): + frappe.connect(db_name=db_name) + import_db_from_sql(source_sql, verbose) + frappe.connect(db_name=db_name) + + if 'tabDefaultValue' not in frappe.db.get_tables(): + import sys + from click import secho + + secho( + "Table 'tabDefaultValue' missing in the restored site. " + "This may be due to incorrect permissions or the result of a restore from a bad backup file. " + "Database not installed correctly.", + fg="red" + ) + sys.exit(1) + +def import_db_from_sql(source_sql=None, verbose=False): + from shutil import which + from subprocess import run, PIPE # we can't pass psql password in arguments in postgresql as mysql. So # set password connection parameter in environment variable subprocess_env = os.environ.copy() subprocess_env['PGPASSWORD'] = str(frappe.conf.db_password) + # bootstrap db if not source_sql: source_sql = os.path.join(os.path.dirname(__file__), 'framework_postgres.sql') - subprocess.check_output([ - 'psql', frappe.conf.db_name, '-h', frappe.conf.db_host or 'localhost', '-U', - frappe.conf.db_name, '-f', source_sql - ], env=subprocess_env) + pv = which('pv') - frappe.connect() + _command = ( + f"psql {frappe.conf.db_name} " + f"-h {frappe.conf.db_host or 'localhost'} -p {str(frappe.conf.db_port or '5432')} " + f"-U {frappe.conf.db_name}" + ) + + if pv: + command = f"{pv} {source_sql} | " + _command + else: + command = _command + f" -f {source_sql}" + + print("Restoring Database file...") + if verbose: + print(command) + + restore_proc = run(command, env=subprocess_env, shell=True, stdout=PIPE) + + if verbose: + print(f"\nSTDOUT by psql:\n{restore_proc.stdout.decode()}\nImported from Database File: {source_sql}") def setup_help_database(help_db_name): root_conn = get_root_connection() @@ -35,19 +78,20 @@ def setup_help_database(help_db_name): root_conn.sql("GRANT ALL PRIVILEGES ON DATABASE `{0}` TO {0}".format(help_db_name)) def get_root_connection(root_login=None, root_password=None): - import getpass if not frappe.local.flags.root_connection: if not root_login: root_login = frappe.conf.get("root_login") or None if not root_login: + from six.moves import input root_login = input("Enter postgres super user: ") if not root_password: root_password = frappe.conf.get("root_password") or None if not root_password: - root_password = getpass.getpass("Postgres super user password: ") + from getpass import getpass + root_password = getpass("Postgres super user password: ") frappe.local.flags.root_connection = frappe.database.get_db(user=root_login, password=root_password) diff --git a/frappe/database/schema.py b/frappe/database/schema.py index 52dc2ba917..daabbaa61c 100644 --- a/frappe/database/schema.py +++ b/frappe/database/schema.py @@ -186,7 +186,7 @@ class DbColumn: column_def += ' not null default {0}'.format(default_value) elif self.default and (self.default not in frappe.db.DEFAULT_SHORTCUTS) \ - and not self.default.startswith(":") and column_def not in ('text', 'longtext'): + and not cstr(self.default).startswith(":") and column_def not in ('text', 'longtext'): column_def += " default {}".format(frappe.db.escape(self.default)) if self.unique and (column_def not in ('text', 'longtext')): diff --git a/frappe/desk/calendar.py b/frappe/desk/calendar.py index ce9fb7f177..064d870092 100644 --- a/frappe/desk/calendar.py +++ b/frappe/desk/calendar.py @@ -29,6 +29,7 @@ def get_event_conditions(doctype, filters=None): def get_events(doctype, start, end, field_map, filters=None, fields=None): field_map = frappe._dict(json.loads(field_map)) + fields = frappe.parse_json(fields) doc_meta = frappe.get_meta(doctype) for d in doc_meta.fields: diff --git a/frappe/desk/desktop.py b/frappe/desk/desktop.py index 72c4519120..5b6e2fdd21 100644 --- a/frappe/desk/desktop.py +++ b/frappe/desk/desktop.py @@ -31,7 +31,7 @@ def handle_not_exist(fn): class Workspace: def __init__(self, page_name, minimal=False): self.page_name = page_name - self.extended_cards = [] + self.extended_links = [] self.extended_charts = [] self.extended_shortcuts = [] @@ -57,11 +57,11 @@ class Workspace: self.restricted_pages = frappe.cache().get_value("domain_restricted_pages") or build_domain_restriced_page_cache() def is_page_allowed(self): - cards = self.doc.cards + get_custom_reports_and_doctypes(self.doc.module) + self.extended_cards + cards = self.doc.get_link_groups() + get_custom_reports_and_doctypes(self.doc.module) + self.extended_links shortcuts = self.doc.shortcuts + self.extended_shortcuts for section in cards: - links = loads(section.links) if isinstance(section.links, string_types) else section.links + links = loads(section.get('links')) if isinstance(section.get('links'), string_types) else section.get('links') for item in links: if self.is_item_allowed(item.get('name'), item.get('type')): return True @@ -108,12 +108,21 @@ class Workspace: 'extends': self.page_name, 'for_user': frappe.session.user } - pages = frappe.get_all("Desk Page", filters=filters, limit=1) - if pages: - return frappe.get_cached_doc("Desk Page", pages[0]) + user_pages = frappe.get_all("Workspace", filters=filters, limit=1) + if user_pages: + return frappe.get_cached_doc("Workspace", user_pages[0]) + + filters = { + 'extends_another_page': 1, + 'extends': self.page_name, + 'is_default': 1 + } + default_page = frappe.get_all("Workspace", filters=filters, limit=1) + if default_page: + return frappe.get_cached_doc("Workspace", default_page[0]) self.get_pages_to_extend() - return frappe.get_cached_doc("Desk Page", self.page_name) + return frappe.get_cached_doc("Workspace", self.page_name) def get_onboarding_doc(self): # Check if onboarding is enabled @@ -141,25 +150,28 @@ class Workspace: return doc def get_pages_to_extend(self): - pages = frappe.get_all("Desk Page", filters={ + pages = frappe.get_all("Workspace", filters={ "extends": self.page_name, 'restrict_to_domain': ['in', frappe.get_active_domains()], 'for_user': '', 'module': ['in', self.allowed_modules] }) - pages = [frappe.get_cached_doc("Desk Page", page['name']) for page in pages] + pages = [frappe.get_cached_doc("Workspace", page['name']) for page in pages] for page in pages: - self.extended_cards = self.extended_cards + page.cards + self.extended_links = self.extended_links + page.get_link_groups() self.extended_charts = self.extended_charts + page.charts self.extended_shortcuts = self.extended_shortcuts + page.shortcuts def is_item_allowed(self, name, item_type): + if frappe.session.user == "Administrator": + return True + item_type = item_type.lower() if item_type == "doctype": - return (name in self.can_read and name in self.restricted_doctypes) + return (name in self.can_read or [] and name in self.restricted_doctypes or []) if item_type == "page": return (name in self.allowed_pages and name in self.restricted_pages) if item_type == "report": @@ -174,7 +186,7 @@ class Workspace: def build_workspace(self): self.cards = { 'label': _(self.doc.cards_label), - 'items': self.get_cards() + 'items': self.get_links() } self.charts = { @@ -196,54 +208,61 @@ class Workspace: 'items': self.get_onboarding_steps() } + def _doctype_contains_a_record(self, name): + exists = self.table_counts.get(name, False) + + if not exists and frappe.db.exists(name): + if not frappe.db.get_value('DocType', name, 'issingle'): + exists = bool(frappe.db.get_all(name, limit=1)) + else: + exists = True + self.table_counts[name] = exists + + return exists + + def _prepare_item(self, item): + if item.dependencies: + + dependencies = [dep.strip() for dep in item.dependencies.split(",")] + + incomplete_dependencies = [d for d in dependencies if not self._doctype_contains_a_record(d)] + + if len(incomplete_dependencies): + item.incomplete_dependencies = incomplete_dependencies + else: + item.incomplete_dependencies = "" + + if item.onboard: + # Mark Spotlights for initial + if item.get("type") == "doctype": + name = item.get("name") + count = self._doctype_contains_a_record(name) + + item["count"] = count + + # Translate label + item["label"] = _(item.label) if item.label else _(item.name) + + return item + @handle_not_exist - def get_cards(self): - cards = self.doc.cards + def get_links(self): + cards = self.doc.get_link_groups() + if not self.doc.hide_custom: cards = cards + get_custom_reports_and_doctypes(self.doc.module) - if len(self.extended_cards): - cards = merge_cards_based_on_label(cards + self.extended_cards) + if len(self.extended_links): + cards = merge_cards_based_on_label(cards + self.extended_links) + default_country = frappe.db.get_default("country") - def _doctype_contains_a_record(name): - exists = self.table_counts.get(name, None) - if not exists: - if not frappe.db.get_value('DocType', name, 'issingle'): - exists = frappe.db.count(name) - else: - exists = True - self.table_counts[name] = exists - return exists - - def _prepare_item(item): - if item.dependencies: - incomplete_dependencies = [d for d in item.dependencies if not _doctype_contains_a_record(d)] - if len(incomplete_dependencies): - item.incomplete_dependencies = incomplete_dependencies - else: - item.incomplete_dependencies = "" - - if item.onboard: - # Mark Spotlights for initial - if item.get("type") == "doctype": - name = item.get("name") - count = _doctype_contains_a_record(name) - - item["count"] = count - - # Translate label - item["label"] = _(item.label) if item.label else _(item.name) - - return item - new_data = [] - for section in cards: + for card in cards: new_items = [] - if isinstance(section.links, string_types): - links = loads(section.links) - else: - links = section.links + card = _dict(card) + + links = card.get('links', []) for item in links: item = _dict(item) @@ -253,18 +272,18 @@ class Workspace: continue # Check if user is allowed to view - if self.is_item_allowed(item.name, item.type): - prepared_item = _prepare_item(item) + if self.is_item_allowed(item.link_to, item.link_type): + prepared_item = self._prepare_item(item) new_items.append(prepared_item) if new_items: - if isinstance(section, _dict): - new_section = section.copy() + if isinstance(card, _dict): + new_card = card.copy() else: - new_section = section.as_dict().copy() - new_section["links"] = new_items - new_section["label"] = _(new_section["label"]) - new_data.append(new_section) + new_card = card.as_dict().copy() + new_card["links"] = new_items + new_card["label"] = _(new_card["label"]) + new_data.append(new_card) return new_data @@ -351,56 +370,39 @@ def get_desktop_page(page): } @frappe.whitelist() -def get_desk_sidebar_items(flatten=False, cache=True): - """Get list of sidebar items for desk - """ +def get_desk_sidebar_items(): + """Get list of sidebar items for desk""" + + # don't get domain restricted pages + blocked_modules = frappe.get_doc('User', frappe.session.user).get_blocked_modules() + + filters = { + 'restrict_to_domain': ['in', frappe.get_active_domains()], + 'extends_another_page': 0, + 'for_user': '', + 'module': ['not in', blocked_modules] + } + + if not frappe.local.conf.developer_mode: + filters['developer_mode_only'] = '0' + + # pages sorted based on pinned to top and then by name + order_by = "pin_to_top desc, pin_to_bottom asc, name asc" + all_pages = frappe.get_all("Workspace", fields=["name", "category", "icon", "module"], + filters=filters, order_by=order_by, ignore_permissions=True) pages = [] - _cache = frappe.cache() - if cache: - pages = _cache.get_value("desk_sidebar_items", user=frappe.session.user) - if not pages or not cache: - # don't get domain restricted pages - blocked_modules = frappe.get_doc('User', frappe.session.user).get_blocked_modules() + # Filter Page based on Permission + for page in all_pages: + try: + wspace = Workspace(page.get('name'), True) + if wspace.is_page_allowed(): + pages.append(page) + page['label'] = _(page.get('name')) + except frappe.PermissionError: + pass - filters = { - 'restrict_to_domain': ['in', frappe.get_active_domains()], - 'extends_another_page': 0, - 'for_user': '', - 'module': ['not in', blocked_modules] - } - - if not frappe.local.conf.developer_mode: - filters['developer_mode_only'] = '0' - - # pages sorted based on pinned to top and then by name - order_by = "pin_to_top desc, pin_to_bottom asc, name asc" - all_pages = frappe.get_all("Desk Page", fields=["name", "category"], filters=filters, order_by=order_by, ignore_permissions=True) - pages = [] - - # Filter Page based on Permission - for page in all_pages: - try: - wspace = Workspace(page.get('name'), True) - if wspace.is_page_allowed(): - pages.append(page) - except frappe.PermissionError: - pass - - _cache.set_value("desk_sidebar_items", pages, frappe.session.user) - - if flatten: - return pages - - from collections import defaultdict - sidebar_items = defaultdict(list) - - # The order will be maintained while categorizing - for page in pages: - # Translate label - page['label'] = _(page.get('name')) - sidebar_items[page["category"]].append(page) - return sidebar_items + return pages def get_table_with_counts(): counts = frappe.cache().get_value("information_schema:counts") @@ -427,8 +429,9 @@ def get_custom_doctype_list(module): out = [] for d in doctypes: out.append({ - "type": "doctype", - "name": d.name, + "type": "Link", + "link_type": "doctype", + "link_to": d.name, "label": _(d.name) }) @@ -444,17 +447,18 @@ def get_custom_report_list(module): out = [] for r in reports: out.append({ - "type": "report", + "type": "Link", + "link_type": "report", "doctype": r.ref_doctype, "is_query_report": 1 if r.report_type in ("Query Report", "Script Report", "Custom Report") else 0, "label": _(r.name), - "name": r.name + "link_to": r.name, }) return out def get_custom_workspace_for_user(page): - """Get custom page from desk_page if exists or create one + """Get custom page from workspace if exists or create one Args: page (stirng): Page name @@ -466,10 +470,10 @@ def get_custom_workspace_for_user(page): 'extends': page, 'for_user': frappe.session.user } - pages = frappe.get_list("Desk Page", filters=filters) + pages = frappe.get_list("Workspace", filters=filters) if pages: - return frappe.get_doc("Desk Page", pages[0]) - doc = frappe.new_doc("Desk Page") + return frappe.get_doc("Workspace", pages[0]) + doc = frappe.new_doc("Workspace") doc.extends = page doc.for_user = frappe.session.user return doc @@ -477,7 +481,7 @@ def get_custom_workspace_for_user(page): @frappe.whitelist() def save_customization(page, config): - """Save customizations as a separate doctype in Desk page per user + """Save customizations as a separate doctype in Workspace per user Args: page (string): Name of the page to be edited @@ -486,11 +490,12 @@ def save_customization(page, config): Returns: Boolean: Customization saving status """ - original_page = frappe.get_doc("Desk Page", page) + original_page = frappe.get_doc("Workspace", page) page_doc = get_custom_workspace_for_user(page) # Update field values page_doc.update({ + "icon": original_page.icon, "charts_label": original_page.charts_label, "cards_label": original_page.cards_label, "shortcuts_label": original_page.shortcuts_label, @@ -502,11 +507,11 @@ def save_customization(page, config): config = _dict(loads(config)) if config.charts: - page_doc.charts = prepare_widget(config.charts, "Desk Chart", "charts") + page_doc.charts = prepare_widget(config.charts, "Workspace Chart", "charts") if config.shortcuts: - page_doc.shortcuts = prepare_widget(config.shortcuts, "Desk Shortcut", "shortcuts") + page_doc.shortcuts = prepare_widget(config.shortcuts, "Workspace Shortcut", "shortcuts") if config.cards: - page_doc.cards = prepare_widget(config.cards, "Desk Card", "cards") + page_doc.build_links_table_from_cards(config.cards) # Set label page_doc.label = page + '-' + frappe.session.user @@ -580,15 +585,26 @@ def update_onboarding_step(name, field, value): """ frappe.db.set_value("Onboarding Step", name, field, value) +@frappe.whitelist() +def reset_customization(page): + """Reset workspace customizations for a user + + Args: + page (string): Name of the page to be reset + """ + page_doc = get_custom_workspace_for_user(page) + page_doc.delete() + def merge_cards_based_on_label(cards): """Merge cards with common label.""" cards_dict = {} for card in cards: - if card.label in cards_dict: - links = loads(cards_dict[card.label].links) + loads(card.links) - cards_dict[card.label].update(dict(links=dumps(links))) - cards_dict[card.label] = cards_dict.pop(card.label) + label = card.get('label') + if label in cards_dict: + links = cards_dict[label].links + card.links + cards_dict[label].update(dict(links=links)) + cards_dict[label] = cards_dict.pop(label) else: - cards_dict[card.label] = card + cards_dict[label] = card - return list(cards_dict.values()) \ No newline at end of file + return list(cards_dict.values()) diff --git a/frappe/desk/doctype/dashboard/dashboard.js b/frappe/desk/doctype/dashboard/dashboard.js index 61300b920b..c640259cf2 100644 --- a/frappe/desk/doctype/dashboard/dashboard.js +++ b/frappe/desk/doctype/dashboard/dashboard.js @@ -3,7 +3,9 @@ frappe.ui.form.on('Dashboard', { refresh: function(frm) { - frm.add_custom_button(__("Show Dashboard"), () => frappe.set_route('dashboard', frm.doc.name)); + frm.add_custom_button(__("Show Dashboard"), + () => frappe.set_route('dashboard-view', frm.doc.name) + ); if (!frappe.boot.developer_mode && frm.doc.is_standard) { frm.disable_form(); diff --git a/frappe/desk/doctype/dashboard/dashboard.py b/frappe/desk/doctype/dashboard/dashboard.py index b12bcfe27d..4e66318769 100644 --- a/frappe/desk/doctype/dashboard/dashboard.py +++ b/frappe/desk/doctype/dashboard/dashboard.py @@ -5,6 +5,7 @@ from __future__ import unicode_literals from frappe.model.document import Document from frappe.modules.export_file import export_to_files +from frappe.config import get_modules_from_all_apps_for_user import frappe from frappe import _ import json @@ -42,6 +43,24 @@ class Dashboard(Document): except ValueError as error: frappe.throw(_("Invalid json added in the custom options: {0}").format(error)) + +def get_permission_query_conditions(user): + if not user: + user = frappe.session.user + + if user == 'Administrator': + return + + roles = frappe.get_roles(user) + if "System Manager" in roles: + return None + + allowed_modules = [frappe.db.escape(module.get('module_name')) for module in get_modules_from_all_apps_for_user()] + module_condition = '`tabDashboard`.`module` in ({allowed_modules}) or `tabDashboard`.`module` is NULL'.format( + allowed_modules=','.join(allowed_modules)) + + return module_condition + @frappe.whitelist() def get_permitted_charts(dashboard_name): permitted_charts = [] @@ -80,7 +99,7 @@ def get_non_standard_warning_message(non_standard_docs_map): def get_html(docs, doctype): html = '

      {}

      '.format(frappe.bold(doctype)) for doc in docs: - html += ''.format(doctype=doctype, doc=doc) + html += ''.format(doctype=doctype, doc=doc) html += '
      ' return html diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.js b/frappe/desk/doctype/dashboard_chart/dashboard_chart.js index 7f26bd9101..f5d1ee0df5 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.js +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.js @@ -21,8 +21,10 @@ frappe.ui.form.on('Dashboard Chart', { refresh: function(frm) { frm.chart_filters = null; + frm.is_disabled = !frappe.boot.developer_mode && frm.doc.is_standard; - if (!frappe.boot.developer_mode && frm.doc.is_standard) { + if (frm.is_disabled) { + !frm.doc.custom_options && frm.set_df_property('chart_options_section', 'hidden', 1); frm.disable_form(); } @@ -169,7 +171,7 @@ frappe.ui.form.on('Dashboard Chart', { frm.field_options = frappe.report_utils.get_field_options_from_report(data.columns, data); frm.set_df_property('x_field', 'options', frm.field_options.non_numeric_fields); if (!frm.field_options.numeric_fields.length) { - frappe.msgprint(__(`Report has no numeric fields, please change the Report Name`)); + frappe.msgprint(__("Report has no numeric fields, please change the Report Name")); } else { let y_field_df = frappe.meta.get_docfield('Dashboard Chart Field', 'y_field', frm.doc.name); y_field_df.options = frm.field_options.numeric_fields; @@ -333,6 +335,7 @@ frappe.ui.form.on('Dashboard Chart', { } table.on('click', () => { + frm.is_disabled && frappe.throw(__('Cannot edit filters for standard charts')); let dialog = new frappe.ui.Dialog({ title: __('Set Filters'), diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py index 7e2d952928..b19f6cf9f0 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py @@ -7,17 +7,18 @@ import frappe from frappe import _ import datetime import json -from frappe.utils.dashboard import cache_source, get_from_date_from_timespan -from frappe.utils import nowdate, add_to_date, getdate, get_last_day, formatdate,\ - get_datetime, cint, now_datetime +from frappe.utils.dashboard import cache_source +from frappe.utils import nowdate, getdate, get_datetime, cint, now_datetime +from frappe.utils.dateutils import\ + get_period, get_period_beginning, get_from_date_from_timespan, get_dates_from_timegrain from frappe.model.naming import append_number_if_name_exists from frappe.boot import get_allowed_reports +from frappe.config import get_modules_from_all_apps_for_user from frappe.model.document import Document from frappe.modules.export_file import export_to_files def get_permission_query_conditions(user): - if not user: user = frappe.session.user @@ -30,9 +31,11 @@ def get_permission_query_conditions(user): doctype_condition = False report_condition = False + module_condition = False allowed_doctypes = [frappe.db.escape(doctype) for doctype in frappe.permissions.get_doctypes_with_read()] allowed_reports = [frappe.db.escape(key) if type(key) == str else key.encode('UTF8') for key in get_allowed_reports()] + allowed_modules = [frappe.db.escape(module.get('module_name')) for module in get_modules_from_all_apps_for_user()] if allowed_doctypes: doctype_condition = '`tabDashboard Chart`.`document_type` in ({allowed_doctypes})'.format( @@ -40,18 +43,24 @@ def get_permission_query_conditions(user): if allowed_reports: report_condition = '`tabDashboard Chart`.`report_name` in ({allowed_reports})'.format( allowed_reports=','.join(allowed_reports)) + if allowed_modules: + module_condition = '''`tabDashboard Chart`.`module` in ({allowed_modules}) + or `tabDashboard Chart`.`module` is NULL'''.format( + allowed_modules=','.join(allowed_modules)) return ''' - (`tabDashboard Chart`.`chart_type` in ('Count', 'Sum', 'Average') - and {doctype_condition}) - or - (`tabDashboard Chart`.`chart_type` = 'Report' - and {report_condition}) - '''.format( - doctype_condition=doctype_condition, - report_condition=report_condition - ) - + ((`tabDashboard Chart`.`chart_type` in ('Count', 'Sum', 'Average') + and {doctype_condition}) + or + (`tabDashboard Chart`.`chart_type` = 'Report' + and {report_condition})) + and + ({module_condition}) + '''.format( + doctype_condition=doctype_condition, + report_condition=report_condition, + module_condition=module_condition + ) def has_permission(doc, ptype, user): roles = frappe.get_roles(user) @@ -64,7 +73,7 @@ def has_permission(doc, ptype, user): if doc.report_name in allowed_reports: return True else: - allowed_doctypes = [frappe.permissions.get_doctypes_with_read()] + allowed_doctypes = frappe.permissions.get_doctypes_with_read() if doc.document_type in allowed_doctypes: return True @@ -156,6 +165,7 @@ def add_chart_to_dashboard(args): def get_chart_config(chart, filters, timespan, timegrain, from_date, to_date): if not from_date: from_date = get_from_date_from_timespan(to_date, timespan) + from_date = get_period_beginning(from_date, timegrain) if not to_date: to_date = now_datetime() @@ -185,7 +195,7 @@ def get_chart_config(chart, filters, timespan, timegrain, from_date, to_date): result = get_result(data, timegrain, from_date, to_date) chart_config = { - "labels": [formatdate(r[0].strftime('%Y-%m-%d')) for r in result], + "labels": [get_period(r[0], timegrain) for r in result], "datasets": [{ "name": chart.name, "values": [r[1] for r in result] @@ -279,16 +289,8 @@ def get_aggregate_function(chart_type): def get_result(data, timegrain, from_date, to_date): - start_date = getdate(from_date) - end_date = getdate(to_date) - - result = [[start_date, 0.0]] - - while start_date < end_date: - next_date = get_next_expected_date(start_date, timegrain) - result.append([next_date, 0.0]) - start_date = next_date - + dates = get_dates_from_timegrain(from_date, to_date, timegrain) + result = [[date, 0] for date in dates] data_index = 0 if data: for i, d in enumerate(result): @@ -298,65 +300,6 @@ def get_result(data, timegrain, from_date, to_date): return result -def get_next_expected_date(date, timegrain): - next_date = None - # given date is always assumed to be the period ending date - next_date = get_period_ending(add_to_date(date, days=1), timegrain) - return getdate(next_date) - -def get_period_ending(date, timegrain): - date = getdate(date) - if timegrain == 'Daily': - pass - elif timegrain == 'Weekly': - date = get_week_ending(date) - elif timegrain == 'Monthly': - date = get_month_ending(date) - elif timegrain == 'Quarterly': - date = get_quarter_ending(date) - elif timegrain == 'Yearly': - date = get_year_ending(date) - - return getdate(date) - -def get_week_ending(date): - # week starts on monday - from datetime import timedelta - start = date - timedelta(days = date.weekday()) - end = start + timedelta(days=6) - - return end - -def get_month_ending(date): - month_of_the_year = int(date.strftime('%m')) - # first day of next month (note month starts from 1) - - date = add_to_date('{}-01-01'.format(date.year), months = month_of_the_year) - # last day of this month - return add_to_date(date, days=-1) - -def get_quarter_ending(date): - date = getdate(date) - - # find the earliest quarter ending date that is after - # the given date - for month in (3, 6, 9, 12): - quarter_end_month = getdate('{}-{}-01'.format(date.year, month)) - quarter_end_date = getdate(get_last_day(quarter_end_month)) - if date <= quarter_end_date: - date = quarter_end_date - break - - return date - -def get_year_ending(date): - ''' returns year ending of the given date ''' - - # first day of next year (note year starts from 1) - date = add_to_date('{}-01-01'.format(date.year), months = 12) - # last day of this month - return add_to_date(date, days=-1) - @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def get_charts_for_user(doctype, txt, searchfield, start, page_len, filters): diff --git a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py index 5e39998e62..3c37ad4a09 100644 --- a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py @@ -5,8 +5,8 @@ from __future__ import unicode_literals import unittest, frappe from frappe.utils import getdate, formatdate, get_last_day -from frappe.desk.doctype.dashboard_chart.dashboard_chart import (get, - get_period_ending) +from frappe.utils.dateutils import get_period_ending, get_period +from frappe.desk.doctype.dashboard_chart.dashboard_chart import get from datetime import datetime from dateutil.relativedelta import relativedelta @@ -53,15 +53,11 @@ class TestDashboardChart(unittest.TestCase): cur_date = datetime.now() - relativedelta(years=1) result = get(chart_name='Test Dashboard Chart', refresh=1) - self.assertEqual(result.get('labels')[0], formatdate(cur_date.strftime('%Y-%m-%d'))) - if formatdate(cur_date.strftime('%Y-%m-%d')) == formatdate(get_last_day(cur_date).strftime('%Y-%m-%d')): - cur_date += relativedelta(months=1) - - for idx in range(1, 13): + for idx in range(13): month = get_last_day(cur_date) month = formatdate(month.strftime('%Y-%m-%d')) - self.assertEqual(result.get('labels')[idx], month) + self.assertEqual(result.get('labels')[idx], get_period(month)) cur_date += relativedelta(months=1) frappe.db.rollback() @@ -87,15 +83,11 @@ class TestDashboardChart(unittest.TestCase): cur_date = datetime.now() - relativedelta(years=1) result = get(chart_name ='Test Empty Dashboard Chart', refresh=1) - self.assertEqual(result.get('labels')[0], formatdate(cur_date.strftime('%Y-%m-%d'))) - if formatdate(cur_date.strftime('%Y-%m-%d')) == formatdate(get_last_day(cur_date).strftime('%Y-%m-%d')): - cur_date += relativedelta(months=1) - - for idx in range(1, 13): + for idx in range(13): month = get_last_day(cur_date) month = formatdate(month.strftime('%Y-%m-%d')) - self.assertEqual(result.get('labels')[idx], month) + self.assertEqual(result.get('labels')[idx], get_period(month)) cur_date += relativedelta(months=1) frappe.db.rollback() @@ -124,15 +116,11 @@ class TestDashboardChart(unittest.TestCase): cur_date = datetime.now() - relativedelta(years=1) result = get(chart_name ='Test Empty Dashboard Chart 2', refresh = 1) - self.assertEqual(result.get('labels')[0], formatdate(cur_date.strftime('%Y-%m-%d'))) - if formatdate(cur_date.strftime('%Y-%m-%d')) == formatdate(get_last_day(cur_date).strftime('%Y-%m-%d')): - cur_date += relativedelta(months=1) - - for idx in range(1, 13): + for idx in range(13): month = get_last_day(cur_date) month = formatdate(month.strftime('%Y-%m-%d')) - self.assertEqual(result.get('labels')[idx], month) + self.assertEqual(result.get('labels')[idx], get_period(month)) cur_date += relativedelta(months=1) # only 1 data point with value @@ -183,13 +171,12 @@ class TestDashboardChart(unittest.TestCase): timeseries = 1 )).insert() - result = get(chart_name ='Test Daily Dashboard Chart', refresh = 1) + result = get(chart_name = 'Test Daily Dashboard Chart', refresh = 1) self.assertEqual(result.get('datasets')[0].get('values'), [200.0, 400.0, 300.0, 0.0, 100.0, 0.0]) self.assertEqual( result.get('labels'), - [formatdate('2019-01-06'), formatdate('2019-01-07'), formatdate('2019-01-08'),\ - formatdate('2019-01-09'), formatdate('2019-01-10'), formatdate('2019-01-11')] + ['06-01-19', '07-01-19', '08-01-19', '09-01-19', '10-01-19', '11-01-19'] ) frappe.db.rollback() @@ -218,7 +205,10 @@ class TestDashboardChart(unittest.TestCase): result = get(chart_name ='Test Weekly Dashboard Chart', refresh = 1) self.assertEqual(result.get('datasets')[0].get('values'), [50.0, 300.0, 800.0, 0.0]) - self.assertEqual(result.get('labels'), [formatdate('2018-12-30'), formatdate('2019-01-06'), formatdate('2019-01-13'), formatdate('2019-01-20')]) + self.assertEqual( + result.get('labels'), + ['30-12-18', '06-01-19', '13-01-19', '20-01-19'] + ) frappe.db.rollback() diff --git a/frappe/desk/doctype/desk_card/desk_card.json b/frappe/desk/doctype/desk_card/desk_card.json deleted file mode 100644 index dbcb4b0d5c..0000000000 --- a/frappe/desk/doctype/desk_card/desk_card.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "actions": [], - "creation": "2020-01-29 14:45:54.383089", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "label", - "column_break_2", - "hidden", - "section_break_3", - "links" - ], - "fields": [ - { - "fieldname": "links", - "fieldtype": "Code", - "label": "Links", - "options": "JSON", - "reqd": 1 - }, - { - "fieldname": "section_break_3", - "fieldtype": "Section Break" - }, - { - "fieldname": "column_break_2", - "fieldtype": "Column Break" - }, - { - "default": "0", - "fieldname": "hidden", - "fieldtype": "Check", - "in_list_view": 1, - "label": "Hidden" - }, - { - "fieldname": "label", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Label", - "reqd": 1 - } - ], - "istable": 1, - "links": [], - "modified": "2020-03-31 14:38:06.303847", - "modified_by": "Administrator", - "module": "Desk", - "name": "Desk Card", - "owner": "Administrator", - "permissions": [], - "quick_entry": 1, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1 -} \ No newline at end of file diff --git a/frappe/desk/doctype/desk_page/desk_page.py b/frappe/desk/doctype/desk_page/desk_page.py deleted file mode 100644 index e92844ac0b..0000000000 --- a/frappe/desk/doctype/desk_page/desk_page.py +++ /dev/null @@ -1,48 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt - -from __future__ import unicode_literals -import frappe -from frappe import _ -from frappe.utils.data import validate_json_string -from frappe.modules.export_file import export_to_files -from frappe.model.document import Document - -class DeskPage(Document): - def validate(self): - self.validate_cards_json() - if (self.is_standard and not frappe.conf.developer_mode and not disable_saving_as_standard()): - frappe.throw(_("You need to be in developer mode to edit this document")) - - def validate_cards_json(self): - for card in self.cards: - try: - validate_json_string(card.links) - except frappe.ValidationError: - frappe.throw(_("Invalid JSON in card links for {0}").format(frappe.bold(card.label))) - - def on_update(self): - if disable_saving_as_standard(): - return - - if frappe.conf.developer_mode and self.is_standard: - export_to_files(record_list=[['Desk Page', self.name]], record_module=self.module) - - @staticmethod - def get_module_page_map(): - filters = { - 'extends_another_page': 0, - 'for_user': '', - } - - pages = frappe.get_all("Desk Page", fields=["name", "module"], filters=filters, as_list=1) - - return { page[1]: page[0] for page in pages if page[1] } - -def disable_saving_as_standard(): - return frappe.flags.in_install or \ - frappe.flags.in_patch or \ - frappe.flags.in_test or \ - frappe.flags.in_fixtures or \ - frappe.flags.in_migrate \ No newline at end of file diff --git a/frappe/desk/doctype/kanban_board/kanban_board.py b/frappe/desk/doctype/kanban_board/kanban_board.py index f1ad41db6c..a655e9e1da 100644 --- a/frappe/desk/doctype/kanban_board/kanban_board.py +++ b/frappe/desk/doctype/kanban_board/kanban_board.py @@ -17,6 +17,10 @@ class KanbanBoard(Document): def on_update(self): frappe.clear_cache(doctype=self.reference_doctype) + def before_insert(self): + for column in self.columns: + column.order = get_order_for_column(self, column.column_name) + def validate_column_name(self): for column in self.columns: if not column.column_name: @@ -125,6 +129,53 @@ def update_order(board_name, order): board.save() return board, updated_cards +@frappe.whitelist() +def update_order_for_single_card(board_name, docname, from_colname, to_colname, old_index, new_index): + '''Save the order of cards in columns''' + board = frappe.get_doc('Kanban Board', board_name) + doctype = board.reference_doctype + fieldname = board.field_name + old_index = frappe.parse_json(old_index) + new_index = frappe.parse_json(new_index) + + # save current order and index of columns to be updated + from_col_order, from_col_idx = get_kanban_column_order_and_index(board, from_colname) + to_col_order, to_col_idx = get_kanban_column_order_and_index(board, to_colname) + + if from_colname == to_colname: + from_col_order = to_col_order + + to_col_order.insert(new_index, from_col_order.pop((old_index))) + + # save updated order + board.columns[from_col_idx].order = frappe.as_json(from_col_order) + board.columns[to_col_idx].order = frappe.as_json(to_col_order) + board.save() + + # update changed value in doc + frappe.set_value(doctype, docname, fieldname, to_colname) + + return board + +def get_kanban_column_order_and_index(board, colname): + for i, col in enumerate(board.columns): + if col.column_name == colname: + col_order = frappe.parse_json(col.order) + col_idx = i + + return col_order, col_idx + +@frappe.whitelist() +def add_card(board_name, docname, colname): + board = frappe.get_doc('Kanban Board', board_name) + + col_order, col_idx = get_kanban_column_order_and_index(board, colname) + col_order.insert(0, docname) + + board.columns[col_idx].order = frappe.as_json(col_order) + + board.save() + return board @frappe.whitelist() def quick_kanban_board(doctype, board_name, field_name, project=None): @@ -133,6 +184,13 @@ def quick_kanban_board(doctype, board_name, field_name, project=None): doc = frappe.new_doc('Kanban Board') meta = frappe.get_meta(doctype) + doc.kanban_board_name = board_name + doc.reference_doctype = doctype + doc.field_name = field_name + + if project: + doc.filters = '[["Task","project","=","{0}"]]'.format(project) + options = '' for field in meta.fields: if field.fieldname == field_name: @@ -149,12 +207,6 @@ def quick_kanban_board(doctype, board_name, field_name, project=None): column_name=column )) - doc.kanban_board_name = board_name - doc.reference_doctype = doctype - doc.field_name = field_name - - if project: - doc.filters = '[["Task","project","=","{0}"]]'.format(project) if doctype in ['Note', 'ToDo']: doc.private = 1 @@ -162,6 +214,12 @@ def quick_kanban_board(doctype, board_name, field_name, project=None): doc.save() return doc +def get_order_for_column(board, colname): + filters = [[board.reference_doctype, board.field_name, '=', colname]] + if board.filters: + filters.append(frappe.parse_json(board.filters)[0]) + + return frappe.as_json(frappe.get_list(board.reference_doctype, filters=filters, pluck='name')) @frappe.whitelist() def update_column_order(board_name, order): diff --git a/frappe/desk/doctype/module_onboarding/module_onboarding.py b/frappe/desk/doctype/module_onboarding/module_onboarding.py index 89160a60f0..8315c0b304 100644 --- a/frappe/desk/doctype/module_onboarding/module_onboarding.py +++ b/frappe/desk/doctype/module_onboarding/module_onboarding.py @@ -41,3 +41,15 @@ class ModuleOnboarding(Document): def before_export(self, doc): doc.is_complete = 0 + + def reset_onboarding(self): + frappe.only_for("Administrator") + + self.is_complete = 0 + steps = self.get_steps() + for step in steps: + step.is_complete = 0 + step.is_skipped = 0 + step.save() + + self.save() diff --git a/frappe/desk/doctype/note/note.py b/frappe/desk/doctype/note/note.py index 64a68312a5..c54689418e 100644 --- a/frappe/desk/doctype/note/note.py +++ b/frappe/desk/doctype/note/note.py @@ -17,7 +17,7 @@ class Note(Document): # expire this notification in a week (default) self.expire_notification_on = frappe.utils.add_days(self.creation, 7) - def before_print(self): + def before_print(self, settings=None): self.print_heading = self.name self.sub_heading = "" diff --git a/frappe/desk/doctype/note/note_list.js b/frappe/desk/doctype/note/note_list.js index c2758ce53b..f7f8d37dcf 100644 --- a/frappe/desk/doctype/note/note_list.js +++ b/frappe/desk/doctype/note/note_list.js @@ -7,7 +7,7 @@ frappe.listview_settings['Note'] = { if(doc.public) { return [__("Public"), "green", "public,=,Yes"]; } else { - return [__("Private"), "darkgrey", "public,=,No"]; + return [__("Private"), "gray", "public,=,No"]; } } } diff --git a/frappe/desk/doctype/notification_log/notification_log.py b/frappe/desk/doctype/notification_log/notification_log.py index c4c6077e85..a6126f1f9b 100644 --- a/frappe/desk/doctype/notification_log/notification_log.py +++ b/frappe/desk/doctype/notification_log/notification_log.py @@ -61,7 +61,7 @@ def make_notification_logs(doc, users): from frappe.social.doctype.energy_point_settings.energy_point_settings import is_energy_point_enabled for user in users: - if frappe.db.exists('User', user): + if frappe.db.exists('User', {"name": user, "enabled": 1}): if is_notifications_enabled(user): if doc.type == 'Energy Point' and not is_energy_point_enabled(): return diff --git a/frappe/desk/doctype/notification_settings/notification_settings.js b/frappe/desk/doctype/notification_settings/notification_settings.js index b8b7f37a4f..88dc145be2 100644 --- a/frappe/desk/doctype/notification_settings/notification_settings.js +++ b/frappe/desk/doctype/notification_settings/notification_settings.js @@ -2,12 +2,19 @@ // For license information, please see license.txt frappe.ui.form.on('Notification Settings', { - onload: () => { + onload: (frm) => { frappe.breadcrumbs.add({ label: __('Settings'), route: '#modules/Settings', type: 'Custom' }); + frm.set_query('subscribed_documents', () => { + return { + filters: { + istable: 0 + } + }; + }); }, refresh: (frm) => { diff --git a/frappe/desk/doctype/notification_settings/notification_settings.json b/frappe/desk/doctype/notification_settings/notification_settings.json index 85f93e156e..fc12022e89 100644 --- a/frappe/desk/doctype/notification_settings/notification_settings.json +++ b/frappe/desk/doctype/notification_settings/notification_settings.json @@ -22,68 +22,52 @@ "default": "1", "fieldname": "enabled", "fieldtype": "Check", - "label": "Enabled", - "show_days": 1, - "show_seconds": 1 + "label": "Enabled" }, { "fieldname": "subscribed_documents", "fieldtype": "Table MultiSelect", - "label": "Subscribed Documents", - "options": "Notification Subscribed Document", - "show_days": 1, - "show_seconds": 1 + "label": "Open Documents", + "options": "Notification Subscribed Document" }, { "fieldname": "column_break_3", "fieldtype": "Section Break", - "label": "Email Settings", - "show_days": 1, - "show_seconds": 1 + "label": "Email Settings" }, { "default": "1", "fieldname": "enable_email_notifications", "fieldtype": "Check", - "label": "Enable Email Notifications", - "show_days": 1, - "show_seconds": 1 + "label": "Enable Email Notifications" }, { "default": "1", "depends_on": "enable_email_notifications", "fieldname": "enable_email_mention", "fieldtype": "Check", - "label": "Mentions", - "show_days": 1, - "show_seconds": 1 + "label": "Mentions" }, { "default": "1", "depends_on": "enable_email_notifications", "fieldname": "enable_email_assignment", "fieldtype": "Check", - "label": "Assignments", - "show_days": 1, - "show_seconds": 1 + "label": "Assignments" }, { "default": "1", "depends_on": "enable_email_notifications", "fieldname": "enable_email_energy_point", "fieldtype": "Check", - "label": "Energy Points", - "show_days": 1, - "show_seconds": 1 + "label": "Energy Points" }, { "default": "1", "depends_on": "enable_email_notifications", "fieldname": "enable_email_share", "fieldtype": "Check", - "label": "Document Share", - "show_days": 1, - "show_seconds": 1 + "label": "Document Share" }, { "default": "__user", @@ -92,23 +76,20 @@ "hidden": 1, "label": "User", "options": "User", - "read_only": 1, - "show_days": 1, - "show_seconds": 1 + "read_only": 1 }, { "default": "0", "fieldname": "seen", "fieldtype": "Check", "hidden": 1, - "label": "Seen", - "show_days": 1, - "show_seconds": 1 + "label": "Seen" } ], "in_create": 1, + "index_web_pages_for_search": 1, "links": [], - "modified": "2020-05-31 22:16:40.798019", + "modified": "2020-11-04 12:54:57.989317", "modified_by": "Administrator", "module": "Desk", "name": "Notification Settings", diff --git a/frappe/desk/doctype/notification_settings/notification_settings.py b/frappe/desk/doctype/notification_settings/notification_settings.py index 9b124cd6f4..34726bdf8a 100644 --- a/frappe/desk/doctype/notification_settings/notification_settings.py +++ b/frappe/desk/doctype/notification_settings/notification_settings.py @@ -42,7 +42,6 @@ def create_notification_settings(user): _doc = frappe.new_doc('Notification Settings') _doc.name = user _doc.insert(ignore_permissions=True) - frappe.db.commit() @frappe.whitelist() diff --git a/frappe/desk/doctype/number_card/number_card.js b/frappe/desk/doctype/number_card/number_card.js index 63b41b956e..6d1454a2cb 100644 --- a/frappe/desk/doctype/number_card/number_card.js +++ b/frappe/desk/doctype/number_card/number_card.js @@ -207,7 +207,7 @@ frappe.ui.form.on('Number Card', { frm.field_options = frappe.report_utils.get_field_options_from_report(data.columns, data); frm.set_df_property('report_field', 'options', frm.field_options.numeric_fields); if (!frm.field_options.numeric_fields.length) { - frappe.msgprint(__(`Report has no numeric fields, please change the Report Name`)); + frappe.msgprint(__("Report has no numeric fields, please change the Report Name")); } } else { frappe.msgprint(__('Report has no data, please modify the filters or change the Report Name')); diff --git a/frappe/desk/doctype/number_card/number_card.py b/frappe/desk/doctype/number_card/number_card.py index d4a2b00c57..7d1a697f6b 100644 --- a/frappe/desk/doctype/number_card/number_card.py +++ b/frappe/desk/doctype/number_card/number_card.py @@ -8,6 +8,7 @@ from frappe.model.document import Document from frappe.utils import cint from frappe.model.naming import append_number_if_name_exists from frappe.modules.export_file import export_to_files +from frappe.config import get_modules_from_all_apps_for_user class NumberCard(Document): def autoname(self): @@ -33,16 +34,24 @@ def get_permission_query_conditions(user=None): return None doctype_condition = False + module_condition = False allowed_doctypes = [frappe.db.escape(doctype) for doctype in frappe.permissions.get_doctypes_with_read()] + allowed_modules = [frappe.db.escape(module.get('module_name')) for module in get_modules_from_all_apps_for_user()] if allowed_doctypes: doctype_condition = '`tabNumber Card`.`document_type` in ({allowed_doctypes})'.format( allowed_doctypes=','.join(allowed_doctypes)) + if allowed_modules: + module_condition = '''`tabNumber Card`.`module` in ({allowed_modules}) + or `tabNumber Card`.`module` is NULL'''.format( + allowed_modules=','.join(allowed_modules)) return ''' - {doctype_condition} - '''.format(doctype_condition=doctype_condition) + {doctype_condition} + and + {module_condition} + '''.format(doctype_condition=doctype_condition, module_condition=module_condition) def has_permission(doc, ptype, user): roles = frappe.get_roles(user) @@ -77,7 +86,7 @@ def get_result(doc, filters, to_date=None): filters = frappe.parse_json(filters) if not filters: - filters = [] + filters = [] if to_date: filters.append([doc.document_type, 'creation', '<', to_date]) @@ -98,9 +107,13 @@ def get_percentage_difference(doc, filters, result): return previous_result = calculate_previous_result(doc, filters) - difference = (result - previous_result)/100.0 - - return difference + if previous_result == 0: + return None + else: + if result == previous_result: + return 0 + else: + return ((result/previous_result)-1)*100.0 def calculate_previous_result(doc, filters): @@ -188,4 +201,4 @@ def add_card_to_dashboard(args): card.save() dashboard.append('cards', dashboard_link) - dashboard.save() \ No newline at end of file + dashboard.save() diff --git a/frappe/desk/doctype/onboarding_step/onboarding_step.json b/frappe/desk/doctype/onboarding_step/onboarding_step.json index 79d659b1ed..f71e821f65 100644 --- a/frappe/desk/doctype/onboarding_step/onboarding_step.json +++ b/frappe/desk/doctype/onboarding_step/onboarding_step.json @@ -8,14 +8,18 @@ "field_order": [ "title", "column_break_2", - "is_mandatory", "is_complete", "is_skipped", + "description_section", + "description", + "intro_video_url", "section_break_5", "action", + "action_label", "column_break_7", "reference_document", "show_full_form", + "show_form_tour", "is_single", "reference_report", "report_reference_doctype", @@ -30,13 +34,6 @@ "video_url" ], "fields": [ - { - "default": "0", - "fieldname": "is_mandatory", - "fieldtype": "Check", - "in_list_view": 1, - "label": "Is Mandatory" - }, { "default": "0", "fieldname": "is_complete", @@ -181,10 +178,38 @@ "fieldname": "show_full_form", "fieldtype": "Check", "label": "Show Full Form?" + }, + { + "description": "Description to inform the user about any action that is going to be performed", + "fieldname": "description_section", + "fieldtype": "Section Break", + "label": "Description" + }, + { + "fieldname": "description", + "fieldtype": "Markdown Editor", + "label": "Description" + }, + { + "fieldname": "intro_video_url", + "fieldtype": "Data", + "label": "Intro Video URL" + }, + { + "fieldname": "action_label", + "fieldtype": "Data", + "label": "Action Label" + }, + { + "default": "0", + "depends_on": "eval:doc.action==\"Create Entry\" && doc.show_full_form", + "fieldname": "show_form_tour", + "fieldtype": "Check", + "label": "Show Form Tour" } ], "links": [], - "modified": "2020-08-06 12:55:20.377679", + "modified": "2020-10-30 14:54:06.646513", "modified_by": "Administrator", "module": "Desk", "name": "Onboarding Step", diff --git a/frappe/desk/doctype/onboarding_step/onboarding_step.py b/frappe/desk/doctype/onboarding_step/onboarding_step.py index 8086acbb2a..e1cc5dfba4 100644 --- a/frappe/desk/doctype/onboarding_step/onboarding_step.py +++ b/frappe/desk/doctype/onboarding_step/onboarding_step.py @@ -10,7 +10,3 @@ class OnboardingStep(Document): def before_export(self, doc): doc.is_complete = 0 doc.is_skipped = 0 - - def validate(self): - if self.action == "Go to Page": - self.is_mandatory = 0 diff --git a/frappe/desk/doctype/tag/tag.py b/frappe/desk/doctype/tag/tag.py index af695a861a..7e016ee91b 100644 --- a/frappe/desk/doctype/tag/tag.py +++ b/frappe/desk/doctype/tag/tag.py @@ -25,6 +25,17 @@ def add_tag(tag, dt, dn, color=None): return tag +@frappe.whitelist() +def add_tags(tags, dt, docs, color=None): + "adds a new tag to a record, and creates the Tag master" + tags = frappe.parse_json(tags) + docs = frappe.parse_json(docs) + for doc in docs: + for tag in tags: + DocTags(dt).add(doc, tag) + + # return tag + @frappe.whitelist() def remove_tag(tag, dt, dn): "removes tag from the record" diff --git a/frappe/desk/doctype/desk_chart/__init__.py b/frappe/desk/doctype/workspace/__init__.py similarity index 100% rename from frappe/desk/doctype/desk_chart/__init__.py rename to frappe/desk/doctype/workspace/__init__.py diff --git a/frappe/desk/doctype/desk_page/test_desk_page.py b/frappe/desk/doctype/workspace/test_workspace.py similarity index 81% rename from frappe/desk/doctype/desk_page/test_desk_page.py rename to frappe/desk/doctype/workspace/test_workspace.py index 7553364744..7a3f122ee2 100644 --- a/frappe/desk/doctype/desk_page/test_desk_page.py +++ b/frappe/desk/doctype/workspace/test_workspace.py @@ -6,5 +6,5 @@ from __future__ import unicode_literals # import frappe import unittest -class TestDeskPage(unittest.TestCase): +class TestWorkspace(unittest.TestCase): pass diff --git a/frappe/desk/doctype/desk_page/desk_page.js b/frappe/desk/doctype/workspace/workspace.js similarity index 84% rename from frappe/desk/doctype/desk_page/desk_page.js rename to frappe/desk/doctype/workspace/workspace.js index 503859eb61..19d429f9f6 100644 --- a/frappe/desk/doctype/desk_page/desk_page.js +++ b/frappe/desk/doctype/workspace/workspace.js @@ -1,11 +1,14 @@ // Copyright (c) 2020, Frappe Technologies and contributors // For license information, please see license.txt -frappe.ui.form.on('Desk Page', { +frappe.ui.form.on('Workspace', { + setup: function() { + frappe.meta.get_field('Workspace Link', 'only_for').no_default = true; + }, + refresh: function(frm) { frm.enable_save(); frm.get_field("is_standard").toggle(frappe.boot.developer_mode); - frm.get_field("extends_another_page").toggle(frappe.boot.developer_mode); frm.get_field("developer_mode_only").toggle(frappe.boot.developer_mode); if (frm.doc.for_user) { diff --git a/frappe/desk/doctype/desk_page/desk_page.json b/frappe/desk/doctype/workspace/workspace.json similarity index 86% rename from frappe/desk/doctype/desk_page/desk_page.json rename to frappe/desk/doctype/workspace/workspace.json index 2b8aea5e6c..fff766a3bf 100644 --- a/frappe/desk/doctype/desk_page/desk_page.json +++ b/frappe/desk/doctype/workspace/workspace.json @@ -12,10 +12,12 @@ "extends", "module", "category", + "icon", "restrict_to_domain", "onboarding", "column_break_3", "extends_another_page", + "is_default", "is_standard", "developer_mode_only", "disable_user_customization", @@ -30,7 +32,7 @@ "shortcuts", "section_break_18", "cards_label", - "cards" + "links" ], "fields": [ { @@ -41,6 +43,7 @@ }, { "collapsible": 1, + "collapsible_depends_on": "charts", "fieldname": "section_break_2", "fieldtype": "Section Break", "label": "Dashboards" @@ -49,14 +52,14 @@ "fieldname": "charts", "fieldtype": "Table", "label": "Charts", - "options": "Desk Chart" + "options": "Workspace Chart" }, { - "depends_on": "eval:!doc.extends_another_page || !doc.is_standard", + "depends_on": "eval:!doc.extends_another_page || !doc.is_standard || frappe.boot.developer_mode", "fieldname": "shortcuts", "fieldtype": "Table", "label": "Shortcuts", - "options": "Desk Shortcut" + "options": "Workspace Shortcut" }, { "fieldname": "restrict_to_domain", @@ -78,12 +81,6 @@ "fieldname": "column_break_3", "fieldtype": "Column Break" }, - { - "fieldname": "cards", - "fieldtype": "Table", - "label": "Cards", - "options": "Desk Card" - }, { "fieldname": "category", "fieldtype": "Select", @@ -144,12 +141,14 @@ }, { "collapsible": 1, + "collapsible_depends_on": "shortcuts", "fieldname": "section_break_15", "fieldtype": "Section Break", "label": "Shortcuts" }, { "collapsible": 1, + "collapsible_depends_on": "links", "fieldname": "section_break_18", "fieldtype": "Section Break", "label": "Link Cards" @@ -176,7 +175,7 @@ "fieldtype": "Link", "in_standard_filter": 1, "label": "Extends", - "options": "Desk Page", + "options": "Workspace", "search_index": 1 }, { @@ -197,13 +196,32 @@ "fieldname": "hide_custom", "fieldtype": "Check", "label": "Hide Custom DocTypes and Reports" - } + }, + { + "fieldname": "icon", + "fieldtype": "Data", + "label": "Icon" + }, + { + "fieldname": "links", + "fieldtype": "Table", + "label": "Links", + "options": "Workspace Link" + }, + { + "default": "0", + "depends_on": "extends_another_page", + "description": "Sets the current page as default for all users", + "fieldname": "is_default", + "fieldtype": "Check", + "label": "Is Default" + } ], "links": [], - "modified": "2020-05-18 19:17:27.206646", + "modified": "2021-01-21 12:09:36.156614", "modified_by": "Administrator", "module": "Desk", - "name": "Desk Page", + "name": "Workspace", "owner": "Administrator", "permissions": [ { diff --git a/frappe/desk/doctype/workspace/workspace.py b/frappe/desk/doctype/workspace/workspace.py new file mode 100644 index 0000000000..0934138821 --- /dev/null +++ b/frappe/desk/doctype/workspace/workspace.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe import _ +from frappe.modules.export_file import export_to_files +from frappe.model.document import Document +from frappe.desk.utils import validate_route_conflict + +from json import loads + +class Workspace(Document): + def validate(self): + if (self.is_standard and not frappe.conf.developer_mode and not disable_saving_as_standard()): + frappe.throw(_("You need to be in developer mode to edit this document")) + validate_route_conflict(self.doctype, self.name) + + duplicate_exists = frappe.db.exists("Workspace", { + "name": ["!=", self.name], 'is_default': 1, 'extends': self.extends + }) + + if self.is_default and self.name and duplicate_exists: + frappe.throw(_("You can only have one default page that extends a particular standard page.")) + + def on_update(self): + if disable_saving_as_standard(): + return + + if frappe.conf.developer_mode and self.is_standard: + export_to_files(record_list=[['Workspace', self.name]], record_module=self.module) + + @staticmethod + def get_module_page_map(): + filters = { + 'extends_another_page': 0, + 'for_user': '', + } + + pages = frappe.get_all("Workspace", fields=["name", "module"], filters=filters, as_list=1) + + return { page[1]: page[0] for page in pages if page[1] } + + def get_link_groups(self): + cards = [] + current_card = { + "label": "Link", + "type": "Card Break", + "icon": None, + "hidden": False, + } + + card_links = [] + + for link in self.links: + link = link.as_dict() + if link.type == "Card Break": + + if card_links: + current_card['links'] = card_links + cards.append(current_card) + + current_card = link + card_links = [] + else: + card_links.append(link) + + current_card['links'] = card_links + cards.append(current_card) + + return cards + + def build_links_table_from_cards(self, config): + # Empty links table + self.links = [] + order = config.get('order') + widgets = config.get('widgets') + + for idx, name in enumerate(order): + card = widgets[name].copy() + links = loads(card.get('links')) + + self.append('links', { + "label": card.get('label'), + "type": "Card Break", + "icon": card.get('icon'), + "hidden": card.get('hidden') or False + }) + + for link in links: + self.append('links', { + "label": link.get('label'), + "type": "Link", + "link_type": link.get('link_type'), + "link_to": link.get('link_to'), + "onboard": link.get('onboard'), + "only_for": link.get('only_for'), + "dependencies": link.get('dependencies'), + "is_query_report": link.get('is_query_report') + }) + + +def disable_saving_as_standard(): + return frappe.flags.in_install or \ + frappe.flags.in_patch or \ + frappe.flags.in_test or \ + frappe.flags.in_fixtures or \ + frappe.flags.in_migrate + +def get_link_type(key): + key = key.lower() + + link_type_map = { + "doctype": "DocType", + "page": "Page", + "report": "Report" + } + + if key in link_type_map: + return link_type_map[key] + + return "DocType" + +def get_report_type(report): + report_type = frappe.get_value("Report", report, "report_type") + return report_type in ["Query Report", "Script Report", "Custom Report"] diff --git a/frappe/desk/doctype/desk_page/__init__.py b/frappe/desk/doctype/workspace_chart/__init__.py similarity index 100% rename from frappe/desk/doctype/desk_page/__init__.py rename to frappe/desk/doctype/workspace_chart/__init__.py diff --git a/frappe/desk/doctype/desk_chart/desk_chart.json b/frappe/desk/doctype/workspace_chart/workspace_chart.json similarity index 90% rename from frappe/desk/doctype/desk_chart/desk_chart.json rename to frappe/desk/doctype/workspace_chart/workspace_chart.json index 09deefd59d..0d800496af 100644 --- a/frappe/desk/doctype/desk_chart/desk_chart.json +++ b/frappe/desk/doctype/workspace_chart/workspace_chart.json @@ -26,10 +26,10 @@ ], "istable": 1, "links": [], - "modified": "2020-03-31 13:33:13.128804", + "modified": "2021-01-12 13:13:25.781925", "modified_by": "Administrator", "module": "Desk", - "name": "Desk Chart", + "name": "Workspace Chart", "owner": "Administrator", "permissions": [], "quick_entry": 1, diff --git a/frappe/desk/doctype/workspace_chart/workspace_chart.py b/frappe/desk/doctype/workspace_chart/workspace_chart.py new file mode 100644 index 0000000000..0bb6194d2e --- /dev/null +++ b/frappe/desk/doctype/workspace_chart/workspace_chart.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class WorkspaceChart(Document): + pass diff --git a/frappe/desk/doctype/desk_shortcut/__init__.py b/frappe/desk/doctype/workspace_link/__init__.py similarity index 100% rename from frappe/desk/doctype/desk_shortcut/__init__.py rename to frappe/desk/doctype/workspace_link/__init__.py diff --git a/frappe/desk/doctype/workspace_link/workspace_link.json b/frappe/desk/doctype/workspace_link/workspace_link.json new file mode 100644 index 0000000000..010fb3f316 --- /dev/null +++ b/frappe/desk/doctype/workspace_link/workspace_link.json @@ -0,0 +1,117 @@ +{ + "actions": [], + "creation": "2020-11-16 15:30:45.784417", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "type", + "label", + "icon", + "hidden", + "link_details_section", + "link_type", + "link_to", + "column_break_7", + "dependencies", + "only_for", + "onboard", + "is_query_report" + ], + "fields": [ + { + "default": "Link", + "fieldname": "type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Type", + "options": "Link\nCard Break", + "reqd": 1 + }, + { + "fieldname": "label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Label", + "reqd": 1 + }, + { + "depends_on": "eval:doc.type == \"Card Break\"", + "fieldname": "icon", + "fieldtype": "Data", + "label": "Icon" + }, + { + "default": "0", + "depends_on": "eval:doc.type == \"Card Break\"", + "fieldname": "hidden", + "fieldtype": "Check", + "label": "Hidden" + }, + { + "depends_on": "eval:doc.type == \"Link\"", + "fieldname": "link_details_section", + "fieldtype": "Section Break", + "label": "Link Details" + }, + { + "fieldname": "link_type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Link Type", + "mandatory_depends_on": "eval:doc.type==\"Link\"", + "options": "DocType\nPage\nReport", + "read_only_depends_on": "eval:doc.type!=\"Link\"" + }, + { + "fieldname": "link_to", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "label": "Link To", + "mandatory_depends_on": "eval:doc.type==\"Link\"", + "options": "link_type", + "read_only_depends_on": "eval:doc.type!=\"Link\"" + }, + { + "fieldname": "column_break_7", + "fieldtype": "Column Break" + }, + { + "fieldname": "dependencies", + "fieldtype": "Data", + "label": "Dependencies" + }, + { + "fieldname": "only_for", + "fieldtype": "Link", + "label": "Only for ", + "options": "Country" + }, + { + "default": "0", + "fieldname": "onboard", + "fieldtype": "Check", + "label": "Onboard" + }, + { + "default": "0", + "depends_on": "eval:doc.link_type == \"Report\"", + "fieldname": "is_query_report", + "fieldtype": "Check", + "label": "Is Query Report" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-01-12 13:13:12.379443", + "modified_by": "Administrator", + "module": "Desk", + "name": "Workspace Link", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/desk/doctype/workspace_link/workspace_link.py b/frappe/desk/doctype/workspace_link/workspace_link.py new file mode 100644 index 0000000000..8a139077a6 --- /dev/null +++ b/frappe/desk/doctype/workspace_link/workspace_link.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class WorkspaceLink(Document): + pass diff --git a/frappe/integrations/doctype/twilio_number_group/__init__.py b/frappe/desk/doctype/workspace_shortcut/__init__.py similarity index 100% rename from frappe/integrations/doctype/twilio_number_group/__init__.py rename to frappe/desk/doctype/workspace_shortcut/__init__.py diff --git a/frappe/desk/doctype/desk_shortcut/desk_shortcut.json b/frappe/desk/doctype/workspace_shortcut/workspace_shortcut.json similarity index 96% rename from frappe/desk/doctype/desk_shortcut/desk_shortcut.json rename to frappe/desk/doctype/workspace_shortcut/workspace_shortcut.json index fbcc2dfb82..8673e93cf7 100644 --- a/frappe/desk/doctype/desk_shortcut/desk_shortcut.json +++ b/frappe/desk/doctype/workspace_shortcut/workspace_shortcut.json @@ -44,6 +44,36 @@ "label": "DocType View", "options": "\nList\nReport Builder\nDashboard\nTree\nNew\nCalendar" }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fieldname": "label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Label", + "reqd": 1 + }, + { + "depends_on": "eval:frappe.boot.developer_mode", + "fieldname": "icon", + "fieldtype": "Data", + "label": "Icon" + }, + { + "depends_on": "eval:frappe.boot.developer_mode", + "fieldname": "restrict_to_domain", + "fieldtype": "Link", + "label": "Restrict to Domain", + "options": "Domain" + }, + { + "depends_on": "eval:doc.type == \"DocType\" && frappe.boot.developer_mode", + "fieldname": "section_break_5", + "fieldtype": "Section Break", + "label": "Count Filter" + }, { "fieldname": "stats_filter", "fieldtype": "Code", @@ -54,55 +84,25 @@ "fieldname": "column_break_3", "fieldtype": "Column Break" }, - { - "description": "For example: {} Open", - "fieldname": "format", - "fieldtype": "Data", - "label": "Format" - }, - { - "depends_on": "eval:doc.type == \"DocType\" && frappe.boot.developer_mode", - "fieldname": "section_break_5", - "fieldtype": "Section Break", - "label": "Count Filter" - }, { "fieldname": "color", "fieldtype": "Color", "label": "Color" }, { - "depends_on": "eval:frappe.boot.developer_mode", - "fieldname": "icon", + "description": "For example: {} Open", + "fieldname": "format", "fieldtype": "Data", - "label": "Icon" - }, - { - "fieldname": "column_break_4", - "fieldtype": "Column Break" - }, - { - "depends_on": "eval:frappe.boot.developer_mode", - "fieldname": "restrict_to_domain", - "fieldtype": "Link", - "label": "Restrict to Domain", - "options": "Domain" - }, - { - "fieldname": "label", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Label", - "reqd": 1 + "label": "Format" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-08-12 14:11:55.080390", + "modified": "2021-01-12 13:13:17.571324", "modified_by": "Administrator", "module": "Desk", - "name": "Desk Shortcut", + "name": "Workspace Shortcut", "owner": "Administrator", "permissions": [], "quick_entry": 1, diff --git a/frappe/desk/doctype/workspace_shortcut/workspace_shortcut.py b/frappe/desk/doctype/workspace_shortcut/workspace_shortcut.py new file mode 100644 index 0000000000..d676f08b73 --- /dev/null +++ b/frappe/desk/doctype/workspace_shortcut/workspace_shortcut.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class WorkspaceShortcut(Document): + pass diff --git a/frappe/desk/form/assign_to.py b/frappe/desk/form/assign_to.py index 26b2bd2835..aee7a8e52a 100644 --- a/frappe/desk/form/assign_to.py +++ b/frappe/desk/form/assign_to.py @@ -168,8 +168,8 @@ def notify_assignment(assigned_by, owner, doc_type, doc_name, action='CLOSE', """ if not (assigned_by and owner and doc_type and doc_name): return - # self assignment / closing - no message - if assigned_by==owner: + # return if self assigned or user disabled + if assigned_by == owner or not frappe.db.get_value('User', owner, 'enabled'): return # Search for email address in description -- i.e. assignee @@ -177,7 +177,7 @@ def notify_assignment(assigned_by, owner, doc_type, doc_name, action='CLOSE', title = get_title(doc_type, doc_name) description_html = "
      {0}
      ".format(description) if description else None - if action=='CLOSE': + if action == 'CLOSE': subject = _('Your assignment on {0} {1} has been removed by {2}')\ .format(frappe.bold(doc_type), get_title_html(title), frappe.bold(user_name)) else: diff --git a/frappe/desk/form/document_follow.py b/frappe/desk/form/document_follow.py index 3aa3a4fa88..f5e5c0ca9b 100644 --- a/frappe/desk/form/document_follow.py +++ b/frappe/desk/form/document_follow.py @@ -10,7 +10,15 @@ from frappe import _ from itertools import groupby @frappe.whitelist() -def follow_document(doctype, doc_name, user, force=False): +def update_follow(doctype, doc_name, following): + if following: + return follow_document(doctype, doc_name, frappe.session.user) + else: + return unfollow_document(doctype, doc_name, frappe.session.user) + + +@frappe.whitelist() +def follow_document(doctype, doc_name, user): ''' param: Doctype name @@ -21,7 +29,7 @@ def follow_document(doctype, doc_name, user, force=False): avoided for some doctype follow only if track changes are set to 1 ''' - if (doctype in ("Communication", "ToDo", "Email Unsubscribe", "File", "Comment") + if (doctype in ("Communication", "ToDo", "Email Unsubscribe", "File", "Comment", "Email Account", "Email Domain") or doctype in log_types): return @@ -76,7 +84,6 @@ def send_email_alert(receiver, docinfo, timeline): ) def send_document_follow_mails(frequency): - ''' param: frequency for sanding mails @@ -140,6 +147,8 @@ def get_version(doctype, doc_name, frequency, user): return timeline def get_comments(doctype, doc_name, frequency, user): + from html2text import html2text + timeline = [] filters = get_filters("reference_name", doc_name, frequency, user) comments = frappe.get_all("Comment", @@ -159,7 +168,7 @@ def get_comments(doctype, doc_name, frequency, user): "time": comment.modified, "data": { "time": time, - "comment": frappe.utils.html2text(str(comment.content)), + "comment": html2text(str(comment.content)), "by": by }, "doctype": doctype, @@ -190,6 +199,8 @@ def get_follow_users(doctype, doc_name): ) def get_row_changed(row_changed, time, doctype, doc_name, v): + from html2text import html2text + items = [] for d in row_changed: d[2] = d[2] if d[2] else ' ' @@ -202,8 +213,8 @@ def get_row_changed(row_changed, time, doctype, doc_name, v): "table_field": d[0], "row": str(d[1]), "field": d[3][0][0], - "from": frappe.utils.html2text(str(d[3][0][1])), - "to": frappe.utils.html2text(str(d[3][0][2])) + "from": html2text(str(d[3][0][1])), + "to": html2text(str(d[3][0][2])) }, "doctype": doctype, "doc_name": doc_name, @@ -229,6 +240,8 @@ def get_added_row(added, time, doctype, doc_name, v): return items def get_field_changed(changed, time, doctype, doc_name, v): + from html2text import html2text + items = [] for d in changed: d[1] = d[1] if d[1] else ' ' @@ -239,8 +252,8 @@ def get_field_changed(changed, time, doctype, doc_name, v): "data": { "time": time, "field": d[0], - "from": frappe.utils.html2text(str(d[1])), - "to": frappe.utils.html2text(str(d[2])) + "from": html2text(str(d[1])), + "to": html2text(str(d[2])) }, "doctype": doctype, "doc_name": doc_name, diff --git a/frappe/desk/form/load.py b/frappe/desk/form/load.py index cacbd3c633..1f5c437330 100644 --- a/frappe/desk/form/load.py +++ b/frappe/desk/form/load.py @@ -13,7 +13,7 @@ from frappe.desk.form.document_follow import is_document_followed from frappe import _ from six.moves.urllib.parse import quote -@frappe.whitelist() +@frappe.whitelist(allow_guest=True) def getdoc(doctype, name, user=None): """ Loads a doclist for a given document. This method is called directly from the client. @@ -52,7 +52,7 @@ def getdoc(doctype, name, user=None): frappe.response.docs.append(doc) -@frappe.whitelist() +@frappe.whitelist(allow_guest=True) def getdoctype(doctype, with_parent=False, cached_timestamp=None): """load doctype""" @@ -91,13 +91,17 @@ def get_docinfo(doc=None, doctype=None, name=None): raise frappe.PermissionError frappe.response["docinfo"] = { "attachments": get_attachments(doc.doctype, doc.name), + "attachment_logs": get_comments(doc.doctype, doc.name, 'attachment'), "communications": _get_communications(doc.doctype, doc.name), 'comments': get_comments(doc.doctype, doc.name), 'total_comments': len(json.loads(doc.get('_comments') or '[]')), 'versions': get_versions(doc), "assignments": get_assignments(doc.doctype, doc.name), + "assignment_logs": get_comments(doc.doctype, doc.name, 'assignment'), "permissions": get_doc_permissions(doc), "shared": frappe.share.get_users(doc.doctype, doc.name), + "share_logs": get_comments(doc.doctype, doc.name, 'share'), + "like_logs": get_comments(doc.doctype, doc.name, 'Like'), "views": get_view_logs(doc.doctype, doc.name), "energy_point_logs": get_point_logs(doc.doctype, doc.name), "additional_timeline_content": get_additional_timeline_content(doc.doctype, doc.name), @@ -128,15 +132,27 @@ def get_communications(doctype, name, start=0, limit=20): return _get_communications(doctype, name, start, limit) -def get_comments(doctype, name): - comments = frappe.get_all('Comment', fields = ['*'], filters = dict( +def get_comments(doctype, name, comment_type='Comment'): + comment_types = [comment_type] + + if comment_type == 'share': + comment_types = ['Shared', 'Unshared'] + + elif comment_type == 'assignment': + comment_types = ['Assignment Completed', 'Assigned'] + + elif comment_type == 'attachment': + comment_types = ['Attachment', 'Attachment Removed'] + + comments = frappe.get_all('Comment', fields = ['name', 'creation', 'content', 'owner', 'comment_type'], filters=dict( reference_doctype = doctype, - reference_name = name + reference_name = name, + comment_type = ['in', comment_types] )) # convert to markdown (legacy ?) - for c in comments: - if c.comment_type == 'Comment': + if comment_type == 'Comment': + for c in comments: c.content = frappe.utils.markdown(c.content) return comments @@ -222,13 +238,12 @@ def get_communication_data(doctype, name, start=0, limit=20, after=None, fields= def get_assignments(dt, dn): cl = frappe.get_all("ToDo", - fields=['name', 'owner', 'description', 'status'], - limit= 5, - filters={ - 'reference_type': dt, - 'reference_name': dn, - 'status': ('!=', 'Cancelled'), - }) + fields=['name', 'owner', 'description', 'status'], + filters={ + 'reference_type': dt, + 'reference_name': dn, + 'status': ('!=', 'Cancelled'), + }) return cl diff --git a/frappe/desk/form/meta.py b/frappe/desk/form/meta.py index c28a40657f..c63da93a33 100644 --- a/frappe/desk/form/meta.py +++ b/frappe/desk/form/meta.py @@ -130,7 +130,7 @@ class FormMeta(Meta): def add_custom_script(self): """embed all require files""" # custom script - custom = frappe.db.get_value("Custom Script", {"dt": self.name, "enabled": 1}, "script") or "" + custom = frappe.db.get_value("Client Script", {"dt": self.name, "enabled": 1}, "script") or "" self.set("__custom_js", custom) @@ -202,13 +202,17 @@ class FormMeta(Meta): self.load_kanban_column_fields() def load_kanban_column_fields(self): - values = frappe.get_list( - 'Kanban Board', fields=['field_name'], - filters={'reference_doctype': self.name}) + try: + values = frappe.get_list( + 'Kanban Board', fields=['field_name'], + filters={'reference_doctype': self.name}) - fields = [x['field_name'] for x in values] - fields = list(set(fields)) - self.set("__kanban_column_fields", fields, as_value=True) + fields = [x['field_name'] for x in values] + fields = list(set(fields)) + self.set("__kanban_column_fields", fields, as_value=True) + except frappe.PermissionError: + # no access to kanban board + pass def get_code_files_via_hooks(hook, name): code_files = [] diff --git a/frappe/desk/form/save.py b/frappe/desk/form/save.py index 5219a98cbd..da43b14fce 100644 --- a/frappe/desk/form/save.py +++ b/frappe/desk/form/save.py @@ -42,7 +42,6 @@ def cancel(doctype=None, name=None, workflow_state_fieldname=None, workflow_stat except Exception: frappe.errprint(frappe.utils.get_traceback()) - frappe.msgprint(frappe._("Did not cancel")) raise def send_updated_docs(doc): diff --git a/frappe/desk/form/utils.py b/frappe/desk/form/utils.py index 4c3bab2e23..395d2b9571 100644 --- a/frappe/desk/form/utils.py +++ b/frappe/desk/form/utils.py @@ -47,7 +47,7 @@ def validate_link(): 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 script").format(fieldname)) + frappe.msgprint(_("Wrong fieldname {0} in add_fetch configuration of custom client script").format(fieldname)) frappe.errprint(frappe.get_traceback()) if fetch_value: diff --git a/frappe/desk/leaderboard.py b/frappe/desk/leaderboard.py index e5654c853f..2a981f061b 100644 --- a/frappe/desk/leaderboard.py +++ b/frappe/desk/leaderboard.py @@ -8,15 +8,26 @@ def get_leaderboards(): 'User': { 'fields': ['points'], 'method': 'frappe.desk.leaderboard.get_energy_point_leaderboard', - 'company_disabled': 1 + 'company_disabled': 1, + 'icon': 'users' } } return leaderboards @frappe.whitelist() def get_energy_point_leaderboard(date_range, company = None, field = None, limit = None): + all_users = frappe.db.get_all('User', + filters = { + 'name': ['not in', ['Administrator', 'Guest']], + 'enabled': 1, + 'user_type': ['!=', 'Website User'] + }, + order_by = 'name ASC') + all_users_list = list(map(lambda x: x['name'], all_users)) + filters = [ ['type', '!=', 'Review'], + ['user', 'in', all_users_list] ] if date_range: date_range = frappe.parse_json(date_range) @@ -27,15 +38,7 @@ def get_energy_point_leaderboard(date_range, company = None, field = None, limit group_by = 'user', order_by = 'value desc' ) - all_users = frappe.db.get_all('User', - filters = { - 'name': ['not in', ['Administrator', 'Guest']], - 'enabled': 1, - 'user_type': ['!=', 'Website User'] - }, - order_by = 'name ASC') - all_users_list = list(map(lambda x: x['name'], all_users)) energy_point_users_list = list(map(lambda x: x['name'], energy_point_users)) for user in all_users_list: if user not in energy_point_users_list: diff --git a/frappe/desk/listview.py b/frappe/desk/listview.py index 1d10a13930..91dc0f3ba9 100644 --- a/frappe/desk/listview.py +++ b/frappe/desk/listview.py @@ -4,7 +4,7 @@ from __future__ import unicode_literals import frappe -@frappe.whitelist() +@frappe.whitelist(allow_guest=True) def get_list_settings(doctype): try: return frappe.get_cached_doc("List View Settings", doctype) diff --git a/frappe/desk/moduleview.py b/frappe/desk/moduleview.py index ad696520f8..df25b77e2d 100644 --- a/frappe/desk/moduleview.py +++ b/frappe/desk/moduleview.py @@ -553,4 +553,4 @@ def get_report_list(module, is_standard="No"): "name": r.name }) - return out + return out \ No newline at end of file diff --git a/frappe/desk/page/activity/activity.css b/frappe/desk/page/activity/activity.css index 87604ff7a5..b2387135c7 100644 --- a/frappe/desk/page/activity/activity.css +++ b/frappe/desk/page/activity/activity.css @@ -10,38 +10,42 @@ cursor: pointer; } +#page-activity hr { + border-top: 1px solid var(--dark-border-color); +} + .activity-label { max-width: 100px; margin-bottom: -4px; } .date-indicator { - background:none; - font-size:12px; - vertical-align:middle; - font-weight:bold; - color:#6c7680; + background: none; + font-size: 12px; + vertical-align: middle; + font-weight: bold; + color: var(--text-muted); } .date-indicator::after { - margin:0 -4px 0 12px; - content:''; - display:inline-block; - height:8px; - width:8px; - border-radius:8px; - background: #d1d8dd; + margin: 0 -4px 0 12px; + content: ""; + display: inline-block; + height: 8px; + width: 8px; + border-radius: 8px; + background: var(--dark-border-color); } .date-indicator.blue { - color: #5e64ff; + color: var(--primary); } .date-indicator.blue::after { - background: #5e64ff; + background: var(--primary); } .activity-message { - border-left: 1px solid #d1d8dd; + border-left: 1px solid var(--dark-border-color); padding: 15px; padding-right: 30px; } @@ -57,14 +61,14 @@ } #page-activity .octicon-heart { - color: #ff5858; + color: var(--red-500); margin: 0px 5px; } .heatmap { - padding-top: 30px; + padding-top: 30px; } .heatmap svg { margin: auto; -} \ No newline at end of file +} diff --git a/frappe/desk/page/activity/activity.js b/frappe/desk/page/activity/activity.js index 4ec54e1b2d..39de414122 100644 --- a/frappe/desk/page/activity/activity.js +++ b/frappe/desk/page/activity/activity.js @@ -3,7 +3,7 @@ frappe.provide("frappe.activity"); -frappe.pages['activity'].on_page_load = function(wrapper) { +frappe.pages['activity'].on_page_load = function (wrapper) { var me = this; frappe.ui.make_app_page({ @@ -14,7 +14,7 @@ frappe.pages['activity'].on_page_load = function(wrapper) { me.page = wrapper.page; me.page.set_title(__("Activity")); - frappe.model.with_doctype("Communication", function() { + frappe.model.with_doctype("Communication", function () { me.page.list = new frappe.views.Activity({ doctype: 'Communication', parent: wrapper @@ -23,7 +23,7 @@ frappe.pages['activity'].on_page_load = function(wrapper) { frappe.activity.render_heatmap(me.page); - me.page.main.on("click", ".activity-message", function() { + me.page.main.on("click", ".activity-message", function () { var link_doctype = $(this).attr("data-link-doctype"), link_name = $(this).attr("data-link-name"), doctype = $(this).attr("data-doctype"), @@ -47,13 +47,13 @@ frappe.pages['activity'].on_page_load = function(wrapper) { }); // Build Report Button - if(frappe.boot.user.can_get_report.indexOf("Feed")!=-1) { - this.page.add_menu_item(__('Build Report'), function() { + if (frappe.boot.user.can_get_report.indexOf("Feed") != -1) { + this.page.add_menu_item(__('Build Report'), function () { frappe.set_route("List", "Feed", "Report"); }, 'fa fa-th') } - this.page.add_menu_item(__('Activity Log'), function() { + this.page.add_menu_item(__('Activity Log'), function () { frappe.route_options = { "user": frappe.session.user } @@ -62,43 +62,43 @@ frappe.pages['activity'].on_page_load = function(wrapper) { }, 'fa fa-th'); }; -frappe.pages['activity'].on_page_show = function() { +frappe.pages['activity'].on_page_show = function () { frappe.breadcrumbs.add("Desk"); } frappe.activity.last_feed_date = false; frappe.activity.Feed = Class.extend({ - init: function(row, data) { + init: function (row, data) { this.scrub_data(data); this.add_date_separator(row, data); - if(!data.add_class) + if (!data.add_class) data.add_class = "label-default"; data.link = ""; if (data.link_doctype && data.link_name) { - data.link = frappe.format(data.link_name, {fieldtype: "Link", options: data.link_doctype}, - {label: __(data.link_doctype) + " " + __(data.link_name)}); + data.link = frappe.format(data.link_name, { fieldtype: "Link", options: data.link_doctype }, + { label: __(data.link_doctype) + " " + __(data.link_name) }); - } else if (data.feed_type==="Comment" && data.comment_type==="Comment") { + } else if (data.feed_type === "Comment" && data.comment_type === "Comment") { // hack for backward compatiblity data.link_doctype = data.reference_doctype; data.link_name = data.reference_name; data.reference_doctype = "Communication"; data.reference_name = data.name; - data.link = frappe.format(data.link_name, {fieldtype: "Link", options: data.link_doctype}, - {label: __(data.link_doctype) + " " + __(data.link_name)}); + data.link = frappe.format(data.link_name, { fieldtype: "Link", options: data.link_doctype }, + { label: __(data.link_doctype) + " " + __(data.link_name) }); } else if (data.reference_doctype && data.reference_name) { - data.link = frappe.format(data.reference_name, {fieldtype: "Link", options: data.reference_doctype}, - {label: __(data.reference_doctype) + " " + __(data.reference_name)}); + data.link = frappe.format(data.reference_name, { fieldtype: "Link", options: data.reference_doctype }, + { label: __(data.reference_doctype) + " " + __(data.reference_name) }); } $(row) .append(frappe.render_template("activity_row", data)) .find("a").addClass("grey"); }, - scrub_data: function(data) { + scrub_data: function (data) { data.by = frappe.user.full_name(data.owner); data.avatar = frappe.avatar(data.owner); @@ -114,22 +114,23 @@ frappe.activity.Feed = Class.extend({ data.when = comment_when(data.creation); data.feed_type = data.comment_type || data.communication_medium; }, - add_date_separator: function(row, data) { + + add_date_separator: function (row, data) { var date = frappe.datetime.str_to_obj(data.creation); var last = frappe.activity.last_feed_date; - if((last && frappe.datetime.obj_to_str(last) != frappe.datetime.obj_to_str(date)) || (!last)) { + if ((last && frappe.datetime.obj_to_str(last) != frappe.datetime.obj_to_str(date)) || (!last)) { var diff = frappe.datetime.get_day_diff(frappe.datetime.get_today(), frappe.datetime.obj_to_str(date)); var pdate; - if(diff < 1) { + if (diff < 1) { pdate = 'Today'; - } else if(diff < 2) { + } else if (diff < 2) { pdate = 'Yesterday'; } else { pdate = frappe.datetime.global_date_format(date); } data.date_sep = pdate; - data.date_class = pdate=='Today' ? "date-indicator blue" : "date-indicator"; + data.date_class = pdate == 'Today' ? "date-indicator blue" : "date-indicator"; } else { data.date_sep = null; data.date_class = ""; @@ -138,29 +139,29 @@ frappe.activity.Feed = Class.extend({ } }); -frappe.activity.render_heatmap = function(page) { - var me = this; +frappe.activity.render_heatmap = function (page) { $('
      \
      \
      ').prependTo(page.main); frappe.call({ method: "frappe.desk.page.activity.activity.get_heatmap_data", - callback: function(r) { - if(r.message) { - var heatmap = new frappe.Chart(".heatmap", { + callback: function (r) { + if (r.message) { + new frappe.Chart(".heatmap", { type: 'heatmap', start: new Date(moment().subtract(1, 'year').toDate()), countLabel: "actions", - discreteDomains: 0, + discreteDomains: 1, + radius: 3, // default 0 data: { 'dataPoints': r.message } }); } } - }) -} + }); +}; frappe.views.Activity = class Activity extends frappe.views.BaseList { constructor(opts) { @@ -181,6 +182,10 @@ frappe.views.Activity = class Activity extends frappe.views.BaseList { // } + setup_view_menu() { + // + } + setup_sort_selector() { } diff --git a/frappe/desk/page/backups/backups.css b/frappe/desk/page/backups/backups.css new file mode 100644 index 0000000000..13f093e0b1 --- /dev/null +++ b/frappe/desk/page/backups/backups.css @@ -0,0 +1,13 @@ +.download-backups { + font-size: var(--text-base); +} + +.download-backup-card { + display: block; + text-decoration: none; +} + +.download-backup-card:hover { + box-shadow: var(--shadow-md); + text-decoration: none; +} diff --git a/frappe/desk/page/backups/backups.html b/frappe/desk/page/backups/backups.html index 0056b6814b..e63481487c 100644 --- a/frappe/desk/page/backups/backups.html +++ b/frappe/desk/page/backups/backups.html @@ -1,32 +1,20 @@ - - - - - - - - - - - {% for f in files %} - - - - - - {% endfor %} - -
      - {{ _("Date") }} - - {{ _("File") }} - - {{ _("Size") }} -
      - {{ f[1] }} - - {{ f[0] }} - - {{ f[2] }} -
      +
      + {% for f in files %} + + {% endfor %} +
      \ No newline at end of file diff --git a/frappe/desk/page/backups/backups.py b/frappe/desk/page/backups/backups.py index 386af70a4e..eaa0c65143 100644 --- a/frappe/desk/page/backups/backups.py +++ b/frappe/desk/page/backups/backups.py @@ -9,7 +9,7 @@ import datetime def get_context(context): def get_time(path): dt = os.path.getmtime(path) - return convert_utc_to_user_timezone(datetime.datetime.utcfromtimestamp(dt)).strftime('%Y-%m-%d %H:%M') + return convert_utc_to_user_timezone(datetime.datetime.utcfromtimestamp(dt)).strftime('%a %b %d %H:%M %Y') def get_size(path): size = os.path.getsize(path) diff --git a/frappe/desk/page/leaderboard/leaderboard.css b/frappe/desk/page/leaderboard/leaderboard.css index a3cb4d09c4..d15b4ffcd4 100644 --- a/frappe/desk/page/leaderboard/leaderboard.css +++ b/frappe/desk/page/leaderboard/leaderboard.css @@ -52,6 +52,34 @@ } .rank { - max-width: 75px; + max-width: 100px; } +.leaderboard .result { + border-top: 1px solid var(--border-color); +} + +.leaderboard .list-item { + padding-left: 45px; +} + +.leaderboard .list-item_content { + padding-right: 60px; +} + +.leaderboard-sidebar { + padding-left: 0; + position: fixed; +} + +.leaderboard-list { + padding: var(-padding-sm) 0; + min-height: 70vh; +} + +.leaderboard-empty-state { + align-items: center; + height: 70vh; + justify-content: center; + display: flex; +} diff --git a/frappe/desk/page/leaderboard/leaderboard.js b/frappe/desk/page/leaderboard/leaderboard.js index 189949ac68..b3fccf84f9 100644 --- a/frappe/desk/page/leaderboard/leaderboard.js +++ b/frappe/desk/page/leaderboard/leaderboard.js @@ -14,11 +14,13 @@ class Leaderboard { frappe.ui.make_app_page({ parent: parent, title: __("Leaderboard"), - single_column: false + single_column: false, + card_layout: true, }); + this.parent = parent; this.page = this.parent.page; - this.page.sidebar.html(``); + this.page.sidebar.html(`
        `); this.$sidebar_list = this.page.sidebar.find('ul'); this.get_leaderboard_config(); @@ -79,7 +81,8 @@ class Leaderboard { this.$graph_area = this.$container.find(".leaderboard-graph"); this.doctypes.map(doctype => { - this.get_sidebar_item(doctype).appendTo(this.$sidebar_list); + const icon = this.leaderboard_config[doctype].icon; + this.get_sidebar_item(doctype, icon).appendTo(this.$sidebar_list); }); this.setup_leaderboard_fields(); @@ -138,16 +141,16 @@ class Leaderboard { } create_date_range_field() { - let timespan_field = $(this.parent).find(`.frappe-control[data-original-title='Timespan']`); + let timespan_field = $(this.parent).find(`.frappe-control[data-original-title=${__('Timespan')}]`); this.date_range_field = $(`
        `).insertAfter(timespan_field).hide(); let date_field = frappe.ui.form.make_control({ df: { fieldtype: 'DateRange', fieldname: 'selected_date_range', - placeholder: "Date Range", + placeholder: __("Date Range"), default: [frappe.datetime.month_start(), frappe.datetime.now_date()], - input_class: 'input-sm', + input_class: 'input-xs', reqd: 1, change: () => { this.selected_date_range = date_field.get_value(); @@ -163,7 +166,7 @@ class Leaderboard { this.$sidebar_list.on("click", "li", (e)=> { let $li = $(e.currentTarget); - let doctype = $li.find("span").attr("doctype-value"); + let doctype = $li.find(".doctype-text").attr("doctype-value"); this.options.selected_company = frappe.defaults.get_default("company"); this.options.selected_doctype = doctype; @@ -181,8 +184,8 @@ class Leaderboard { $(this.parent).find("[data-original-title=Company]").show(); } - this.$sidebar_list.find("li").removeClass("active"); - $li.addClass("active"); + this.$sidebar_list.find("li").removeClass("active selected"); + $li.addClass("active selected"); frappe.set_route("leaderboard", this.options.selected_doctype); this.make_request(); @@ -193,7 +196,7 @@ class Leaderboard { this.$search_box = $(``); $(this.parent).find(".page-form").append(this.$search_box); @@ -236,21 +239,16 @@ class Leaderboard { let graph_items = results.slice(0, 10); this.$graph_area.show().empty(); - let args = { + + const custom_options = { data: { - datasets: [ - { - values: graph_items.map(d => d.value) - } - ], + datasets: [{ values: graph_items.map(d => d.value) }], labels: graph_items.map(d => d.name) }, - colors: ["light-green"], format_tooltip_x: d => d[this.options.selected_filter_item], - type: "bar", height: 140 }; - new frappe.Chart(".leaderboard-graph", args); + frappe.utils.make_chart('.leaderboard-graph', custom_options); notify(this, r); }); @@ -263,7 +261,7 @@ class Leaderboard { frappe.utils.setup_search($(me.parent), ".list-item-container", ".list-id"); } else { me.$graph_area.hide(); - me.message = __("No items found."); + me.message = __("No Items Found"); me.$container.find(".leaderboard-list").html(me.render_list_view()); } } @@ -292,7 +290,7 @@ class Leaderboard { .map(i => frappe.model.unscrub(i)); const fields = ["rank", "name", this.options.selected_filter_item]; const filters = fields.map(filter => { - const col = frappe.model.unscrub(filter); + const col = __(frappe.model.unscrub(filter)); return ( `
        -
        ${filters}
        +
        ${filters}
        `; return html; } @@ -330,14 +328,16 @@ class Leaderboard { } render_message() { - - let html = - `
        -
        -

        No Item found

        -
        -
        `; - + const display_class = this.message ? '' : 'hide'; + let html = `
        +
        + Empty State +
        ${this.message}
        +
        +
        `; return html; } @@ -348,13 +348,13 @@ class Leaderboard { return fieldname === this.options.selected_filter_item; })); - const link = `#Form/${this.options.selected_doctype}/${item.name}`; + const link = `/app/${frappe.router.slug(this.options.selected_doctype)}/${item.name}`; const name_html = item.formatted_name ? `${item.formatted_name}` : ` ${item.name} `; const html = `
        -
        +
        ${index}
        @@ -368,10 +368,13 @@ class Leaderboard { return html; } - get_sidebar_item(item) { - return $(`
      • - - ${ __(item) } + get_sidebar_item(item, icon) { + let icon_html = icon ? frappe.utils.icon(icon, 'md') : ''; + return $(`
      • + ${icon_html} + + ${ __(item) } +
      • `); } diff --git a/frappe/desk/page/setup_wizard/install_fixtures.py b/frappe/desk/page/setup_wizard/install_fixtures.py index 60e1f3242a..6d3aaee22b 100644 --- a/frappe/desk/page/setup_wizard/install_fixtures.py +++ b/frappe/desk/page/setup_wizard/install_fixtures.py @@ -18,14 +18,14 @@ def install(): @frappe.whitelist() def update_genders(): - default_genders = [_("Male"), _("Female"), _("Other"),_("Transgender"), _("Genderqueer"), _("Non-Conforming"),_("Prefer not to say")] + default_genders = ["Male", "Female", "Other","Transgender", "Genderqueer", "Non-Conforming","Prefer not to say"] records = [{'doctype': 'Gender', 'gender': d} for d in default_genders] for record in records: frappe.get_doc(record).insert(ignore_permissions=True, ignore_if_duplicate=True) @frappe.whitelist() def update_salutations(): - default_salutations = [_("Mr"), _("Ms"), _('Mx'), _("Dr"), _("Mrs"), _("Madam"), _("Miss"), _("Master"), _("Prof")] + default_salutations = ["Mr", "Ms", 'Mx', "Dr", "Mrs", "Madam", "Miss", "Master", "Prof"] records = [{'doctype': 'Salutation', 'salutation': d} for d in default_salutations] for record in records: doc = frappe.new_doc(record.get("doctype")) diff --git a/frappe/desk/page/setup_wizard/setup_wizard.js b/frappe/desk/page/setup_wizard/setup_wizard.js index 9a950a694d..f44a57e339 100644 --- a/frappe/desk/page/setup_wizard/setup_wizard.js +++ b/frappe/desk/page/setup_wizard/setup_wizard.js @@ -9,36 +9,34 @@ frappe.setup = { utils: {}, domains: [], - on: function(event, fn) { - if(!frappe.setup.events[event]) { + on: function (event, fn) { + if (!frappe.setup.events[event]) { frappe.setup.events[event] = []; } frappe.setup.events[event].push(fn); }, - add_slide: function(slide) { + add_slide: function (slide) { frappe.setup.slides.push(slide); }, - remove_slide: function(slide_name){ + remove_slide: function (slide_name) { frappe.setup.slides = frappe.setup.slides.filter((slide) => slide.name !== slide_name); }, - run_event: function(event) { - $.each(frappe.setup.events[event] || [], function(i, fn) { + run_event: function (event) { + $.each(frappe.setup.events[event] || [], function (i, fn) { fn(); }); } } -frappe.pages['setup-wizard'].on_page_load = function(wrapper) { +frappe.pages['setup-wizard'].on_page_load = function (wrapper) { let requires = (frappe.boot.setup_wizard_requires || []); - - - frappe.require(requires, function() { + frappe.require(requires, function () { frappe.call({ method: "frappe.desk.page.setup_wizard.setup_wizard.load_languages", freeze: true, - callback: function(r) { + callback: function (r) { frappe.setup.data.lang = r.message; frappe.setup.run_event("before_load"); @@ -47,12 +45,13 @@ frappe.pages['setup-wizard'].on_page_load = function(wrapper) { slides: frappe.setup.slides, slide_class: frappe.setup.SetupWizardSlide, unidirectional: 1, + done_state: 1, before_load: ($footer) => { $footer.find('.next-btn').removeClass('btn-default') .addClass('btn-primary'); $footer.find('.text-right').prepend( - $(` - ${__("Complete Setup")}`)); + $(``)); } } @@ -60,7 +59,7 @@ frappe.pages['setup-wizard'].on_page_load = function(wrapper) { frappe.setup.run_event("after_load"); // frappe.wizard.values = test_values_edu; let route = frappe.get_route(); - if(route) { + if (route) { frappe.wizard.show_slide(route[1]); } } @@ -68,16 +67,16 @@ frappe.pages['setup-wizard'].on_page_load = function(wrapper) { }); }; -frappe.pages['setup-wizard'].on_page_show = function(wrapper) { - if(frappe.get_route()[1]) { +frappe.pages['setup-wizard'].on_page_show = function () { + if (frappe.get_route()[1]) { frappe.wizard && frappe.wizard.show_slide(frappe.get_route()[1]); } }; -frappe.setup.on("before_load", function() { +frappe.setup.on("before_load", function () { // load slides frappe.setup.slides_settings.forEach((s) => { - if(!(s.name==='user' && frappe.boot.developer_mode)) { + if (!(s.name === 'user' && frappe.boot.developer_mode)) { // if not user slide with developer mode frappe.setup.add_slide(s); } @@ -89,8 +88,8 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides { super(args); $.extend(this, args); - this.welcomed = true; this.page_name = "setup-wizard"; + this.welcomed = true; frappe.set_route("setup-wizard/0"); } @@ -113,7 +112,7 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides { handle_enter_press(e) { if (e.which === frappe.ui.keyCode.ENTER) { var $target = $(e.target); - if($target.hasClass('prev-btn')) { + if ($target.hasClass('prev-btn')) { $target.trigger('click'); } else { this.container.find('.next-btn').trigger('click'); @@ -123,7 +122,7 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides { } before_show_slide() { - if(!this.welcomed) { + if (!this.welcomed) { frappe.set_route(this.page_name); return false; } @@ -142,10 +141,10 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides { show_hide_prev_next(id) { super.show_hide_prev_next(id); - if (id + 1 === this.slides.length){ + if (id + 1 === this.slides.length) { this.$next_btn.removeClass("btn-primary").hide(); this.$complete_btn.addClass("btn-primary").show() - .on('click', this.action_on_complete.bind(this)); + .on('click', () => this.action_on_complete()); } else { this.$next_btn.addClass("btn-primary").show(); @@ -155,7 +154,7 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides { refresh_slides() { // For Translations, etc. - if(this.in_refresh_slides || !this.current_slide.set_values()) { + if (this.in_refresh_slides || !this.current_slide.set_values()) { return; } this.in_refresh_slides = true; @@ -171,7 +170,7 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides { // re-render all slide, only remake made slides $.each(this.slide_dict, (id, slide) => { - if(slide.made) { + if (slide.made) { this.made_slide_ids.push(id); } }); @@ -194,30 +193,30 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides { return frappe.call({ method: "frappe.desk.page.setup_wizard.setup_wizard.setup_complete", - args: {args: this.values}, + args: { args: this.values }, callback: (r) => { - if(r.message.status === 'ok') { + if (r.message.status === 'ok') { this.post_setup_success(); - } else if(r.message.fail !== undefined) { + } else if (r.message.fail !== undefined) { this.abort_setup(r.message.fail); } }, - error: this.abort_setup.bind(this, "Error in setup", true) + error: () => this.abort_setup("Error in setup") }); } post_setup_success() { this.set_setup_complete_message(__("Setup Complete"), __("Refreshing...")); - if(frappe.setup.welcome_page) { + if (frappe.setup.welcome_page) { localStorage.setItem("session_last_route", frappe.setup.welcome_page); } - setTimeout(function() { + setTimeout(function () { // Reload - window.location.href = '/desk'; + window.location.href = '/app'; }, 2000); } - abort_setup(fail_msg, error=false) { + abort_setup(fail_msg) { this.$working_state.find('.state-icon-container').html(''); fail_msg = fail_msg ? fail_msg : __("Failed to complete setup"); @@ -231,12 +230,12 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides { listen_for_setup_stages() { frappe.realtime.on("setup_task", (data) => { // console.log('data', data); - if(data.stage_status) { + if (data.stage_status) { // .html('Process '+ data.progress[0] + ' of ' + data.progress[1] + ': ' + data.stage_status); this.update_setup_message(data.stage_status); - this.set_setup_load_percent((data.progress[0]+1)/data.progress[1] * 100); + this.set_setup_load_percent((data.progress[0] + 1) / data.progress[1] * 100); } - if(data.fail_msg) { + if (data.fail_msg) { this.abort_setup(data.fail_msg); } }) @@ -248,8 +247,8 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides { get_setup_slides_filtered_by_domain() { var filtered_slides = []; - frappe.setup.slides.forEach(function(slide) { - if(frappe.setup.domains) { + frappe.setup.slides.forEach(function (slide) { + if (frappe.setup.domains) { let active_domains = frappe.setup.domains; if (!slide.domains || slide.domains.filter(d => active_domains.includes(d)).length > 0) { @@ -277,8 +276,7 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides { } attach_abort_button() { - this.$abort_btn = $(``); + this.$abort_btn = $(``); this.$working_state.find('.content').append(this.$abort_btn); this.$abort_btn.on('click', () => { @@ -290,18 +288,18 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides { this.$abort_btn.hide(); } - get_message(title, message="") { - const loading_html = `
        -
        -
        + get_message(title, message = "") { + const loading_html = `
        +
        +
        `; - return $(`
        + return $(`
        -

        ${title}

        +

        ${title}

        ${loading_html}
        -

        ${message}

        +

        ${message}

        `); } @@ -312,7 +310,7 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides { } set_setup_load_percent(percent) { - this.$working_state.find('.progress-bar').css({"width": percent + "%"}); + this.$working_state.find('.progress-bar').css({ "width": percent + "%" }); } }; @@ -327,13 +325,13 @@ frappe.setup.SetupWizardSlide = class SetupWizardSlide extends frappe.ui.Slide { this.reset_action_button_state(); } - set_init_values () { + set_init_values() { var me = this; // set values from frappe.setup.values - if(frappe.wizard.values && this.fields) { - this.fields.forEach(function(f) { + if (frappe.wizard.values && this.fields) { + this.fields.forEach(function (f) { var value = frappe.wizard.values[f.fieldname]; - if(value) { + if (value) { me.get_field(f.fieldname).set_input(value); } }); @@ -354,11 +352,13 @@ frappe.setup.slides_settings = [ // help: __("Let's prepare the system for first use."), fields: [ - { fieldname: "language", label: __("Your Language"), - fieldtype: "Select", reqd: 1} + { + fieldname: "language", label: __("Your Language"), + fieldtype: "Select", reqd: 1 + } ], - onload: function(slide) { + onload: function (slide) { this.setup_fields(slide); var language_field = slide.get_field("language"); @@ -372,7 +372,7 @@ frappe.setup.slides_settings = [ moment.locale("en"); }, - setup_fields: function(slide) { + setup_fields: function (slide) { frappe.setup.utils.setup_language_field(slide); frappe.setup.utils.bind_language_events(slide); }, @@ -385,25 +385,31 @@ frappe.setup.slides_settings = [ icon: "fa fa-flag", // help: __("Select your Country, Time Zone and Currency"), fields: [ - { fieldname: "country", label: __("Your Country"), reqd:1, - fieldtype: "Select" }, + { + fieldname: "country", label: __("Your Country"), reqd: 1, + fieldtype: "Select" + }, { fieldtype: "Section Break" }, - { fieldname: "timezone", label: __("Time Zone"), reqd:1, - fieldtype: "Select" }, + { + fieldname: "timezone", label: __("Time Zone"), reqd: 1, + fieldtype: "Select" + }, { fieldtype: "Column Break" }, - { fieldname: "currency", label: __("Currency"), reqd:1, - fieldtype: "Select" } + { + fieldname: "currency", label: __("Currency"), reqd: 1, + fieldtype: "Select" + } ], - onload: function(slide) { - if(frappe.setup.data.regional_data) { + onload: function (slide) { + if (frappe.setup.data.regional_data) { this.setup_fields(slide); } else { frappe.setup.utils.load_regional_data(slide, this.setup_fields); } }, - setup_fields: function(slide) { + setup_fields: function (slide) { frappe.setup.utils.setup_region_fields(slide); frappe.setup.utils.bind_region_events(slide); } @@ -415,24 +421,30 @@ frappe.setup.slides_settings = [ title: __("The First User: You"), icon: "fa fa-user", fields: [ - { "fieldtype":"Attach Image", "fieldname":"attach_user_image", - label: __("Attach Your Picture"), is_private: 0, align: 'center'}, - { "fieldname": "full_name", "label": __("Full Name"), "fieldtype": "Data", - reqd:1}, - { "fieldname": "email", "label": __("Email Address") + ' (' + __("Will be your login ID") + ')', - "fieldtype": "Data", "options":"Email"}, + { + "fieldtype": "Attach Image", "fieldname": "attach_user_image", + label: __("Attach Your Picture"), is_private: 0, align: 'center' + }, + { + "fieldname": "full_name", "label": __("Full Name"), "fieldtype": "Data", + reqd: 1 + }, + { + "fieldname": "email", "label": __("Email Address") + ' (' + __("Will be your login ID") + ')', + "fieldtype": "Data", "options": "Email" + }, { "fieldname": "password", "label": __("Password"), "fieldtype": "Password" } ], // help: __('The first user will become the System Manager (you can change this later).'), - onload: function(slide) { - if(frappe.session.user!=="Administrator") { + onload: function (slide) { + if (frappe.session.user !== "Administrator") { slide.form.fields_dict.email.$wrapper.toggle(false); slide.form.fields_dict.password.$wrapper.toggle(false); // remove password field delete slide.form.fields_dict.password; - if(frappe.boot.user.first_name || frappe.boot.user.last_name) { + if (frappe.boot.user.first_name || frappe.boot.user.last_name) { slide.form.fields_dict.full_name.set_input( [frappe.boot.user.first_name, frappe.boot.user.last_name].join(' ').trim()); } @@ -440,7 +452,7 @@ frappe.setup.slides_settings = [ var user_image = frappe.get_cookie("user_image"); var $attach_user_image = slide.form.fields_dict.attach_user_image.$wrapper; - if(user_image) { + if (user_image) { $attach_user_image.find(".missing-image").toggle(false); $attach_user_image.find("img").attr("src", decodeURIComponent(user_image)); $attach_user_image.find(".img-container").toggle(true); @@ -457,11 +469,11 @@ frappe.setup.slides_settings = [ } }, - setup_fields: function(slide) { - if(frappe.setup.data.full_name) { + setup_fields: function (slide) { + if (frappe.setup.data.full_name) { slide.form.fields_dict.full_name.set_input(frappe.setup.data.full_name); } - if(frappe.setup.data.email) { + if (frappe.setup.data.email) { let email = frappe.setup.data.email; slide.form.fields_dict.email.set_input(email); if (frappe.get_gravatar(email, 200)) { @@ -476,21 +488,21 @@ frappe.setup.slides_settings = [ ]; frappe.setup.utils = { - load_regional_data: function(slide, callback) { + load_regional_data: function (slide, callback) { frappe.call({ - method:"frappe.geo.country_info.get_country_timezone_info", - callback: function(data) { + method: "frappe.geo.country_info.get_country_timezone_info", + callback: function (data) { frappe.setup.data.regional_data = data.message; callback(slide); } }); }, - load_user_details: function(slide, callback) { + load_user_details: function (slide, callback) { frappe.call({ method: "frappe.desk.page.setup_wizard.setup_wizard.load_user_details", freeze: true, - callback: function(r) { + callback: function (r) { frappe.setup.data.full_name = r.message.full_name; frappe.setup.data.email = r.message.email; callback(slide); @@ -498,13 +510,13 @@ frappe.setup.utils = { }) }, - setup_language_field: function(slide) { + setup_language_field: function (slide) { var language_field = slide.get_field("language"); language_field.df.options = frappe.setup.data.lang.languages; language_field.refresh(); }, - setup_region_fields: function(slide) { + setup_region_fields: function (slide) { /* Set a slide's country, timezone and currency fields */ @@ -516,33 +528,34 @@ frappe.setup.utils = { .add_options([""].concat(Object.keys(data.country_info).sort())); slide.get_input("currency").empty() - .add_options(frappe.utils.unique([""].concat($.map(data.country_info, - function(opts, country) { return opts.currency; }))).sort()); + .add_options(frappe.utils.unique([""].concat( + $.map(data.country_info, opts => opts.currency) + )).sort()); slide.get_input("timezone").empty() .add_options([""].concat(data.all_timezones)); // set values if present - if(frappe.wizard.values.country) { + if (frappe.wizard.values.country) { country_field.set_input(frappe.wizard.values.country); } else if (data.default_country) { country_field.set_input(data.default_country); } - if(frappe.wizard.values.currency) { + if (frappe.wizard.values.currency) { slide.get_field("currency").set_input(frappe.wizard.values.currency); } - if(frappe.wizard.values.timezone) { + if (frappe.wizard.values.timezone) { slide.get_field("timezone").set_input(frappe.wizard.values.timezone); } }, - bind_language_events: function(slide) { - slide.get_input("language").unbind("change").on("change", function() { - clearTimeout (slide.language_call_timeout); - slide.language_call_timeout = setTimeout (() => { + bind_language_events: function (slide) { + slide.get_input("language").unbind("change").on("change", function () { + clearTimeout(slide.language_call_timeout); + slide.language_call_timeout = setTimeout(() => { var lang = $(this).val() || "English"; frappe._messages = {}; frappe.call({ @@ -551,7 +564,7 @@ frappe.setup.utils = { args: { language: lang }, - callback: function(r) { + callback: function () { frappe.setup._from_load_messages = true; frappe.wizard.refresh_slides(); } @@ -560,11 +573,11 @@ frappe.setup.utils = { }); }, - bind_region_events: function(slide) { + bind_region_events: function (slide) { /* Bind a slide's country, timezone and currency fields */ - slide.get_input("country").on("change", function() { + slide.get_input("country").on("change", function () { var country = slide.get_input("country").val(); var $timezone = slide.get_input("timezone"); var data = frappe.setup.data.regional_data; @@ -572,7 +585,7 @@ frappe.setup.utils = { $timezone.empty(); // add country specific timezones first - if(country) { + if (country) { var timezone_list = data.country_info[country].timezones || []; $timezone.add_options(timezone_list.sort()); slide.get_field("currency").set_input(data.country_info[country].currency); @@ -589,16 +602,16 @@ frappe.setup.utils = { || "dd-mm-yyyy"); }); - slide.get_input("currency").on("change", function() { + slide.get_input("currency").on("change", function () { var currency = slide.get_input("currency").val(); if (!currency) return; - frappe.model.with_doc("Currency", currency, function() { + frappe.model.with_doc("Currency", currency, function () { frappe.provide("locals.:Currency." + currency); var currency_doc = frappe.model.get_doc("Currency", currency); var number_format = currency_doc.number_format; - if (number_format==="#.###") { + if (number_format === "#.###") { number_format = "#.###,##"; - } else if (number_format==="#,###") { + } else if (number_format === "#,###") { number_format = "#,###.##" } diff --git a/frappe/desk/page/translation_tool/translation_tool.css b/frappe/desk/page/translation_tool/translation_tool.css index 52d34777dd..9603a4ce35 100644 --- a/frappe/desk/page/translation_tool/translation_tool.css +++ b/frappe/desk/page/translation_tool/translation_tool.css @@ -5,6 +5,7 @@ cursor: pointer; overflow: hidden; } + .translation-item:hover { background-color: #fafbfc; } @@ -19,8 +20,9 @@ } .translation-tool { - border: 0px 1px 1px 1px solid #d1d8dd; + display: flex; width: 100%; + padding: 0; height: 72vh; } diff --git a/frappe/desk/page/translation_tool/translation_tool.js b/frappe/desk/page/translation_tool/translation_tool.js index 892bab32ce..b3f0c032e3 100644 --- a/frappe/desk/page/translation_tool/translation_tool.js +++ b/frappe/desk/page/translation_tool/translation_tool.js @@ -2,7 +2,8 @@ frappe.pages['translation-tool'].on_page_load = function(wrapper) { var page = frappe.ui.make_app_page({ parent: wrapper, title: 'Translation Tool', - single_column: true + single_column: true, + card_layout: true, }); frappe.translation_tool = new TranslationTool(page); @@ -250,7 +251,7 @@ class TranslationTool { set_status(translation) { this.form.get_field('header').$wrapper.find('.translation-status').html(` - + ${this.get_indicator_status_text(translation)} `); @@ -347,7 +348,7 @@ class TranslationTool { ) .then(() => { frappe.dom.unfreeze(); - frappe.show_alert(__('Successfully Submitted!')); + frappe.show_alert({ message: __('Successfully Submitted!'), indicator: 'success'}); this.edited_translations = {}; this.update_header(); this.fetch_messages_then_render(); @@ -426,10 +427,13 @@ class TranslationTool { let edited_translations_count = Object.keys(this.edited_translations) .length; if (edited_translations_count) { - this.page.set_indicator( - __('{0} translations pending', [edited_translations_count]), - 'orange' - ); + let message = ''; + if (edited_translations_count == 1) { + message = __('{0} translation pending', [edited_translations_count]); + } else { + message = __('{0} translations pending', [edited_translations_count]); + } + this.page.set_indicator(message, 'orange'); } else { this.page.set_indicator(''); } diff --git a/frappe/desk/page/user_profile/user_profile.css b/frappe/desk/page/user_profile/user_profile.css index c05a52ada2..9bcfc3394a 100644 --- a/frappe/desk/page/user_profile/user_profile.css +++ b/frappe/desk/page/user_profile/user_profile.css @@ -1,121 +1,30 @@ -.user-image-container { - margin-top: 7px; - padding-bottom: 100%; +.recent-activity .new-timeline { + padding-top: 0; } -.user-image-container .standard-image { - font-size: 72px; +.recent-activity .new-timeline:before { + top: 25px; } -.profile-details { - margin: -5px 5px; +.recent-activity-title { + font-weight: 700; + font-size: var(--text-xl); + color: var(--text-color); } -.profile-links { - margin: 30px 5px; +.recent-activity .recent-activity-footer { + margin-left: calc(var(--timeline-left-padding) + var(--timeline-item-left-margin)); + max-width: var(--timeline-content-max-width); } -.user-initial { - font-size: 72px; -} - -.chart-column-container{ - border-bottom: 1px solid #d1d8dd; - margin: 5px 0; -} - -.chart-container text.title { - text-transform: uppercase; - font-size: 11px; -} - -.heatmap-container { - height: 170px -} - -.performance-heatmap { - width: 80%; - display: inline-block; -} - -.performance-heatmap .chart-container { - margin-left: 30px; -} - -.performance-heatmap .frappe-chart { - margin-top: 5px; -} - -.performance-heatmap .frappe-chart .chart-legend { - display: none; -} - -.performance-percentage-chart .frappe-chart { - position: absolute; - top: 5px; -} - -.performance-line-chart .frappe-chart { - margin-top: -20px; -} - -.percentage-chart-container { - height: 130px; -} - -.line-chart-container .chart-filter { - z-index: 1; +.recent-activity .show-more-activity-btn { + display: block; + margin: auto; + width: max-content; + margin-top: 35px; + font-size: var(--text-md); } .recent-activity { - margin: 20px; - font-size: 12px; -} - -.show-more-activity { - text-align: center; - margin-top: 20px; -} - -.recent-activity-item { - margin: 15px 5px; -} - -.interest-icon { - margin-right: 5px; -} - -.chart-filter { - position: relative; - top: 5px; - margin-right: 10px; -} - -.filter-label { - margin-right: 4px; -} - -.performance-title { - position: relative; - left: 30px; - top: 20px; -} - -@media (max-width: 991px) { - .user-profile-sidebar { - display: flex; - } - - .percentage-chart-container { - border-top: 1px solid #d1d8dd; - } - - .user-profile-sidebar .profile-links { - margin: 0; - } - - .user-profile-sidebar .profile-details { - width: 50%; - margin: 0; - } -} + padding-bottom: 60px; +} \ No newline at end of file diff --git a/frappe/desk/page/user_profile/user_profile.html b/frappe/desk/page/user_profile/user_profile.html index ca9c8f0001..911ccc702d 100644 --- a/frappe/desk/page/user_profile/user_profile.html +++ b/frappe/desk/page/user_profile/user_profile.html @@ -1,25 +1,44 @@ - +
        +
        + +
        +
        +

        {%=__("Type Distribution") %}

        +
        +
        +
        +
        + No Data to Show +
        +
        +
        +
        +
        +

        {%=__("Energy Points") %}

        +
        +
        +
        +
        + No Data to Show +
        +
        +
        +
        +
        +
        {%=__("Recent Activity") %}
        +
        + +
        +
        +
        \ No newline at end of file diff --git a/frappe/desk/page/user_profile/user_profile.js b/frappe/desk/page/user_profile/user_profile.js index 646c31f7a1..3443a33942 100644 --- a/frappe/desk/page/user_profile/user_profile.js +++ b/frappe/desk/page/user_profile/user_profile.js @@ -1,413 +1,6 @@ -frappe.provide('frappe.energy_points'); - -frappe.pages['user-profile'].on_page_load = function(wrapper) { - - frappe.ui.make_app_page({ - parent: wrapper, - title: __('User Profile'), - }); - - let user_profile = new UserProfile(wrapper); - $(wrapper).bind('show', ()=> { +frappe.pages['user-profile'].on_page_load = function (wrapper) { + frappe.require('assets/js/user_profile_controller.min.js', () => { + let user_profile = new frappe.ui.UserProfile(wrapper); user_profile.show(); }); -}; - -class UserProfile { - - constructor(wrapper) { - this.wrapper = $(wrapper); - this.page = wrapper.page; - this.sidebar = this.wrapper.find('.layout-side-section'); - this.main_section = this.wrapper.find('.layout-main-section'); - } - - show() { - let route = frappe.get_route(); - this.user_id = route[1] || frappe.session.user; - - //validate if user - if (route.length > 1) { - frappe.db.exists('User', this.user_id).then( exists => { - if (exists) { - this.make_user_profile(); - } else { - frappe.msgprint(__('User does not exist')); - } - }); - } else { - this.user_id = frappe.session.user; - this.make_user_profile(); - } - } - - make_user_profile() { - frappe.set_route('user-profile', this.user_id); - this.user = frappe.user_info(this.user_id); - this.page.set_title(this.user.fullname); - this.setup_user_search(); - this.main_section.empty().append(frappe.render_template('user_profile')); - this.energy_points = 0; - this.review_points = 0; - this.rank = 0; - this.month_rank = 0; - this.render_user_details(); - this.render_points_and_rank(); - this.render_heatmap(); - this.render_line_chart(); - this.render_percentage_chart('type', 'Type Distribution'); - this.create_percentage_chart_filters(); - this.setup_show_more_activity(); - this.render_user_activity(); - } - - setup_user_search() { - this.$user_search_button = this.page.set_secondary_action(__('Change User'), () => { - this.show_user_search_dialog(); - }); - } - - show_user_search_dialog() { - let dialog = new frappe.ui.Dialog({ - title: __('Change User'), - fields: [ - { - fieldtype: 'Link', - fieldname: 'user', - options: 'User', - label: __('User'), - } - ], - primary_action_label: __('Go'), - primary_action: ({ user }) => { - dialog.hide(); - this.user_id = user; - this.make_user_profile(); - } - }); - dialog.show(); - } - - render_heatmap() { - this.heatmap = new frappe.Chart('.performance-heatmap', { - type: 'heatmap', - countLabel: 'Energy Points', - data: {}, - discreteDomains: 0, - }); - this.update_heatmap_data(); - this.create_heatmap_chart_filters(); - } - - update_heatmap_data(date_from) { - frappe.xcall('frappe.desk.page.user_profile.user_profile.get_energy_points_heatmap_data', { - user: this.user_id, - date: date_from || frappe.datetime.year_start(), - }).then((r) => { - this.heatmap.update( {dataPoints: r} ); - }); - } - - - render_line_chart() { - this.line_chart_filters = [ - ['Energy Point Log', 'user', '=', this.user_id, false], - ['Energy Point Log', 'type', '!=', 'Review', false] - ]; - - this.line_chart_config = { - timespan: 'Last Month', - time_interval: 'Daily', - type: 'Line', - value_based_on: 'points', - chart_type: 'Sum', - document_type: 'Energy Point Log', - name: 'Energy Points', - width: 'half', - based_on: 'creation' - }; - - this.line_chart = new frappe.Chart( '.performance-line-chart', { - title: 'Energy Points', - type: 'line', - height: 200, - data: { - labels: [], - datasets: [{}] - }, - colors: ['purple'], - axisOptions: { - xIsSeries: 1 - } - }); - this.update_line_chart_data(); - this.create_line_chart_filters(); - } - - update_line_chart_data() { - this.line_chart_config.filters_json = JSON.stringify(this.line_chart_filters); - - frappe.xcall('frappe.desk.doctype.dashboard_chart.dashboard_chart.get', { - chart: this.line_chart_config, - no_cache: 1, - }).then(chart => { - this.line_chart.update(chart); - }); - } - - render_percentage_chart(field, title) { - frappe.xcall('frappe.desk.page.user_profile.user_profile.get_energy_points_percentage_chart_data', { - user: this.user_id, - field: field - }).then(chart => { - if (chart.labels.length) { - this.percentage_chart = new frappe.Chart( '.performance-percentage-chart', { - title: title, - type: 'percentage', - data: { - labels: chart.labels, - datasets: chart.datasets - }, - truncateLegends: 1, - barOptions: { - height: 11, - depth: 1 - }, - height: 160, - maxSlices: 8, - colors: ['#5e64ff', '#743ee2', '#ff5858', '#ffa00a', '#feef72', '#28a745', '#98d85b', '#a9a7ac'], - }); - } else { - this.wrapper.find('.percentage-chart-container').hide(); - } - }); - } - - create_line_chart_filters() { - let filters = [ - { - label: 'All', - options: ['All', 'Auto', 'Criticism', 'Appreciation', 'Revert'], - action: (selected_item) => { - if (selected_item === 'All') { - this.line_chart_filters = [ - ['Energy Point Log', 'user', '=', this.user_id, false], - ['Energy Point Log', 'type', '!=', 'Review', false] - ]; - } else { - this.line_chart_filters[1] = ['Energy Point Log', 'type', '=', selected_item, false]; - } - this.update_line_chart_data(); - } - }, - { - label: 'Last Month', - options: ['Last Week', 'Last Month', 'Last Quarter', 'Last Year'], - action: (selected_item) => { - this.line_chart_config.timespan = selected_item; - this.update_line_chart_data(); - } - }, - { - label: 'Daily', - options: ['Daily', 'Weekly', 'Monthly'], - action: (selected_item) => { - this.line_chart_config.time_interval = selected_item; - this.update_line_chart_data(); - } - }, - ]; - frappe.dashboard_utils.render_chart_filters(filters, 'chart-filter', '.line-chart-container', 1); - } - - create_percentage_chart_filters() { - let filters = [ - { - label: 'Type', - options: ['Type', 'Reference Doctype', 'Rule'], - fieldnames: ['type', 'reference_doctype', 'rule'], - action: (selected_item, fieldname) => { - let title = selected_item + ' Distribution'; - this.render_percentage_chart(fieldname, title); - } - }, - ]; - frappe.dashboard_utils.render_chart_filters(filters, 'chart-filter', '.percentage-chart-container'); - } - - create_heatmap_chart_filters() { - let filters = [ - { - label: frappe.dashboard_utils.get_year(frappe.datetime.now_date()), - options: frappe.dashboard_utils.get_years_since_creation(frappe.boot.user.creation), - action: (selected_item) => { - this.update_heatmap_data(frappe.datetime.obj_to_str(selected_item)); - } - }, - ]; - frappe.dashboard_utils.render_chart_filters(filters, 'chart-filter', '.heatmap-container'); - } - - - edit_profile() { - let edit_profile_dialog = new frappe.ui.Dialog({ - title: __('Edit Profile'), - fields: [ - { - fieldtype: 'Attach Image', - fieldname: 'user_image', - label: 'Profile Image', - }, - { - fieldtype: 'Data', - fieldname: 'interest', - label: 'Interests', - }, - { - fieldtype: 'Column Break' - }, - { - fieldtype: 'Data', - fieldname: 'location', - label: 'Location', - }, - { - fieldtype: 'Section Break', - fieldname: 'Interest', - }, - { - fieldtype: 'Small Text', - fieldname: 'bio', - label: 'Bio', - } - ], - primary_action: values => { - edit_profile_dialog.disable_primary_action(); - frappe.xcall('frappe.desk.page.user_profile.user_profile.update_profile_info', { - profile_info: values - }).then(user => { - user.image = user.user_image; - this.user = Object.assign(values, user); - edit_profile_dialog.hide(); - this.render_user_details(); - }).finally(() => { - edit_profile_dialog.enable_primary_action(); - }); - }, - primary_action_label: __('Save') - }); - - edit_profile_dialog.set_values({ - user_image: this.user.image, - location: this.user.location, - interest: this.user.interest, - bio: this.user.bio - }); - edit_profile_dialog.show(); - } - - render_user_details() { - this.sidebar.empty().append(frappe.render_template('user_profile_sidebar', { - user_image: frappe.avatar(this.user_id, 'avatar-frame', 'user_image', this.user.image), - user_abbr: this.user.abbr, - user_location: this.user.location, - user_interest: this.user.interest, - user_bio: this.user.bio, - })); - - this.setup_user_profile_links(); - } - - setup_user_profile_links() { - if (this.user_id !== frappe.session.user) { - this.wrapper.find('.profile-links').hide(); - } else { - this.wrapper.find('.edit-profile-link').on('click', () => { - this.edit_profile(); - }); - - this.wrapper.find('.user-settings-link').on('click', () => { - this.go_to_user_settings(); - }); - } - } - - get_user_rank() { - return frappe.xcall('frappe.desk.page.user_profile.user_profile.get_user_rank', { - user: this.user_id, - }).then(r => { - if (r.monthly_rank.length) this.month_rank = r.monthly_rank[0]; - if (r.all_time_rank.length) this.rank = r.all_time_rank[0]; - }); - } - - get_user_points() { - return frappe.xcall( - 'frappe.social.doctype.energy_point_log.energy_point_log.get_user_energy_and_review_points', - { - user: this.user_id, - } - ).then(r => { - if (r[this.user_id]) { - this.energy_points = r[this.user_id].energy_points; - this.review_points = r[this.user_id].review_points; - } - }); - } - - render_points_and_rank() { - let $profile_details = this.wrapper.find('.profile-details'); - - this.get_user_rank().then(() => { - this.get_user_points().then(() => { - let html = $(__(`

        ${__('Energy Points: ')}{0}

        -

        ${__('Review Points: ')}{1}

        -

        ${__('Rank: ')}{2}

        -

        ${__('Monthly Rank: ')}{3}

        - `, [this.energy_points, this.review_points, this.rank, this.month_rank])); - - $profile_details.append(html); - }); - }); - } - - go_to_user_settings() { - frappe.set_route('Form', 'User', this.user_id); - } - - render_user_activity() { - this.$recent_activity_list = this.wrapper.find('.recent-activity-list'); - - let get_recent_energy_points_html = (field) => { - let message_html = frappe.energy_points.format_history_log(field); - return `

        ${message_html}

        `; - }; - - frappe.xcall('frappe.desk.page.user_profile.user_profile.get_energy_points_list', { - start: this.activity_start, - limit: this.activity_end, - user: this.user_id - }).then(list => { - if (list.length < 11) { - let activity_html = `${__('No More Activity')}`; - this.wrapper.find('.show-more-activity').html(activity_html); - } - let html = list.slice(0, 10).map(get_recent_energy_points_html).join(''); - this.$recent_activity_list.append(html); - }); - } - - setup_show_more_activity() { - //Show 10 items at a time - this.activity_start = 0; - this.activity_end = 11; - this.wrapper.find('.show-more-activity').on('click', () => this.show_more_activity()); - } - - show_more_activity() { - this.activity_start = this.activity_end; - this.activity_end += 11; - this.render_user_activity(); - } - -} +}; \ No newline at end of file diff --git a/frappe/desk/page/user_profile/user_profile.py b/frappe/desk/page/user_profile/user_profile.py index 06928f9855..73df6d78cb 100644 --- a/frappe/desk/page/user_profile/user_profile.py +++ b/frappe/desk/page/user_profile/user_profile.py @@ -1,17 +1,23 @@ import frappe from datetime import datetime +from frappe.utils import getdate @frappe.whitelist() def get_energy_points_heatmap_data(user, date): + try: + date = getdate(date) + except Exception: + date = getdate() + return dict(frappe.db.sql("""select unix_timestamp(date(creation)), sum(points) from `tabEnergy Point Log` where date(creation) > subdate('{date}', interval 1 year) and date(creation) < subdate('{date}', interval -1 year) and - user = '{user}' and + user = %s and type != 'Review' group by date(creation) - order by creation asc""".format(user = user, date = date))) + order by creation asc""".format(date = date), user)) @frappe.whitelist() diff --git a/frappe/desk/page/user_profile/user_profile_controller.js b/frappe/desk/page/user_profile/user_profile_controller.js new file mode 100644 index 0000000000..c1a89f316e --- /dev/null +++ b/frappe/desk/page/user_profile/user_profile_controller.js @@ -0,0 +1,453 @@ +import BaseTimeline from "../../../public/js/frappe/form/footer/base_timeline"; +frappe.provide('frappe.energy_points'); + +class UserProfile { + constructor(wrapper) { + this.wrapper = $(wrapper); + this.page = frappe.ui.make_app_page({ + parent: wrapper, + }); + this.sidebar = this.wrapper.find('.layout-side-section'); + this.main_section = this.wrapper.find('.layout-main-section'); + this.wrapper.bind('show', () => { + this.show(); + }); + } + + show() { + let route = frappe.get_route(); + this.user_id = route[1] || frappe.session.user; + + //validate if user + if (route.length > 1) { + frappe.dom.freeze(__('Loading user profile') + '...'); + frappe.db.exists('User', this.user_id).then(exists => { + frappe.dom.unfreeze(); + if (exists) { + this.make_user_profile(); + } else { + frappe.msgprint(__('User does not exist')); + } + }); + } else { + frappe.set_route('user-profile', frappe.session.user); + } + } + + make_user_profile() { + this.user = frappe.user_info(this.user_id); + this.page.set_title(this.user.fullname); + this.setup_user_search(); + this.main_section.empty().append(frappe.render_template('user_profile')); + this.energy_points = 0; + this.review_points = 0; + this.rank = 0; + this.month_rank = 0; + this.render_user_details(); + this.render_points_and_rank(); + this.render_heatmap(); + this.render_line_chart(); + this.render_percentage_chart('type', 'Type Distribution'); + this.create_percentage_chart_filters(); + this.setup_user_activity_timeline(); + } + + setup_user_search() { + this.$user_search_button = this.page.set_secondary_action( + __('Change User'), + () => this.show_user_search_dialog(), + { icon: 'change', size: 'sm' } + ); + } + + show_user_search_dialog() { + let dialog = new frappe.ui.Dialog({ + title: __('Change User'), + fields: [ + { + fieldtype: 'Link', + fieldname: 'user', + options: 'User', + label: __('User'), + } + ], + primary_action_label: __('Go'), + primary_action: ({ user }) => { + dialog.hide(); + this.user_id = user; + this.make_user_profile(); + } + }); + dialog.show(); + } + + render_heatmap() { + this.heatmap = new frappe.Chart('.performance-heatmap', { + type: 'heatmap', + countLabel: 'Energy Points', + data: {}, + discreteDomains: 1, + radius: 3, + height: 150 + }); + this.update_heatmap_data(); + this.create_heatmap_chart_filters(); + } + + update_heatmap_data(date_from) { + frappe.xcall('frappe.desk.page.user_profile.user_profile.get_energy_points_heatmap_data', { + user: this.user_id, + date: date_from || frappe.datetime.year_start(), + }).then((r) => { + this.heatmap.update({ dataPoints: r }); + }); + } + + + render_line_chart() { + this.line_chart_filters = [ + ['Energy Point Log', 'user', '=', this.user_id, false], + ['Energy Point Log', 'type', '!=', 'Review', false] + ]; + + this.line_chart_config = { + timespan: 'Last Month', + time_interval: 'Daily', + type: 'Line', + value_based_on: 'points', + chart_type: 'Sum', + document_type: 'Energy Point Log', + name: 'Energy Points', + width: 'half', + based_on: 'creation' + }; + + this.line_chart = new frappe.Chart('.performance-line-chart', { + type: 'line', + height: 200, + data: { + labels: [], + datasets: [{}] + }, + colors: ['purple'], + axisOptions: { + xIsSeries: 1 + } + }); + this.update_line_chart_data(); + this.create_line_chart_filters(); + } + + update_line_chart_data() { + this.line_chart_config.filters_json = JSON.stringify(this.line_chart_filters); + + frappe.xcall('frappe.desk.doctype.dashboard_chart.dashboard_chart.get', { + chart: this.line_chart_config, + no_cache: 1, + }).then(chart => { + this.line_chart.update(chart); + }); + } + + // eslint-disable-next-line no-unused-vars + render_percentage_chart(field, title) { + frappe.xcall('frappe.desk.page.user_profile.user_profile.get_energy_points_percentage_chart_data', { + user: this.user_id, + field: field + }).then(chart => { + if (chart.labels.length) { + this.percentage_chart = new frappe.Chart('.performance-percentage-chart', { + type: 'percentage', + data: { + labels: chart.labels, + datasets: chart.datasets + }, + truncateLegends: 1, + barOptions: { + height: 11, + depth: 1 + }, + height: 200, + maxSlices: 8, + colors: ['purple', 'blue', 'cyan', 'teal', 'pink', 'red', 'orange', 'yellow'], + }); + } else { + this.wrapper.find('.percentage-chart-container').hide(); + } + }); + } + + create_line_chart_filters() { + let filters = [ + { + label: 'All', + options: ['All', 'Auto', 'Criticism', 'Appreciation', 'Revert'], + action: (selected_item) => { + if (selected_item === 'All') { + this.line_chart_filters = [ + ['Energy Point Log', 'user', '=', this.user_id, false], + ['Energy Point Log', 'type', '!=', 'Review', false] + ]; + } else { + this.line_chart_filters[1] = ['Energy Point Log', 'type', '=', selected_item, false]; + } + this.update_line_chart_data(); + } + }, + { + label: 'Last Month', + options: ['Last Week', 'Last Month', 'Last Quarter', 'Last Year'], + action: (selected_item) => { + this.line_chart_config.timespan = selected_item; + this.update_line_chart_data(); + } + }, + { + label: 'Daily', + options: ['Daily', 'Weekly', 'Monthly'], + action: (selected_item) => { + this.line_chart_config.time_interval = selected_item; + this.update_line_chart_data(); + } + }, + ]; + frappe.dashboard_utils.render_chart_filters(filters, 'chart-filter', '.line-chart-options', 1); + } + + create_percentage_chart_filters() { + let filters = [ + { + label: 'Type', + options: ['Type', 'Reference Doctype', 'Rule'], + fieldnames: ['type', 'reference_doctype', 'rule'], + action: (selected_item, fieldname) => { + let title = selected_item + ' Distribution'; + this.render_percentage_chart(fieldname, title); + } + }, + ]; + frappe.dashboard_utils.render_chart_filters(filters, 'chart-filter', '.percentage-chart-options'); + } + + create_heatmap_chart_filters() { + let filters = [ + { + label: frappe.dashboard_utils.get_year(frappe.datetime.now_date()), + options: frappe.dashboard_utils.get_years_since_creation(frappe.boot.user.creation), + action: (selected_item) => { + this.update_heatmap_data(frappe.datetime.obj_to_str(selected_item)); + } + }, + ]; + frappe.dashboard_utils.render_chart_filters(filters, 'chart-filter', '.heatmap-options'); + } + + + edit_profile() { + let edit_profile_dialog = new frappe.ui.Dialog({ + title: __('Edit Profile'), + fields: [ + { + fieldtype: 'Attach Image', + fieldname: 'user_image', + label: 'Profile Image', + }, + { + fieldtype: 'Data', + fieldname: 'interest', + label: 'Interests', + }, + { + fieldtype: 'Column Break' + }, + { + fieldtype: 'Data', + fieldname: 'location', + label: 'Location', + }, + { + fieldtype: 'Section Break', + fieldname: 'Interest', + }, + { + fieldtype: 'Small Text', + fieldname: 'bio', + label: 'Bio', + } + ], + primary_action: values => { + edit_profile_dialog.disable_primary_action(); + frappe.xcall('frappe.desk.page.user_profile.user_profile.update_profile_info', { + profile_info: values + }).then(user => { + user.image = user.user_image; + this.user = Object.assign(values, user); + edit_profile_dialog.hide(); + this.render_user_details(); + }).finally(() => { + edit_profile_dialog.enable_primary_action(); + }); + }, + primary_action_label: __('Save') + }); + + edit_profile_dialog.set_values({ + user_image: this.user.image, + location: this.user.location, + interest: this.user.interest, + bio: this.user.bio + }); + edit_profile_dialog.show(); + } + + render_user_details() { + this.sidebar.empty().append(frappe.render_template('user_profile_sidebar', { + user_image: this.user.image, + user_abbr: this.user.abbr, + user_location: this.user.location, + user_interest: this.user.interest, + user_bio: this.user.bio, + })); + + this.setup_user_profile_links(); + } + + setup_user_profile_links() { + if (this.user_id !== frappe.session.user) { + this.wrapper.find('.profile-links').hide(); + } else { + this.wrapper.find('.edit-profile-link').on('click', () => { + this.edit_profile(); + }); + + this.wrapper.find('.user-settings-link').on('click', () => { + this.go_to_user_settings(); + }); + } + } + + get_user_rank() { + return frappe.xcall('frappe.desk.page.user_profile.user_profile.get_user_rank', { + user: this.user_id, + }).then(r => { + if (r.monthly_rank.length) this.month_rank = r.monthly_rank[0]; + if (r.all_time_rank.length) this.rank = r.all_time_rank[0]; + }); + } + + get_user_points() { + return frappe.xcall( + 'frappe.social.doctype.energy_point_log.energy_point_log.get_user_energy_and_review_points', + { + user: this.user_id, + } + ).then(r => { + if (r[this.user_id]) { + this.energy_points = r[this.user_id].energy_points; + this.review_points = r[this.user_id].review_points; + } + }); + } + + render_points_and_rank() { + let $profile_details = this.wrapper.find('.user-stats'); + let $profile_details_wrapper = this.wrapper.find('.user-stats-detail'); + + const _get_stat_dom = (value, label, icon) => { + return `
        + ${frappe.utils.icon(icon, "lg", "no-stroke")} +
        +
        ${value}
        +
        ${label}
        +
        +
        `; + }; + + this.get_user_rank().then(() => { + this.get_user_points().then(() => { + let html = $(` + ${_get_stat_dom(this.energy_points, __('Energy Points'), "color-energy-points")} + ${_get_stat_dom(this.review_points, __('Review Points'), "color-review-points")} + ${_get_stat_dom(this.rank, __('Rank'), "color-rank")} + ${_get_stat_dom(this.month_rank, __('Monthly Rank'), "color-monthly-rank")} + `); + + $profile_details.append(html); + $profile_details_wrapper.removeClass("hide"); + }); + }); + } + + go_to_user_settings() { + frappe.set_route('Form', 'User', this.user_id); + } + + setup_user_activity_timeline() { + this.user_activity_timeline = new UserProfileTimeline({ + parent: this.wrapper.find('.recent-activity-list'), + footer: this.wrapper.find('.recent-activity-footer'), + user: this.user_id + }); + + this.user_activity_timeline.refresh(); + } +} + +class UserProfileTimeline extends BaseTimeline { + make() { + super.make(); + this.activity_start = 0; + this.activity_limit = 20; + this.setup_show_more_activity(); + } + prepare_timeline_contents() { + return this.get_user_activity_data().then((activities) => { + if (!activities.length) { + this.show_more_button.hide(); + this.timeline_wrapper.html(`
        ${__('No activities to show')}
        `); + return; + } + this.show_more_button.toggle(activities.length === this.activity_limit); + this.timeline_items = activities.map((activity) => this.get_activity_timeline_item(activity)); + }); + } + + get_user_activity_data() { + return frappe.xcall('frappe.desk.page.user_profile.user_profile.get_energy_points_list', { + start: this.activity_start, + limit: this.activity_limit, + user: this.user + }); + } + + get_activity_timeline_item(data) { + let icon = data.type == 'Appreciation' ? 'clap': data.type == 'Criticism' ? 'criticize': null; + return { + icon: icon, + creation: data.creation, + is_card: true, + content: frappe.energy_points.format_history_log(data), + }; + } + + setup_show_more_activity() { + this.show_more_button = $(`${__('Show More Activity')}`); + this.show_more_button.hide(); + this.footer.append(this.show_more_button); + this.show_more_button.on('click', () => this.show_more_activity()); + } + + show_more_activity() { + this.activity_start += this.activity_limit; + this.get_user_activity_data().then(activities => { + if (!activities.length || activities.length < this.activity_limit) { + this.show_more_button.hide(); + } + let timeline_items = activities.map((activity) => this.get_activity_timeline_item(activity)); + timeline_items.map((item) => this.add_timeline_item(item, true)); + }); + } +} + +frappe.provide('frappe.ui'); +frappe.ui.UserProfile = UserProfile; diff --git a/frappe/desk/page/user_profile/user_profile_sidebar.html b/frappe/desk/page/user_profile/user_profile_sidebar.html index 77dae5edd0..4a35c6cf9c 100644 --- a/frappe/desk/page/user_profile/user_profile_sidebar.html +++ b/frappe/desk/page/user_profile/user_profile_sidebar.html @@ -1,23 +1,60 @@ \ No newline at end of file diff --git a/frappe/desk/query_report.py b/frappe/desk/query_report.py index 5a9aae8435..3008cf0e61 100644 --- a/frappe/desk/query_report.py +++ b/frappe/desk/query_report.py @@ -12,6 +12,7 @@ from frappe.modules import scrub, get_module_path from frappe.utils import ( flt, cint, + cstr, get_html_format, get_url_to_form, gzip_decompress, @@ -74,23 +75,27 @@ def generate_report_result(report, filters=None, user=None, custom_columns=None) res = report.execute_script_report(filters) columns, result, message, chart, report_summary, skip_total_row = ljust_list(res, 6) + columns = [get_column_as_dict(col) for col in columns] + report_column_names = [col["fieldname"] for col in columns] + + # convert to list of dicts + result = normalize_result(result, columns) if report.custom_columns: - # Original query columns, needed to reorder data as per custom columns - query_columns = columns - # Reordered columns + # saved columns (with custom columns / with different column order) columns = json.loads(report.custom_columns) - result = reorder_data_for_custom_columns(columns, query_columns, result) - - result = add_data_to_custom_columns(columns, result) - + # unsaved custom_columns if custom_columns: - result = add_data_to_custom_columns(custom_columns, result) - for custom_column in custom_columns: columns.insert(custom_column["insert_after_index"] + 1, custom_column) + # all columns which are not in original report + report_custom_columns = [column for column in columns if column["fieldname"] not in report_column_names] + + if report_custom_columns: + result = add_custom_column_data(report_custom_columns, result) + if result: result = get_filtered_data(report.ref_doctype, columns, result, user) @@ -109,6 +114,20 @@ def generate_report_result(report, filters=None, user=None, custom_columns=None) or 0, } +def normalize_result(result, columns): + # Converts to list of dicts from list of lists/tuples + data = [] + column_names = [column["fieldname"] for column in columns] + if result and isinstance(result[0], (list, tuple)): + for row in result: + row_obj = {} + for idx, column_name in enumerate(column_names): + row_obj[column_name] = row[idx] + data.append(row_obj) + else: + data = result + + return data @frappe.whitelist() def background_enqueue_run(report_name, filters=None, user=None): @@ -177,14 +196,7 @@ def get_script(report_name): @frappe.whitelist() @frappe.read_only() -def run( - report_name, - filters=None, - user=None, - ignore_prepared_report=False, - custom_columns=None, -): - +def run(report_name, filters=None, user=None, ignore_prepared_report=False, custom_columns=None): report = get_report_doc(report_name) if not user: user = frappe.session.user @@ -221,69 +233,20 @@ def run( return result -def add_data_to_custom_columns(columns, result): - custom_fields_data = get_data_for_custom_report(columns) +def add_custom_column_data(custom_columns, result): + custom_column_data = get_data_for_custom_report(custom_columns) - data = [] - for row in result: - row_obj = {} - if isinstance(row, tuple): - row = list(row) + for column in custom_columns: + key = (column.get('doctype'), column.get('fieldname')) + if key in custom_column_data: + for row in result: + row_reference = row.get(column.get('link_field')) + # possible if the row is empty + if not row_reference: + continue + row[column.get('fieldname')] = custom_column_data.get(key).get(row_reference) - if isinstance(row, list): - for idx, column in enumerate(columns): - if column.get("link_field"): - row_obj[column["fieldname"]] = None - row.insert(idx, None) - else: - row_obj[column["fieldname"]] = row[idx] - data.append(row_obj) - else: - data.append(row) - - for row in data: - for column in columns: - if column.get("link_field"): - fieldname = column["fieldname"] - key = (column["doctype"], fieldname) - link_field = column["link_field"] - row[fieldname] = custom_fields_data.get(key, {}).get( - row.get(link_field) - ) - - return data - - -def reorder_data_for_custom_columns(custom_columns, columns, result): - if not result: - return [] - - columns = [get_column_as_dict(col) for col in columns] - if isinstance(result[0], list) or isinstance(result[0], tuple): - # If the result is a list of lists - custom_column_names = [col["label"] for col in custom_columns] - original_column_names = [col["label"] for col in columns] - return get_columns_from_list(custom_column_names, original_column_names, result) - else: - # columns do not need to be reordered if result is a list of dicts - return result - - -def get_columns_from_list(columns, target_columns, result): - reordered_result = [] - - for res in result: - r = [] - for col_name in columns: - try: - idx = target_columns.index(col_name) - r.append(res[idx]) - except ValueError: - pass - - reordered_result.append(r) - - return reordered_result + return result def get_prepared_report_result(report, filters, dn="", user=None): @@ -343,31 +306,27 @@ def get_prepared_report_result(report, filters, dn="", user=None): @frappe.whitelist() def export_query(): """export from query reports""" - data = frappe._dict(frappe.local.form_dict) - - del data["cmd"] - if "csrf_token" in data: - del data["csrf_token"] + data.pop("cmd", None) + data.pop("csrf_token", None) if isinstance(data.get("filters"), string_types): filters = json.loads(data["filters"]) - if isinstance(data.get("report_name"), string_types): + + if data.get("report_name"): report_name = data["report_name"] frappe.permissions.can_export( frappe.get_cached_value("Report", report_name, "ref_doctype"), raise_exception=True, ) - if isinstance(data.get("file_format_type"), string_types): - file_format_type = data["file_format_type"] - custom_columns = frappe.parse_json(data["custom_columns"]) + file_format_type = data.get("file_format_type") + custom_columns = frappe.parse_json(data.get("custom_columns", "[]")) + include_indentation = data.get("include_indentation") + visible_idx = data.get("visible_idx") - include_indentation = data["include_indentation"] - if isinstance(data.get("visible_idx"), string_types): - visible_idx = json.loads(data.get("visible_idx")) - else: - visible_idx = None + if isinstance(visible_idx, string_types): + visible_idx = json.loads(visible_idx) if file_format_type == "Excel": data = run(report_name, filters, custom_columns=custom_columns) @@ -386,8 +345,8 @@ def export_query(): data["result"] = handle_duration_fieldtype_values( data.get("result"), data.get("columns") ) - xlsx_data = build_xlsx_data(columns, data, visible_idx, include_indentation) - xlsx_file = make_xlsx(xlsx_data, "Query Report") + xlsx_data, column_widths = build_xlsx_data(columns, data, visible_idx, include_indentation) + xlsx_file = make_xlsx(xlsx_data, "Query Report", column_widths=column_widths) frappe.response["filename"] = report_name + ".xlsx" frappe.response["filecontent"] = xlsx_file.getvalue() @@ -421,34 +380,38 @@ def handle_duration_fieldtype_values(result, columns): def build_xlsx_data(columns, data, visible_idx, include_indentation): result = [[]] + column_widths = [] - # add column headings - for idx in range(len(data.columns)): - if not columns[idx].get("hidden"): - result[0].append(columns[idx]["label"]) + for column in data.columns: + if column.get("hidden"): + continue + result[0].append(column["label"]) + column_width = cint(column.get('width', 0)) + # to convert into scale accepted by openpyxl + column_width /= 10 + column_widths.append(column_width) # build table from result - for i, row in enumerate(data.result): + for row_idx, row in enumerate(data.result): # only pick up rows that are visible in the report - if i in visible_idx: + if row_idx in visible_idx: row_data = [] - - if isinstance(row, dict) and row: - for idx in range(len(data.columns)): - # check if column is not hidden - if not columns[idx].get("hidden"): - label = columns[idx]["label"] - fieldname = columns[idx]["fieldname"] - cell_value = row.get(fieldname, row.get(label, "")) - if cint(include_indentation) and "indent" in row and idx == 0: - cell_value = (" " * cint(row["indent"])) + cell_value - row_data.append(cell_value) - else: + if isinstance(row, dict): + for col_idx, column in enumerate(data.columns): + if column.get("hidden"): + continue + label = column.get("label") + fieldname = column.get("fieldname") + cell_value = row.get(fieldname, row.get(label, "")) + if cint(include_indentation) and "indent" in row and col_idx == 0: + cell_value = (" " * cint(row["indent"])) + cstr(cell_value) + row_data.append(cell_value) + elif row: row_data = row result.append(row_data) - return result + return result, column_widths def add_total_row(result, columns, meta=None): @@ -755,6 +718,8 @@ def get_column_as_dict(col): col_dict["fieldtype"], col_dict["options"] = col[1].split("/") else: col_dict["fieldtype"] = col[1] + if len(col) == 3: + col_dict["width"] = col[2] col_dict["label"] = col[0] col_dict["fieldname"] = frappe.scrub(col[0]) diff --git a/frappe/desk/report/todo/todo.py b/frappe/desk/report/todo/todo.py index a51d44fe08..f4fe2dc805 100644 --- a/frappe/desk/report/todo/todo.py +++ b/frappe/desk/report/todo/todo.py @@ -24,7 +24,7 @@ def execute(filters=None): for todo in todo_list: if todo.owner==frappe.session.user or todo.assigned_by==frappe.session.user: if todo.reference_type: - todo.reference = """%s: %s""" % (todo.reference_type, + todo.reference = """%s: %s""" % (todo.reference_type, todo.reference_name, todo.reference_type, todo.reference_name) else: todo.reference = None diff --git a/frappe/desk/reportview.py b/frappe/desk/reportview.py index 9f5a5d84c8..3003385601 100644 --- a/frappe/desk/reportview.py +++ b/frappe/desk/reportview.py @@ -14,7 +14,7 @@ from frappe.core.doctype.access_log.access_log import make_access_log from frappe.utils import cstr, format_duration -@frappe.whitelist() +@frappe.whitelist(allow_guest=True) @frappe.read_only() def get(): args = get_form_params() @@ -54,6 +54,12 @@ def get_form_params(): fields = data["fields"] + if ((isinstance(fields, string_types) and fields == "*") + or (isinstance(fields, (list, tuple)) and len(fields) == 1 and fields[0] == "*")): + parenttype = data.doctype + data["fields"] = frappe.db.get_table_columns(parenttype) + fields = data["fields"] + for field in fields: key = field.split(" as ")[0] @@ -61,21 +67,24 @@ def get_form_params(): if key.startswith('sum('): continue if key.startswith('avg('): continue - if "." in key: - parenttype, fieldname = key.split(".")[0][4:-1], key.split(".")[1].strip("`") - else: - parenttype = data.doctype - fieldname = field.strip("`") + parenttype, fieldname = get_parent_dt_and_field(key, data) - df = frappe.get_meta(parenttype).get_field(fieldname) + if fieldname == "*": + # * inside list is not allowed with other fields + fields.remove(field) + + meta = frappe.get_meta(parenttype) + df = meta.get_field(fieldname) - fieldname = df.fieldname if df else None report_hide = df.report_hide if df else None # remove the field from the query if the report hide flag is set and current view is Report if report_hide and is_report: fields.remove(field) + if df and fieldname in [df.fieldname for df in meta.get_high_permlevel_fields()]: + if df.get('permlevel') not in meta.get_permlevel_access(parenttype=data.doctype) and field in fields: + fields.remove(field) # queries must always be server side data.query = None @@ -83,6 +92,16 @@ def get_form_params(): return data +def get_parent_dt_and_field(field, data): + if "." in field: + parenttype, fieldname = field.split(".")[0][4:-1], field.split(".")[1].strip("`") + else: + parenttype = data.doctype + fieldname = field.strip("`") + + return parenttype, fieldname + + def compress(data, args = {}): """separate keys and values""" from frappe.desk.query_report import add_total_row diff --git a/frappe/desk/search.py b/frappe/desk/search.py index 798e499bb9..6faa827dde 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -80,13 +80,15 @@ def search_widget(doctype, txt, query=None, searchfield=None, start=0, is_whitelisted(frappe.get_attr(query)) frappe.response["values"] = frappe.call(query, doctype, txt, searchfield, start, page_length, filters, as_dict=as_dict) - except Exception as e: + except frappe.exceptions.PermissionError as e: if frappe.local.conf.developer_mode: raise e else: frappe.respond_as_web_page(title='Invalid Method', html='Method not found', indicator_color='red', http_status_code=404) return + except Exception as e: + raise e elif not query and doctype in standard_queries: # from standard queries search_widget(doctype, txt, standard_queries[doctype][0], @@ -141,7 +143,7 @@ def search_widget(doctype, txt, query=None, searchfield=None, start=0, # find relevance as location of search term from the beginning of string `name`. used for sorting results. formatted_fields.append("""locate({_txt}, `tab{doctype}`.`name`) as `_relevance`""".format( - _txt=frappe.db.escape((txt or "").replace("%", "")), doctype=doctype)) + _txt=frappe.db.escape((txt or "").replace("%", "").replace("@", "")), doctype=doctype)) # In order_by, `idx` gets second priority, because it stores link count @@ -150,7 +152,8 @@ def search_widget(doctype, txt, query=None, searchfield=None, start=0, # 2 is the index of _relevance column order_by = "_relevance, {0}, `tab{1}`.idx desc".format(order_by_based_on_meta, doctype) - ignore_permissions = True if doctype == "DocType" else (cint(ignore_user_permissions) and has_permission(doctype)) + ptype = 'select' if frappe.only_has_select_perm(doctype) else 'read' + ignore_permissions = True if doctype == "DocType" else (cint(ignore_user_permissions) and has_permission(doctype, ptype=ptype)) if doctype in UNTRANSLATED_DOCTYPES: page_length = None diff --git a/frappe/desk/treeview.py b/frappe/desk/treeview.py index 811143be03..e0b6ca240a 100644 --- a/frappe/desk/treeview.py +++ b/frappe/desk/treeview.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt +# MIT License. See license.txt from __future__ import unicode_literals import frappe diff --git a/frappe/desk/utils.py b/frappe/desk/utils.py new file mode 100644 index 0000000000..01b47ac106 --- /dev/null +++ b/frappe/desk/utils.py @@ -0,0 +1,23 @@ +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +import frappe + +def validate_route_conflict(doctype, name): + ''' + Raises exception if name clashes with routes from other documents for /app routing + ''' + + all_names = [] + for _doctype in ['Page', 'Workspace', 'DocType']: + try: + all_names.extend([slug(d) for d in frappe.get_all(_doctype, pluck='name') if (doctype != _doctype and d != name)]) + except frappe.db.TableMissingError: + pass + + if slug(name) in all_names: + frappe.msgprint(frappe._('Name already taken, please set a new name')) + raise frappe.NameError + +def slug(name): + return name.lower().replace(' ', '-') \ No newline at end of file diff --git a/frappe/email/doctype/auto_email_report/auto_email_report.js b/frappe/email/doctype/auto_email_report/auto_email_report.js index 1b91e7a38c..3423c3ccba 100644 --- a/frappe/email/doctype/auto_email_report/auto_email_report.js +++ b/frappe/email/doctype/auto_email_report/auto_email_report.js @@ -97,9 +97,18 @@ frappe.ui.form.on('Auto Email Report', { }) report_filters = report_filters_list; - report_filters.forEach(function(f) { - $('' + f.label + ''+ frappe.format(filters[f.fieldname], f) +'') - .appendTo(table.find('tbody')); + const mandatory_css = { + "background-color": "var(--error-bg)", + "font-weight": "bold" + }; + + report_filters.forEach(f => { + const css = f.reqd ? mandatory_css : {}; + const row = $("").appendTo(table.find("tbody")); + $("" + f.label + "").appendTo(row); + $("" + frappe.format(filters[f.fieldname], f) +"") + .css(css) + .appendTo(row); }); table.on('click', function() { diff --git a/frappe/email/doctype/auto_email_report/auto_email_report.json b/frappe/email/doctype/auto_email_report/auto_email_report.json index 8067566ece..211e2e9662 100644 --- a/frappe/email/doctype/auto_email_report/auto_email_report.json +++ b/frappe/email/doctype/auto_email_report/auto_email_report.json @@ -147,6 +147,7 @@ "label": "Email Settings" }, { + "description": "For multiple addresses, enter the address on different line. e.g. test@test.com \u23ce test1@test.com", "fieldname": "email_to", "fieldtype": "Small Text", "label": "Email To", @@ -200,7 +201,7 @@ "read_only": 1 } ], - "modified": "2019-05-09 22:38:27.570890", + "modified": "2021-01-28 15:59:43.151995", "modified_by": "Administrator", "module": "Email", "name": "Auto Email Report", diff --git a/frappe/email/doctype/auto_email_report/auto_email_report.py b/frappe/email/doctype/auto_email_report/auto_email_report.py index 539f6c9db8..d82caa7bd4 100644 --- a/frappe/email/doctype/auto_email_report/auto_email_report.py +++ b/frappe/email/doctype/auto_email_report/auto_email_report.py @@ -29,6 +29,7 @@ class AutoEmailReport(Document): self.validate_report_count() self.validate_emails() self.validate_report_format() + self.validate_mandatory_fields() def validate_emails(self): '''Cleanup list of emails''' @@ -56,6 +57,21 @@ class AutoEmailReport(Document): frappe.throw(_("{0} is not a valid report format. Report format should one of the following {1}") .format(frappe.bold(self.format), frappe.bold(", ".join(valid_report_formats)))) + def validate_mandatory_fields(self): + # Check if all Mandatory Report Filters are filled by the User + filters = frappe.parse_json(self.filters) if self.filters else {} + filter_meta = frappe.parse_json(self.filter_meta) if self.filter_meta else {} + throw_list = [] + for meta in filter_meta: + if meta.get("reqd") and not filters.get(meta["fieldname"]): + throw_list.append(meta['label']) + if throw_list: + frappe.throw( + title= _('Missing Filters Required'), + msg= _('Following Report Filters have missing values:') + + '

        • ' + '
        • '.join(throw_list) + '
        ', + ) + def get_report_content(self): '''Returns file in for the report in given format''' report = frappe.get_doc('Report', self.report) @@ -81,7 +97,7 @@ class AutoEmailReport(Document): if self.format == 'HTML': columns, data = make_links(columns, data) - + columns = update_field_types(columns) return self.get_html_table(columns, data) elif self.format == 'XLSX': @@ -236,5 +252,14 @@ def make_links(columns, data): elif col.fieldtype == "Dynamic Link": if col.options and row.get(col.fieldname) and row.get(col.options): row[col.fieldname] = get_link_to_form(row[col.options], row[col.fieldname]) + elif col.fieldtype == "Currency": + row[col.fieldname] = frappe.format_value(row[col.fieldname], col) return columns, data + +def update_field_types(columns): + for col in columns: + if col.fieldtype in ("Link", "Dynamic Link", "Currency") and col.options != "Currency": + col.fieldtype = "Data" + col.options = "" + return columns \ No newline at end of file diff --git a/frappe/email/doctype/document_follow/test_document_follow.py b/frappe/email/doctype/document_follow/test_document_follow.py index 1208a6c5c1..1ac2d19305 100644 --- a/frappe/email/doctype/document_follow/test_document_follow.py +++ b/frappe/email/doctype/document_follow/test_document_follow.py @@ -8,15 +8,15 @@ import unittest import frappe.desk.form.document_follow as document_follow class TestDocumentFollow(unittest.TestCase): - - def test_add_subscription_and_send_mail(self): + def test_document_follow(self): user = get_user() event_doc = get_event() event_doc.description = "This is a test description for sending mail" event_doc.save(ignore_version=False) - doc = document_follow.follow_document("Event", event_doc.name , user.name, force=True) + document_follow.unfollow_document("Event", event_doc.name, user.name) + doc = document_follow.follow_document("Event", event_doc.name, user.name) self.assertEquals(doc.user, user.name) document_follow.send_hourly_updates() @@ -45,12 +45,15 @@ def get_event(): return doc def get_user(): - doc = frappe.new_doc("User") - doc.email = "test@docsub.com" - doc.first_name = "Test" - doc.last_name = "User" - doc.send_welcome_email = 0 - doc.document_follow_notify = 1 - doc.document_follow_frequency = "Hourly" - doc.insert() + if frappe.db.exists('User', 'test@docsub.com'): + doc = frappe.get_doc('User', 'test@docsub.com') + else: + doc = frappe.new_doc("User") + doc.email = "test@docsub.com" + doc.first_name = "Test" + doc.last_name = "User" + doc.send_welcome_email = 0 + doc.document_follow_notify = 1 + doc.document_follow_frequency = "Hourly" + doc.insert() return doc \ No newline at end of file diff --git a/frappe/email/doctype/email_account/email_account.json b/frappe/email/doctype/email_account/email_account.json index 057638697a..6d811b801f 100644 --- a/frappe/email/doctype/email_account/email_account.json +++ b/frappe/email/doctype/email_account/email_account.json @@ -56,6 +56,7 @@ "auto_reply_message", "set_footer", "footer", + "brand_logo", "uidvalidity", "uidnext", "no_remaining", @@ -65,6 +66,8 @@ { "fieldname": "email_id", "fieldtype": "Data", + "hide_days": 1, + "hide_seconds": 1, "in_global_search": 1, "in_list_view": 1, "label": "Email Address", @@ -75,46 +78,62 @@ "default": "0", "fieldname": "login_id_is_different", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "label": "Use Different Email Login ID" }, { "depends_on": "login_id_is_different", "fieldname": "login_id", "fieldtype": "Data", + "hide_days": 1, + "hide_seconds": 1, "label": "Email Login ID" }, { "fieldname": "password", "fieldtype": "Password", + "hide_days": 1, + "hide_seconds": 1, "label": "Password" }, { "default": "0", "fieldname": "awaiting_password", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "label": "Awaiting password" }, { "default": "0", "fieldname": "ascii_encode_password", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "label": "Use ASCII encoding for password" }, { "description": "e.g. \"Support\", \"Sales\", \"Jerry Yang\"", "fieldname": "email_account_name", "fieldtype": "Data", + "hide_days": 1, + "hide_seconds": 1, "label": "Email Account Name", "unique": 1 }, { "fieldname": "email_settings", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "hide_days": 1, + "hide_seconds": 1 }, { "depends_on": "eval:!doc.service", "fieldname": "domain", "fieldtype": "Link", + "hide_days": 1, + "hide_seconds": 1, "in_list_view": 1, "in_standard_filter": 1, "label": "Domain", @@ -124,18 +143,24 @@ "depends_on": "eval:!doc.domain", "fieldname": "service", "fieldtype": "Select", + "hide_days": 1, + "hide_seconds": 1, "label": "Service", "options": "\nGMail\nSendgrid\nSparkPost\nYahoo Mail\nOutlook.com\nYandex.Mail" }, { "fieldname": "mailbox_settings", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "hide_days": 1, + "hide_seconds": 1 }, { "default": "0", "description": "Check this to pull emails from your mailbox", "fieldname": "enable_incoming", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "label": "Enable Incoming" }, { @@ -144,6 +169,8 @@ "fetch_from": "domain.use_imap", "fieldname": "use_imap", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "label": "Use IMAP" }, { @@ -152,6 +179,8 @@ "fetch_from": "domain.email_server", "fieldname": "email_server", "fieldtype": "Data", + "hide_days": 1, + "hide_seconds": 1, "label": "Email Server" }, { @@ -160,6 +189,8 @@ "fetch_from": "domain.use_ssl", "fieldname": "use_ssl", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "label": "Use SSL" }, { @@ -169,6 +200,8 @@ "fetch_from": "domain.attachment_limit", "fieldname": "attachment_limit", "fieldtype": "Int", + "hide_days": 1, + "hide_seconds": 1, "label": "Attachment Limit (MB)" }, { @@ -176,6 +209,8 @@ "description": "Append as communication against this DocType (must have fields, \"Status\", \"Subject\")", "fieldname": "append_to", "fieldtype": "Link", + "hide_days": 1, + "hide_seconds": 1, "in_standard_filter": 1, "label": "Append To", "options": "DocType" @@ -186,6 +221,8 @@ "description": "e.g. replies@yourcomany.com. All replies will come to this inbox.", "fieldname": "default_incoming", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "label": "Default Incoming" }, { @@ -193,6 +230,8 @@ "depends_on": "eval: doc.enable_incoming", "fieldname": "email_sync_option", "fieldtype": "Select", + "hide_days": 1, + "hide_seconds": 1, "label": "Email Sync Option", "options": "ALL\nUNSEEN" }, @@ -201,18 +240,24 @@ "description": "Total number of emails to sync in initial sync process ", "fieldname": "initial_sync_count", "fieldtype": "Select", + "hide_days": 1, + "hide_seconds": 1, "label": "Initial Sync Count", "options": "100\n250\n500" }, { "depends_on": "enable_incoming", "fieldname": "section_break_13", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "hide_days": 1, + "hide_seconds": 1 }, { "default": "0", "fieldname": "notify_if_unreplied", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "label": "Notify if unreplied" }, { @@ -220,6 +265,8 @@ "depends_on": "notify_if_unreplied", "fieldname": "unreplied_for_mins", "fieldtype": "Int", + "hide_days": 1, + "hide_seconds": 1, "label": "Notify if unreplied for (in mins)" }, { @@ -227,17 +274,23 @@ "description": "Email Addresses", "fieldname": "send_notification_to", "fieldtype": "Small Text", + "hide_days": 1, + "hide_seconds": 1, "label": "Send Notification to" }, { "fieldname": "outgoing_mail_settings", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "hide_days": 1, + "hide_seconds": 1 }, { "default": "0", "description": "SMTP Settings for outgoing emails", "fieldname": "enable_outgoing", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "label": "Enable Outgoing" }, { @@ -246,6 +299,8 @@ "fetch_from": "domain.smtp_server", "fieldname": "smtp_server", "fieldtype": "Data", + "hide_days": 1, + "hide_seconds": 1, "label": "SMTP Server" }, { @@ -254,6 +309,8 @@ "fetch_from": "domain.use_tls", "fieldname": "use_tls", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "label": "Use TLS" }, { @@ -262,6 +319,8 @@ "fetch_from": "domain.smtp_port", "fieldname": "smtp_port", "fieldtype": "Data", + "hide_days": 1, + "hide_seconds": 1, "label": "Port" }, { @@ -270,6 +329,8 @@ "description": "Notifications and bulk mails will be sent from this outgoing server.", "fieldname": "default_outgoing", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "label": "Default Outgoing" }, { @@ -278,6 +339,8 @@ "description": "Uses the Email Address mentioned in this Account as the Sender for all emails sent using this Account. ", "fieldname": "always_use_account_email_id_as_sender", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "label": "Always use Account's Email Address as Sender" }, { @@ -286,12 +349,16 @@ "description": "Uses the Email Address Name mentioned in this Account as the Sender's Name for all emails sent using this Account.", "fieldname": "always_use_account_name_as_sender_name", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "label": "Always use Account's Name as Sender's Name" }, { "default": "1", "fieldname": "send_unsubscribe_message", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "label": "Send unsubscribe message in email" }, { @@ -299,38 +366,52 @@ "description": "Track if your email has been opened by the recipient.\n
        \nNote: If you're sending to multiple recipients, even if 1 recipient reads the email, it'll be considered \"Opened\"", "fieldname": "track_email_status", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "label": "Track Email Status" }, { "default": "0", "fieldname": "no_smtp_authentication", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "label": "Disable SMTP server authentication" }, { "fieldname": "signature_section", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "hide_days": 1, + "hide_seconds": 1 }, { "default": "0", "fieldname": "add_signature", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "label": "Add Signature" }, { "depends_on": "add_signature", "fieldname": "signature", "fieldtype": "Text Editor", + "hide_days": 1, + "hide_seconds": 1, "label": "Signature" }, { "fieldname": "auto_reply", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "hide_days": 1, + "hide_seconds": 1 }, { "default": "0", "fieldname": "enable_auto_reply", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "label": "Enable Auto Reply" }, { @@ -338,21 +419,29 @@ "description": "ProTip: Add Reference: {{ reference_doctype }} {{ reference_name }} to send document reference", "fieldname": "auto_reply_message", "fieldtype": "Text Editor", + "hide_days": 1, + "hide_seconds": 1, "label": "Auto Reply Message" }, { "fieldname": "set_footer", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "hide_days": 1, + "hide_seconds": 1 }, { "fieldname": "footer", "fieldtype": "Text Editor", + "hide_days": 1, + "hide_seconds": 1, "label": "Footer" }, { "fieldname": "uidvalidity", "fieldtype": "Data", "hidden": 1, + "hide_days": 1, + "hide_seconds": 1, "label": "UIDVALIDITY", "no_copy": 1 }, @@ -360,6 +449,8 @@ "fieldname": "uidnext", "fieldtype": "Int", "hidden": 1, + "hide_days": 1, + "hide_seconds": 1, "label": "UIDNEXT", "no_copy": 1 }, @@ -367,6 +458,8 @@ "fieldname": "no_remaining", "fieldtype": "Data", "hidden": 1, + "hide_days": 1, + "hide_seconds": 1, "label": "No of emails remaining to be synced", "no_copy": 1 }, @@ -374,19 +467,25 @@ "fieldname": "no_failed", "fieldtype": "Int", "hidden": 1, + "hide_days": 1, + "hide_seconds": 1, "label": "no failed attempts", "no_copy": 1, "read_only": 1 }, { "fieldname": "section_break_12", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "hide_days": 1, + "hide_seconds": 1 }, { "default": "0", "description": "For more information, click here.", "fieldname": "enable_automatic_linking", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "label": "Enable Automatic Linking in Documents" }, { @@ -394,6 +493,8 @@ "description": "If non-standard port (e.g. POP3: 995/110, IMAP: 993/143)", "fieldname": "incoming_port", "fieldtype": "Data", + "hide_days": 1, + "hide_seconds": 1, "label": "Port" }, { @@ -401,6 +502,8 @@ "depends_on": "eval:!doc.domain && doc.enable_outgoing", "fieldname": "append_emails_to_sent_folder", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "label": "Append Emails to Sent Folder" }, { @@ -408,18 +511,28 @@ "depends_on": "eval:!doc.domain && doc.enable_outgoing", "fieldname": "use_ssl_for_outgoing", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "label": "Use SSL for Outgoing" }, { "default": "1", "fieldname": "create_contact", "fieldtype": "Check", + "hide_days": 1, + "hide_seconds": 1, "label": "Create Contacts from Incoming Emails" + }, + { + "fieldname": "brand_logo", + "fieldtype": "Attach Image", + "label": "Brand Logo" } ], "icon": "fa fa-inbox", + "index_web_pages_for_search": 1, "links": [], - "modified": "2020-05-11 15:18:43.931499", + "modified": "2021-01-21 10:05:24.820597", "modified_by": "Administrator", "module": "Email", "name": "Email Account", @@ -441,4 +554,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} +} \ No newline at end of file diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index 14a3cfd9f1..4869c5a9bf 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -25,7 +25,11 @@ from frappe.core.doctype.communication.email import set_incoming_outgoing_accoun from frappe.utils.html_utils import clean_email_html from frappe.email.utils import get_port -class SentEmailInInbox(Exception): pass +class SentEmailInInbox(Exception): + pass + +class InvalidEmailCredentials(frappe.ValidationError): + pass class EmailAccount(Document): def autoname(self): @@ -86,6 +90,29 @@ class EmailAccount(Document): if self.append_to not in valid_doctypes: frappe.throw(_("Append To can be one of {0}").format(comma_or(valid_doctypes))) + def before_save(self): + messages = [] + as_list = 1 + if not self.enable_incoming and self.default_incoming: + self.default_incoming = False + messages.append(_("{} has been disabled. It can only be enabled if {} is checked.") + .format( + frappe.bold(_('Default Incoming')), + frappe.bold(_('Enable Incoming')) + ) + ) + if not self.enable_outgoing and self.default_outgoing: + self.default_outgoing = False + messages.append(_("{} has been disabled. It can only be enabled if {} is checked.") + .format( + frappe.bold(_('Default Outgoing')), + frappe.bold(_('Enable Outgoing')) + ) + ) + if messages: + if len(messages) == 1: (as_list, messages) = (0, messages[0]) + frappe.msgprint(messages, as_list= as_list, indicator='orange', title=_("Defaults Updated")) + def on_update(self): """Check there is only one default of each type.""" from frappe.core.doctype.user.user import setup_user_email_inbox @@ -148,7 +175,7 @@ class EmailAccount(Document): return None args = frappe._dict({ - "email_account":self.name, + "email_account": self.name, "host": self.email_server, "use_ssl": self.use_ssl, "username": getattr(self, "login_id", None) or self.email_id, @@ -166,23 +193,47 @@ class EmailAccount(Document): frappe.throw(_("{0} is required").format("Email Server")) email_server = EmailServer(frappe._dict(args)) + self.check_email_server_connection(email_server, in_receive) + + if not in_receive and self.use_imap: + email_server.imap.logout() + + # reset failed attempts count + self.set_failed_attempts_count(0) + + return email_server + + def check_email_server_connection(self, email_server, in_receive): + # tries to connect to email server and handles failure try: email_server.connect() except (error_proto, imaplib.IMAP4.error) as e: - e = cstr(e) - message = e.lower().replace(" ","") - if in_receive and any(map(lambda t: t in message, ['authenticationfailed', 'loginviayourwebbrowser', #abbreviated to work with both failure and failed - 'loginfailed', 'err[auth]', 'errtemporaryerror'])): #temporary error to deal with godaddy - # if called via self.receive and it leads to authentication error, disable incoming - # and send email to system manager - self.handle_incoming_connect_error( - description=_('Authentication failed while receiving emails from Email Account {0}. Message from server: {1}').format(self.name, e) - ) + message = cstr(e).lower().replace(" ","") + auth_error_codes = [ + 'authenticationfailed', + 'loginfailed', + ] + other_error_codes = [ + 'err[auth]', + 'errtemporaryerror', + 'loginviayourwebbrowser' + ] + + all_error_codes = auth_error_codes + other_error_codes + + if in_receive and any(map(lambda t: t in message, all_error_codes)): + # if called via self.receive and it leads to authentication error, + # disable incoming and send email to System Manager + error_message = _("Authentication failed while receiving emails from Email Account: {0}.").format(self.name) + error_message += "
        " + _("Message from server: {0}").format(cstr(e)) + self.handle_incoming_connect_error(description=error_message) return None + elif not in_receive and any(map(lambda t: t in message, auth_error_codes)): + self.throw_invalid_credentials_exception() else: - frappe.throw(e) + frappe.throw(cstr(e)) except socket.error: if in_receive: @@ -195,16 +246,16 @@ class EmailAccount(Document): else: frappe.cache().set_value("workers:no-internet", True) return None - else: raise - if not in_receive: - if self.use_imap: - email_server.imap.logout() - # reset failed attempts count - self.set_failed_attempts_count(0) - return email_server + @classmethod + def throw_invalid_credentials_exception(cls): + frappe.throw( + _("Incorrect email or password. Please check your login credentials."), + exc=InvalidEmailCredentials, + title=_("Invalid Credentials") + ) def handle_incoming_connect_error(self, description): if test_internet(): diff --git a/frappe/email/doctype/email_account/email_account_list.js b/frappe/email/doctype/email_account/email_account_list.js index f3bbd99e9b..5ec56fb3db 100644 --- a/frappe/email/doctype/email_account/email_account_list.js +++ b/frappe/email/doctype/email_account/email_account_list.js @@ -2,19 +2,19 @@ frappe.listview_settings["Email Account"] = { add_fields: ["default_incoming", "default_outgoing", "enable_incoming", "enable_outgoing"], get_indicator: function(doc) { if(doc.default_incoming && doc.default_outgoing) { - var color = (doc.enable_incoming && doc.enable_outgoing) ? "blue" : "darkgrey"; + var color = (doc.enable_incoming && doc.enable_outgoing) ? "blue" : "gray"; return [__("Default Sending and Inbox"), color, "default_incoming,=,Yes|default_outgoing,=,Yes"] } else if(doc.default_incoming) { - var color = doc.enable_incoming ? "blue" : "darkgrey"; + color = doc.enable_incoming ? "blue" : "gray"; return [__("Default Inbox"), color, "default_incoming,=,Yes"]; } else if(doc.default_outgoing) { - var color = doc.enable_outgoing ? "blue" : "darkgrey"; + color = doc.enable_outgoing ? "blue" : "gray"; return [__("Default Sending"), color, "default_outgoing,=,Yes"]; } else { - var color = doc.enable_incoming ? "blue" : "darkgrey"; + color = doc.enable_incoming ? "blue" : "gray"; return [__("Inbox"), color, "is_global,=,No|is_default=No"]; } } diff --git a/frappe/email/doctype/email_domain/email_domain.py b/frappe/email/doctype/email_domain/email_domain.py index 08583dc228..ce39523564 100644 --- a/frappe/email/doctype/email_domain/email_domain.py +++ b/frappe/email/doctype/email_domain/email_domain.py @@ -17,6 +17,8 @@ class EmailDomain(Document): def validate(self): """Validate email id and check POP3/IMAP and SMTP connections is enabled.""" + logger = frappe.logger() + if self.email_id: validate_email_address(self.email_id, True) @@ -26,19 +28,25 @@ class EmailDomain(Document): if not frappe.local.flags.in_install and not frappe.local.flags.in_patch: try: if self.use_imap: + logger.info('Checking incoming IMAP email server {host}:{port} ssl={ssl}...'.format( + host=self.email_server, port=get_port(self), ssl=self.use_ssl)) if self.use_ssl: test = imaplib.IMAP4_SSL(self.email_server, port=get_port(self)) else: test = imaplib.IMAP4(self.email_server, port=get_port(self)) else: + logger.info('Checking incoming POP3 email server {host}:{port} ssl={ssl}...'.format( + host=self.email_server, port=get_port(self), ssl=self.use_ssl)) if self.use_ssl: test = poplib.POP3_SSL(self.email_server, port=get_port(self)) else: test = poplib.POP3(self.email_server, port=get_port(self)) - except Exception: - frappe.throw(_("Incoming email account not correct")) + except Exception as e: + logger.warn('Incoming email account "{host}" not correct'.format(host=self.email_server), exc_info=e) + frappe.throw(title=_("Incoming email account not correct"), + msg='Error connecting IMAP/POP3 "{host}": {e}'.format(host=self.email_server, e=e)) finally: try: @@ -54,22 +62,28 @@ class EmailDomain(Document): if not self.get('smtp_port'): self.smtp_port = 465 + logger.info('Checking outgoing SMTPS email server {host}:{port}...'.format( + host=self.smtp_server, port=self.smtp_port)) sess = smtplib.SMTP_SSL((self.smtp_server or "").encode('utf-8'), cint(self.smtp_port) or None) else: if self.use_tls and not self.smtp_port: self.smtp_port = 587 + logger.info('Checking outgoing SMTP email server {host}:{port} STARTTLS={tls}...'.format( + host=self.smtp_server, port=self.get('smtp_port'), tls=self.use_tls)) sess = smtplib.SMTP(cstr(self.smtp_server or ""), cint(self.smtp_port) or None) sess.quit() - except Exception: - frappe.throw(_("Outgoing email account not correct")) + except Exception as e: + logger.warn('Outgoing email account "{host}" not correct'.format(host=self.smtp_server), exc_info=e) + frappe.throw(title=_("Outgoing email account not correct"), + msg='Error connecting SMTP "{host}": {e}'.format(host=self.smtp_server, e=e)) def on_update(self): """update all email accounts using this domain""" for email_account in frappe.get_all("Email Account", filters={"domain": self.name}): try: email_account = frappe.get_doc("Email Account", email_account.name) - for attr in ["email_server", "use_imap", "use_ssl", "use_tls", "attachment_limit", "smtp_server", "smtp_port", "use_ssl_for_outgoing", "append_emails_to_sent_folder"]: + for attr in ["email_server", "use_imap", "use_ssl", "use_tls", "attachment_limit", "smtp_server", "smtp_port", "use_ssl_for_outgoing", "append_emails_to_sent_folder", "incoming_port"]: email_account.set(attr, self.get(attr, default=0)) email_account.save() diff --git a/frappe/email/doctype/email_domain/test_email_domain.py b/frappe/email/doctype/email_domain/test_email_domain.py index 0050b3250a..1c5306e9c2 100644 --- a/frappe/email/doctype/email_domain/test_email_domain.py +++ b/frappe/email/doctype/email_domain/test_email_domain.py @@ -5,8 +5,35 @@ from __future__ import unicode_literals import frappe import unittest +from frappe.test_runner import make_test_objects -# test_records = frappe.get_test_records('Domain') +test_records = frappe.get_test_records('Email Domain') class TestDomain(unittest.TestCase): - pass + + def setUp(self): + make_test_objects('Email Domain', reset=True) + + def tearDown(self): + frappe.delete_doc("Email Account", "Test") + frappe.delete_doc("Email Domain", "test.com") + + def test_on_update(self): + mail_domain = frappe.get_doc("Email Domain", "test.com") + mail_account = frappe.get_doc("Email Account", "Test") + + # Initially, incoming_port is different in domain and account + self.assertNotEqual(mail_account.incoming_port, mail_domain.incoming_port) + # Trigger update of accounts using this domain + mail_domain.on_update() + mail_account = frappe.get_doc("Email Account", "Test") + # After update, incoming_port in account should match the domain + self.assertEqual(mail_account.incoming_port, mail_domain.incoming_port) + + # Also make sure that the other attributes match + self.assertEqual(mail_account.use_imap, mail_domain.use_imap) + self.assertEqual(mail_account.use_ssl, mail_domain.use_ssl) + self.assertEqual(mail_account.use_tls, mail_domain.use_tls) + self.assertEqual(mail_account.attachment_limit, mail_domain.attachment_limit) + self.assertEqual(mail_account.smtp_server, mail_domain.smtp_server) + self.assertEqual(mail_account.smtp_port, mail_domain.smtp_port) diff --git a/frappe/email/doctype/email_domain/test_records.json b/frappe/email/doctype/email_domain/test_records.json new file mode 100644 index 0000000000..32bc66e150 --- /dev/null +++ b/frappe/email/doctype/email_domain/test_records.json @@ -0,0 +1,30 @@ +[ + { + "doctype": "Email Domain", + "domain_name": "test.com", + "email_id": "_test@test.com", + "email_server": "imap.test.com", + "use_imap": "imap.test.com", + "use_ssl": 1, + "use_tls": 1, + "incoming_port": "993", + "attachment_limit": "1", + "smtp_server": "smtp.test.com", + "smtp_port": "587" + }, + { + "doctype": "Email Account", + "name": "_Test Email Account 1", + "enable_incoming": 1, + "email_id": "_test@test.com", + "domain": "test.com", + "email_server": "imap.test.com", + "use_imap": 1, + "use_ssl": 0, + "use_tls": 1, + "incoming_port": "143", + "attachment_limit": "1", + "smtp_server": "smtp.test.com", + "smtp_port": "587" + } +] diff --git a/frappe/email/doctype/email_group/email_group.js b/frappe/email/doctype/email_group/email_group.js index 63c3832b47..404600c97d 100644 --- a/frappe/email/doctype/email_group/email_group.js +++ b/frappe/email/doctype/email_group/email_group.js @@ -3,11 +3,6 @@ frappe.ui.form.on("Email Group", "refresh", function(frm) { if(!frm.is_new()) { - frm.add_custom_button(__("View Subscribers"), function() { - frappe.route_options = {"email_group": frm.doc.name}; - frappe.set_route("List", "Email Group Member"); - }, __("View")); - frm.add_custom_button(__("Import Subscribers"), function() { frappe.prompt({fieldtype:"Select", options: frm.doc.__onload.import_types, label:__("Import Email From"), fieldname:"doctype", reqd:1}, diff --git a/frappe/email/doctype/email_group/email_group.json b/frappe/email/doctype/email_group/email_group.json index 0d784d409a..c49de841e6 100644 --- a/frappe/email/doctype/email_group/email_group.json +++ b/frappe/email/doctype/email_group/email_group.json @@ -5,6 +5,7 @@ "creation": "2015-03-18 06:08:32.729800", "doctype": "DocType", "document_type": "Setup", + "engine": "InnoDB", "field_order": [ "title", "total_subscribers", @@ -41,8 +42,15 @@ "options": "Email Template" } ], - "links": [], - "modified": "2020-02-21 14:12:48.884738", + "index_web_pages_for_search": 1, + "links": [ + { + "group": "Members", + "link_doctype": "Email Group Member", + "link_fieldname": "email_group" + } + ], + "modified": "2020-09-24 16:41:55.286377", "modified_by": "Administrator", "module": "Email", "name": "Email Group", diff --git a/frappe/email/doctype/email_template/email_template.json b/frappe/email/doctype/email_template/email_template.json index 0d0922f16f..dc73acacc1 100644 --- a/frappe/email/doctype/email_template/email_template.json +++ b/frappe/email/doctype/email_template/email_template.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_import": 1, "allow_rename": 1, "autoname": "Prompt", @@ -8,6 +9,8 @@ "engine": "InnoDB", "field_order": [ "subject", + "use_html", + "response_html", "response", "owner", "section_break_4", @@ -22,11 +25,12 @@ "reqd": 1 }, { + "depends_on": "eval:!doc.use_html", "fieldname": "response", "fieldtype": "Text Editor", "in_list_view": 1, "label": "Response", - "reqd": 1 + "mandatory_depends_on": "eval:!doc.use_html" }, { "default": "user", @@ -45,10 +49,24 @@ "fieldtype": "HTML", "label": "Email Reply Help", "options": "

        Email Reply Example

        \n\n
        Order Overdue\n\nTransaction {{ name }} has exceeded Due Date. Please take necessary action.\n\nDetails\n\n- Customer: {{ customer }}\n- Amount: {{ grand_total }}\n
        \n\n

        How to get fieldnames

        \n\n

        The fieldnames you can use in your email template are the fields in the document from which you are sending the email. You can find out the fields of any documents via Setup > Customize Form View and selecting the document type (e.g. Sales Invoice)

        \n\n

        Templating

        \n\n

        Templates are compiled using the Jinja Templating Language. To learn more about Jinja, read this documentation.

        \n" + }, + { + "default": "0", + "fieldname": "use_html", + "fieldtype": "Check", + "label": "Use HTML" + }, + { + "depends_on": "eval:doc.use_html", + "fieldname": "response_html", + "fieldtype": "Code", + "label": "Response ", + "options": "HTML" } ], "icon": "fa fa-comment", - "modified": "2019-10-30 14:15:00.956347", + "links": [], + "modified": "2020-11-30 14:12:50.321633", "modified_by": "Administrator", "module": "Email", "name": "Email Template", diff --git a/frappe/email/doctype/email_template/email_template.py b/frappe/email/doctype/email_template/email_template.py index 2743032331..6708e9dd3f 100644 --- a/frappe/email/doctype/email_template/email_template.py +++ b/frappe/email/doctype/email_template/email_template.py @@ -9,7 +9,29 @@ from six import string_types class EmailTemplate(Document): def validate(self): - validate_template(self.response) + if self.use_html: + validate_template(self.response_html) + else: + validate_template(self.response) + + def get_formatted_subject(self, doc): + return frappe.render_template(self.subject, doc) + + def get_formatted_response(self, doc): + if self.use_html: + return frappe.render_template(self.response_html, doc) + + return frappe.render_template(self.response, doc) + + def get_formatted_email(self, doc): + if isinstance(doc, string_types): + doc = json.loads(doc) + + return { + "subject" : self.get_formatted_subject(doc), + "message" : self.get_formatted_response(doc) + } + @frappe.whitelist() def get_email_template(template_name, doc): @@ -18,5 +40,4 @@ def get_email_template(template_name, doc): doc = json.loads(doc) email_template = frappe.get_doc("Email Template", template_name) - return {"subject" : frappe.render_template(email_template.subject, doc), - "message" : frappe.render_template(email_template.response, doc)} \ No newline at end of file + return email_template.get_formatted_email(doc) \ No newline at end of file diff --git a/frappe/email/doctype/newsletter/newsletter.json b/frappe/email/doctype/newsletter/newsletter.json index 1dd6115b43..dcd19ed33c 100644 --- a/frappe/email/doctype/newsletter/newsletter.json +++ b/frappe/email/doctype/newsletter/newsletter.json @@ -19,9 +19,12 @@ "message", "message_md", "message_html", + "section_break_13", "send_unsubscribe_link", "send_attachments", + "column_break_9", "published", + "send_webview_link", "route", "test_the_newsletter", "test_email_id", @@ -160,6 +163,21 @@ "fieldtype": "Check", "label": "Schedule Sending", "read_only_depends_on": "eval: doc.email_sent" + }, + { + "fieldname": "column_break_9", + "fieldtype": "Column Break" + }, + { + "default": "0", + "depends_on": "published", + "fieldname": "send_webview_link", + "fieldtype": "Check", + "label": "Send Web View Link" + }, + { + "fieldname": "section_break_13", + "fieldtype": "Section Break" } ], "has_web_view": 1, @@ -169,7 +187,7 @@ "is_published_field": "published", "links": [], "max_attachments": 3, - "modified": "2020-08-24 19:59:37.262500", + "modified": "2021-02-22 14:33:56.095380", "modified_by": "Administrator", "module": "Email", "name": "Newsletter", diff --git a/frappe/email/doctype/newsletter/newsletter.py b/frappe/email/doctype/newsletter/newsletter.py index a4d60706eb..ad985ee20e 100755 --- a/frappe/email/doctype/newsletter/newsletter.py +++ b/frappe/email/doctype/newsletter/newsletter.py @@ -68,13 +68,17 @@ class Newsletter(WebsiteGenerator): except IOError: frappe.throw(_("Unable to find attachment {0}").format(file.name)) - send(recipients=self.recipients, sender=sender, - subject=self.subject, message=self.get_message(), + args = { + "message": self.get_message(), + "name": self.name + } + frappe.sendmail(recipients=self.recipients, sender=sender, + subject=self.subject, message=self.get_message(), template="newsletter", reference_doctype=self.doctype, reference_name=self.name, add_unsubscribe_link=self.send_unsubscribe_link, attachments=attachments, unsubscribe_method="/unsubscribe", unsubscribe_params={"name": self.name}, - send_priority=0, queue_separately=True) + send_priority=0, queue_separately=True, args=args) if not frappe.flags.in_test: frappe.db.auto_commit_on_many_writes = False @@ -85,11 +89,11 @@ class Newsletter(WebsiteGenerator): self.db_set("scheduled_to_send", len(self.recipients)) def get_message(self): - + if self.content_type == "HTML": + return frappe.render_template(self.message_html, {"doc": self.as_dict()}) return { 'Rich Text': self.message, - 'Markdown': markdown(self.message_md), - 'HTML': self.message_html + 'Markdown': markdown(self.message_md) }[self.content_type or 'Rich Text'] def get_recipients(self): diff --git a/frappe/email/doctype/newsletter/test_newsletter.py b/frappe/email/doctype/newsletter/test_newsletter.py index ee7f123b7e..bd8fadc29c 100644 --- a/frappe/email/doctype/newsletter/test_newsletter.py +++ b/frappe/email/doctype/newsletter/test_newsletter.py @@ -2,58 +2,66 @@ # License: GNU General Public License v3. See license.txt from __future__ import unicode_literals -import frappe, unittest -from frappe.utils import getdate, add_days +import unittest +from random import choice -from frappe.email.doctype.newsletter.newsletter import confirmed_unsubscribe, send_scheduled_email -from six.moves.urllib.parse import unquote +import frappe +from frappe.email.doctype.newsletter.newsletter import ( + confirmed_unsubscribe, + send_scheduled_email, +) +from frappe.email.doctype.newsletter.newsletter import get_newsletter_list +from frappe.email.queue import flush +from frappe.utils import add_days, getdate test_dependencies = ["Email Group"] +emails = [ + "test_subscriber1@example.com", + "test_subscriber2@example.com", + "test_subscriber3@example.com", + "test1@example.com", +] -emails = ["test_subscriber1@example.com", "test_subscriber2@example.com", - "test_subscriber3@example.com", "test1@example.com"] class TestNewsletter(unittest.TestCase): def setUp(self): frappe.set_user("Administrator") - frappe.db.sql('delete from `tabEmail Group Member`') + frappe.db.sql("delete from `tabEmail Group Member`") + + if not frappe.db.exists("Email Group", "_Test Email Group"): + frappe.get_doc({"doctype": "Email Group", "title": "_Test Email Group"}).insert() - group_exist=frappe.db.exists("Email Group", "_Test Email Group") - if len(group_exist) == 0: - frappe.get_doc({ - "doctype": "Email Group", - "title": "_Test Email Group" - }).insert() for email in emails: - frappe.get_doc({ - "doctype": "Email Group Member", - "email": email, - "email_group": "_Test Email Group" - }).insert() + frappe.get_doc({ + "doctype": "Email Group Member", + "email": email, + "email_group": "_Test Email Group" + }).insert() def test_send(self): - name = self.send_newsletter() + self.send_newsletter() - email_queue_list = [frappe.get_doc('Email Queue', e.name) for e in frappe.get_all("Email Queue")] + email_queue_list = [frappe.get_doc("Email Queue", e.name) for e in frappe.get_all("Email Queue")] self.assertEqual(len(email_queue_list), 4) - recipients = [e.recipients[0].recipient for e in email_queue_list] - for email in emails: - self.assertTrue(email in recipients) + + recipients = set([e.recipients[0].recipient for e in email_queue_list]) + self.assertTrue(set(emails).issubset(recipients)) def test_unsubscribe(self): - # test unsubscribe name = self.send_newsletter() - from frappe.email.queue import flush + to_unsubscribe = choice(emails) + group = frappe.get_all("Newsletter Email Group", filters={"parent": name}, fields=["email_group"]) + flush(from_test=True) - to_unsubscribe = unquote(frappe.local.flags.signed_query_string.split("email=")[1].split("&")[0]) - group = frappe.get_all("Newsletter Email Group", filters={"parent" : name}, fields=["email_group"]) confirmed_unsubscribe(to_unsubscribe, group[0].email_group) name = self.send_newsletter() - - email_queue_list = [frappe.get_doc('Email Queue', e.name) for e in frappe.get_all("Email Queue")] + email_queue_list = [ + frappe.get_doc("Email Queue", e.name) for e in frappe.get_all("Email Queue") + ] self.assertEqual(len(email_queue_list), 3) recipients = [e.recipients[0].recipient for e in email_queue_list] + for email in emails: if email != to_unsubscribe: self.assertTrue(email in recipients) @@ -86,7 +94,6 @@ class TestNewsletter(unittest.TestCase): def test_portal(self): self.send_newsletter(1) frappe.set_user("test1@example.com") - from frappe.email.doctype.newsletter.newsletter import get_newsletter_list newsletters = get_newsletter_list("Newsletter", None, None, 0) self.assertEqual(len(newsletters), 1) @@ -106,4 +113,4 @@ class TestNewsletter(unittest.TestCase): self.assertEqual(len(email_queue_list), 4) recipients = [e.recipients[0].recipient for e in email_queue_list] for email in emails: - self.assertTrue(email in recipients) \ No newline at end of file + self.assertTrue(email in recipients) diff --git a/frappe/email/doctype/notification/notification.js b/frappe/email/doctype/notification/notification.js index 2cc027acd6..c999f5f160 100644 --- a/frappe/email/doctype/notification/notification.js +++ b/frappe/email/doctype/notification/notification.js @@ -97,14 +97,7 @@ frappe.notification = { }, setup_example_message: function(frm) { let template = ''; - if (frm.doc.channel === 'WhatsApp') { - template = `
        Warning:
        Only Use Pre-Approved WhatsApp for Business Template -
        Message Example
        - -
        -Your appointment is coming up on {{ doc.date }} at {{ doc.time }}
        -
        `; - } else if (frm.doc.channel === 'Email') { + if (frm.doc.channel === 'Email') { template = `
        Message Example
        <h3>Order Overdue</h3>
        @@ -124,7 +117,7 @@ Last comment: {{ comments[-1].comment }} by {{ comments[-1].by }}
         </ul>
         
        `; - } else { + } else if (in_list(['Slack', 'System Notification', 'SMS'], frm.doc.channel)) { template = `
        Message Example
        *Order Overdue*
        @@ -142,7 +135,9 @@ Last comment: {{ comments[-1].comment }} by {{ comments[-1].by }}
         • Amount: {{ doc.grand_total }}
         
        `; } - frm.set_df_property('message_examples', 'options', template); + if (template) { + frm.set_df_property('message_examples', 'options', template); + } } }; @@ -203,7 +198,7 @@ frappe.ui.form.on('Notification', { frappe.notification.setup_example_message(frm); if (frm.doc.channel === 'SMS' && frm.doc.__islocal) { frm.set_df_property('channel', - 'description', `To use SMS Channel, initialize SMS Settings.`); + 'description', `To use SMS Channel, initialize SMS Settings.`); } else { frm.set_df_property('channel', 'description', ` `); } diff --git a/frappe/email/doctype/notification/notification.json b/frappe/email/doctype/notification/notification.json index 2a8ee1aeb1..c1c877efd4 100644 --- a/frappe/email/doctype/notification/notification.json +++ b/frappe/email/doctype/notification/notification.json @@ -10,7 +10,6 @@ "enabled", "column_break_2", "channel", - "twilio_number", "slack_webhook_url", "filters", "subject", @@ -61,7 +60,7 @@ "fieldname": "channel", "fieldtype": "Select", "label": "Channel", - "options": "Email\nSlack\nSystem Notification\nWhatsApp\nSMS", + "options": "Email\nSlack\nSystem Notification\nSMS", "reqd": 1, "set_only_once": 1 }, @@ -80,14 +79,14 @@ "label": "Filters" }, { - "depends_on": "eval: !in_list(['SMS', 'WhatsApp'], doc.channel)", + "depends_on": "eval: in_list(['Email', 'Slack', 'System Notification'], doc.channel)", "description": "To add dynamic subject, use jinja tags like\n\n
        {{ doc.name }} Delivered
        ", "fieldname": "subject", "fieldtype": "Data", "ignore_xss_filter": 1, "in_list_view": 1, "label": "Subject", - "mandatory_depends_on": "eval:!in_list(['SMS', 'WhatsApp'], doc.channel)" + "mandatory_depends_on": "eval: in_list(['Email', 'Slack', 'System Notification'], doc.channel)" }, { "fieldname": "document_type", @@ -208,7 +207,7 @@ "label": "Value To Be Set" }, { - "depends_on": "eval:in_list(['Email', 'SMS', 'WhatsApp'], doc.channel)", + "depends_on": "eval:doc.channel !=\"Slack\"", "fieldname": "column_break_5", "fieldtype": "Section Break", "label": "Recipients" @@ -263,15 +262,6 @@ "label": "Print Format", "options": "Print Format" }, - { - "depends_on": "eval: doc.channel==='WhatsApp'", - "description": "To use WhatsApp for Business, initialize Twilio Settings.", - "fieldname": "twilio_number", - "fieldtype": "Link", - "label": "Twilio Number", - "mandatory_depends_on": "eval: doc.channel==='WhatsApp'", - "options": "Twilio Number Group" - }, { "default": "0", "depends_on": "eval: doc.channel !== 'System Notification'", @@ -291,7 +281,7 @@ "icon": "fa fa-envelope", "index_web_pages_for_search": 1, "links": [], - "modified": "2020-09-03 10:33:23.084590", + "modified": "2020-11-24 14:25:43.245677", "modified_by": "Administrator", "module": "Email", "name": "Notification", diff --git a/frappe/email/doctype/notification/notification.py b/frappe/email/doctype/notification/notification.py index 62be313b82..2ea7a3785e 100644 --- a/frappe/email/doctype/notification/notification.py +++ b/frappe/email/doctype/notification/notification.py @@ -14,7 +14,6 @@ from frappe.utils.safe_exec import get_safe_globals from frappe.modules.utils import export_module_json, get_doc_module from six import string_types from frappe.integrations.doctype.slack_webhook_url.slack_webhook_url import send_slack_message -from frappe.integrations.doctype.twilio_settings.twilio_settings import send_whatsapp_message from frappe.core.doctype.sms_settings.sms_settings import send_sms from frappe.desk.doctype.notification_log.notification_log import enqueue_create_notification @@ -29,7 +28,7 @@ class Notification(Document): self.name = self.subject def validate(self): - if self.channel not in ('WhatsApp', 'SMS'): + if self.channel in ("Email", "Slack", "System Notification"): validate_template(self.subject) validate_template(self.message) @@ -43,7 +42,6 @@ class Notification(Document): self.validate_forbidden_types() self.validate_condition() self.validate_standard() - self.validate_twilio_settings() frappe.cache().hdel('notifications', self.document_type) def on_update(self): @@ -70,11 +68,6 @@ def get_context(context): if self.is_standard and not frappe.conf.developer_mode: frappe.throw(_('Cannot edit Standard Notification. To edit, please disable this and duplicate it')) - def validate_twilio_settings(self): - if self.enabled and self.channel == "WhatsApp" \ - and not frappe.db.get_single_value("Twilio Settings", "enabled"): - frappe.throw(_("Please enable Twilio settings to send WhatsApp messages")) - def validate_condition(self): temp_doc = frappe.new_doc(self.document_type) if self.condition: @@ -137,9 +130,6 @@ def get_context(context): if self.channel == 'Slack': self.send_a_slack_msg(doc, context) - if self.channel == 'WhatsApp': - self.send_whatsapp_msg(doc, context) - if self.channel == 'SMS': self.send_sms(doc, context) @@ -191,6 +181,7 @@ def get_context(context): 'document_type': doc.doctype, 'document_name': doc.name, 'subject': subject, + 'from_user': doc.modified_by or doc.owner, 'email_content': frappe.render_template(self.message, context), 'attached_file': attachments and json.dumps(attachments[0]) } @@ -230,13 +221,6 @@ def get_context(context): reference_doctype=doc.doctype, reference_name=doc.name) - def send_whatsapp_msg(self, doc, context): - send_whatsapp_message( - sender=self.twilio_number, - receiver_list=self.get_receiver_list(doc, context), - message=frappe.render_template(self.message, context), - ) - def send_sms(self, doc, context): send_sms( receiver_list=self.get_receiver_list(doc, context), @@ -302,7 +286,7 @@ def get_context(context): # For sending messages to the owner's mobile phone number if recipient.receiver_by_document_field == 'owner': - receiver_list.append(get_user_info(doc.get('owner'), 'mobile_no')) + receiver_list += get_user_info([dict(user_name=doc.get('owner'))], 'mobile_no') # For sending messages to the number specified in the receiver field elif recipient.receiver_by_document_field: receiver_list.append(doc.get(recipient.receiver_by_document_field)) diff --git a/frappe/email/email_body.py b/frappe/email/email_body.py index 5bb654abf3..3fb1dfa0da 100755 --- a/frappe/email/email_body.py +++ b/frappe/email/email_body.py @@ -13,7 +13,6 @@ from email.mime.multipart import MIMEMultipart from email.header import Header from email import policy - def get_email(recipients, sender='', msg='', subject='[No Subject]', text_content = None, footer=None, print_html=None, formatted=None, attachments=None, content=None, reply_to=None, cc=[], bcc=[], email_account=None, expose_recipients=None, @@ -198,12 +197,15 @@ class EMail: def set_message_id(self, message_id, is_notification=False): if message_id: - self.msg_root["Message-Id"] = '<' + message_id + '>' + message_id = '<' + message_id + '>' else: - self.msg_root["Message-Id"] = get_message_id() - self.msg_root["isnotification"] = '' + message_id = get_message_id() + self.set_header('isnotification', '') + if is_notification: - self.msg_root["isnotification"] = '' + self.set_header('isnotification', '') + + self.set_header('Message-Id', message_id) def set_in_reply_to(self, in_reply_to): """Used to send the Message-Id of a received email back as In-Reply-To""" @@ -246,11 +248,14 @@ class EMail: return self.msg_root.as_string(policy=policy.SMTPUTF8) def get_formatted_html(subject, message, footer=None, print_html=None, - email_account=None, header=None, unsubscribe_link=None, sender=None): + email_account=None, header=None, unsubscribe_link=None, sender=None, with_container=False): if not email_account: email_account = get_outgoing_email_account(False, sender=sender) rendered_email = frappe.get_template("templates/emails/standard.html").render({ + "brand_logo": get_brand_logo(email_account) if with_container or header else None, + "with_container": with_container, + "site_url": get_url(), "header": get_header(header), "content": message, "signature": get_signature(email_account), @@ -269,14 +274,14 @@ def get_formatted_html(subject, message, footer=None, print_html=None, return html @frappe.whitelist() -def get_email_html(template, args, subject, header=None): +def get_email_html(template, args, subject, header=None, with_container=False): import json - + with_container = cint(with_container) args = json.loads(args) if header and header.startswith('['): header = json.loads(header) email = frappe.utils.jinja.get_email_from_template(template, args) - return get_formatted_html(subject, email[0], header=header) + return get_formatted_html(subject, email[0], header=header, with_container=with_container) def inline_style_in_html(html): ''' Convert email.css and html to inline-styled html @@ -285,11 +290,16 @@ def inline_style_in_html(html): apps = frappe.get_installed_apps() - css_files = [] + # add frappe email css file + css_files = ['assets/css/email.css'] + if 'frappe' in apps: + apps.remove('frappe') + for app in apps: path = 'assets/{0}/css/email.css'.format(app) - if os.path.exists(os.path.abspath(path)): - css_files.append(path) + css_files.append(path) + + css_files = [css_file for css_file in css_files if os.path.exists(os.path.abspath(css_file))] p = Premailer(html=html, external_styles=css_files, strip_important=False) @@ -350,7 +360,7 @@ def get_message_id(): def get_signature(email_account): if email_account and email_account.add_signature and email_account.signature: - return "

        " + email_account.signature + return "
        " + email_account.signature else: return "" @@ -363,10 +373,10 @@ def get_footer(email_account, footer=None): if email_account and email_account.footer: args.update({'email_account_footer': email_account.footer}) - company_address = frappe.db.get_default("email_footer_address") + sender_address = frappe.db.get_default("email_footer_address") - if company_address: - args.update({'company_address': company_address}) + if sender_address: + args.update({'sender_address': sender_address}) if not cint(frappe.db.get_default("disable_standard_email_footer")): args.update({'default_mail_footer': frappe.get_hooks('default_mail_footer')}) @@ -464,3 +474,6 @@ def get_header(header=None): def sanitize_email_header(str): return str.replace('\r', '').replace('\n', '') + +def get_brand_logo(email_account): + return email_account.get('brand_logo') \ No newline at end of file diff --git a/frappe/email/queue.py b/frappe/email/queue.py index 25b6f93f83..2aff04edc9 100755 --- a/frappe/email/queue.py +++ b/frappe/email/queue.py @@ -24,7 +24,7 @@ def send(recipients=None, sender=None, subject=None, message=None, text_content= attachments=None, reply_to=None, cc=None, bcc=None, message_id=None, in_reply_to=None, send_after=None, expose_recipients=None, send_priority=1, communication=None, now=False, read_receipt=None, queue_separately=False, is_notification=False, add_unsubscribe_link=1, inline_images=None, - header=None, print_letterhead=False): + header=None, print_letterhead=False, with_container=False): """Add email to sending queue (Email Queue) :param recipients: List of recipients. @@ -48,6 +48,7 @@ def send(recipients=None, sender=None, subject=None, message=None, text_content= :param add_unsubscribe_link: Send unsubscribe link in the footer of the Email, default 1. :param inline_images: List of inline images as {"filename", "filecontent"}. All src properties will be replaced with random Content-Id :param header: Append header in email (boolean) + :param with_container: Wraps email inside styled container """ if not unsubscribe_method: unsubscribe_method = "/api/method/frappe.email.queue.unsubscribe" @@ -130,7 +131,7 @@ def send(recipients=None, sender=None, subject=None, message=None, text_content= email_content = get_formatted_html(subject, message, email_account=email_account, header=header, - unsubscribe_link=unsubscribe_link) + unsubscribe_link=unsubscribe_link, with_container=with_container) # add to queue add(recipients, sender, subject, @@ -584,14 +585,15 @@ def prepare_message(email, recipient, recipients_list): return safe_encode(message.as_string()) -def clear_outbox(): - """Remove low priority older than 31 days in Outbox and expire mails not sent for 7 days. - Called daily via scheduler. +def clear_outbox(days=None): + """Remove low priority older than 31 days in Outbox or configured in Log Settings. Note: Used separate query to avoid deadlock """ + if not days: + days=31 email_queues = frappe.db.sql_list("""SELECT `name` FROM `tabEmail Queue` - WHERE `priority`=0 AND `modified` < (NOW() - INTERVAL '31' DAY)""") + WHERE `priority`=0 AND `modified` < (NOW() - INTERVAL '{0}' DAY)""".format(days)) if email_queues: frappe.db.sql("""DELETE FROM `tabEmail Queue` WHERE `name` IN ({0})""".format( @@ -602,6 +604,11 @@ def clear_outbox(): ','.join(['%s']*len(email_queues) )), tuple(email_queues)) +def set_expiry_for_email_queue(): + ''' Mark emails as expire that has not sent for 7 days. + Called daily via scheduler. + ''' + frappe.db.sql(""" UPDATE `tabEmail Queue` SET `status`='Expired' diff --git a/frappe/email/receive.py b/frappe/email/receive.py index 9ba080bfda..cf6c13ee76 100644 --- a/frappe/email/receive.py +++ b/frappe/email/receive.py @@ -59,10 +59,6 @@ class EmailServer: frappe.msgprint(_('Invalid Mail Server. Please rectify and try again.')) raise - except Exception as e: - frappe.msgprint(_('Cannot connect: {0}').format(str(e))) - raise - def connect_pop(self): #this method return pop connection try: @@ -540,6 +536,8 @@ class Email: except MaxFileSizeReachedError: # WARNING: bypass max file size exception pass + except frappe.FileAlreadyAttachedException: + pass except frappe.DuplicateEntryError: # same file attached twice?? pass diff --git a/frappe/email/smtp.py b/frappe/email/smtp.py index aa025465e5..9ba81fa146 100644 --- a/frappe/email/smtp.py +++ b/frappe/email/smtp.py @@ -2,7 +2,6 @@ # MIT License. See license.txt from __future__ import unicode_literals -from six import reraise as raise_ import frappe import smtplib import email.utils @@ -211,10 +210,9 @@ class SMTPServer: try: if self.use_ssl: if not self.port: - self.smtp_port = 465 + self.port = 465 - self._sess = smtplib.SMTP_SSL((self.server or "").encode('utf-8'), - cint(self.port) or None) + self._sess = smtplib.SMTP_SSL((self.server or ""), cint(self.port)) else: if self.use_tls and not self.port: self.port = 587 @@ -242,16 +240,17 @@ class SMTPServer: return self._sess + except smtplib.SMTPAuthenticationError as e: + from frappe.email.doctype.email_account.email_account import EmailAccount + EmailAccount.throw_invalid_credentials_exception() + except _socket.error as e: # Invalid mail server -- due to refusing connection - frappe.msgprint(_('Invalid Outgoing Mail Server or Port')) - traceback = sys.exc_info()[2] - raise_(frappe.ValidationError, e, traceback) - - except smtplib.SMTPAuthenticationError as e: - frappe.msgprint(_("Invalid login or password")) - traceback = sys.exc_info()[2] - raise_(frappe.ValidationError, e, traceback) + frappe.throw( + _("Invalid Outgoing Mail Server or Port"), + exc=frappe.ValidationError, + title=_("Incorrect Configuration") + ) except smtplib.SMTPException: frappe.msgprint(_('Unable to send emails at this time')) diff --git a/frappe/email/test_email_body.py b/frappe/email/test_email_body.py index 705a853bc6..3fcabb9495 100644 --- a/frappe/email/test_email_body.py +++ b/frappe/email/test_email_body.py @@ -17,7 +17,7 @@ class TestEmailBody(unittest.TestCase):

        Hey John Doe!

        This is embedded image you asked for

        - +
        ''' email_text = ''' @@ -25,7 +25,7 @@ Hey John Doe! This is the text version of this email ''' - img_path = os.path.abspath('assets/frappe/images/favicon.png') + img_path = os.path.abspath('assets/frappe/images/frappe-favicon.svg') with open(img_path, 'rb') as f: img_content = f.read() img_base64 = base64.b64encode(img_content).decode() @@ -77,12 +77,11 @@ This is the text version of this email def test_image(self): img_signature = ''' -Content-Type: image/png +Content-Type: image/svg+xml MIME-Version: 1.0 Content-Transfer-Encoding: base64 -Content-Disposition: inline; filename="favicon.png" +Content-Disposition: inline; filename="frappe-favicon.svg" ''' - self.assertTrue(img_signature in self.email_string) self.assertTrue(self.img_base64 in self.email_string) @@ -117,7 +116,7 @@ w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> def test_replace_filename_with_cid(self): original_message = '''
        - test + test
        ''' @@ -138,7 +137,7 @@ w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> ''' transformed_html = '''

        Hi John

        -

        This is a test email

        +

        This is a test email

        ''' self.assertTrue(transformed_html in inline_style_in_html(html)) @@ -154,10 +153,8 @@ w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> content=email_html, header=['Email Title', 'green'] ).as_string().replace("\r\n", "\n") - - self.assertTrue('''''' in email_string) + # REDESIGN-TODO: Add style for indicators in email + self.assertTrue('''''' in email_string) self.assertTrue('Email Title' in email_string) def test_get_email_header(self): diff --git a/frappe/email/test_smtp.py b/frappe/email/test_smtp.py new file mode 100644 index 0000000000..869d708430 --- /dev/null +++ b/frappe/email/test_smtp.py @@ -0,0 +1,25 @@ +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# License: The MIT License + +import unittest +from frappe.email.smtp import SMTPServer + +class TestSMTP(unittest.TestCase): + def test_smtp_ssl_session(self): + for port in [None, 0, 465, "465"]: + make_server(port, 1, 0) + + def test_smtp_tls_session(self): + for port in [None, 0, 587, "587"]: + make_server(port, 0, 1) + + +def make_server(port, ssl, tls): + server = SMTPServer( + server = "smtp.gmail.com", + port = port, + use_ssl = ssl, + use_tls = tls + ) + + server.sess \ No newline at end of file diff --git a/frappe/event_streaming/doctype/event_consumer/event_consumer.py b/frappe/event_streaming/doctype/event_consumer/event_consumer.py index 1505c3a05d..5789e09e74 100644 --- a/frappe/event_streaming/doctype/event_consumer/event_consumer.py +++ b/frappe/event_streaming/doctype/event_consumer/event_consumer.py @@ -31,10 +31,12 @@ class EventConsumer(Document): self.update_consumer_status() else: frappe.db.set_value(self.doctype, self.name, 'incoming_change', 0) - + frappe.cache().delete_value('event_consumer_document_type_map') def on_trash(self): + for i in frappe.get_all('Event Update Log Consumer', {'consumer': self.name}): + frappe.delete_doc('Event Update Log Consumer', i.name) frappe.cache().delete_value('event_consumer_document_type_map') def update_consumer_status(self): @@ -88,8 +90,9 @@ def register_consumer(data): for entry in consumer_doctypes: consumer.append('consumer_doctypes', { - 'ref_doctype': entry, - 'status': 'Pending' + 'ref_doctype': entry.get('doctype'), + 'status': 'Pending', + 'condition': entry.get('condition') }) consumer.insert() @@ -153,3 +156,53 @@ def notify(consumer): jobs = get_jobs() if not jobs or enqueued_method not in jobs[frappe.local.site] and not consumer.flags.notifed: frappe.enqueue(enqueued_method, queue='long', enqueue_after_commit=True, **{'consumer': consumer}) + + +def has_consumer_access(consumer, update_log): + """Checks if consumer has completely satisfied all the conditions on the doc""" + + if isinstance(consumer, str): + consumer = frappe.get_doc('Event Consumer', consumer) + + if not frappe.db.exists(update_log.ref_doctype, update_log.docname): + # Delete Log + # Check if the last Update Log of this document was read by this consumer + last_update_log = frappe.get_all( + 'Event Update Log', + filters={ + 'ref_doctype': update_log.ref_doctype, + 'docname': update_log.docname, + 'creation': ['<', update_log.creation] + }, + order_by='creation desc', + limit_page_length=1 + ) + if not len(last_update_log): + return False + + last_update_log = frappe.get_doc('Event Update Log', last_update_log[0].name) + return len([x for x in last_update_log.consumers if x.consumer == consumer.name]) + + doc = frappe.get_doc(update_log.ref_doctype, update_log.docname) + try: + for dt_entry in consumer.consumer_doctypes: + if dt_entry.ref_doctype != update_log.ref_doctype: + continue + + if not dt_entry.condition: + return True + + condition: str = dt_entry.condition + if condition.startswith('cmd:'): + cmd = condition.split('cmd:')[1].strip() + args = { + 'consumer': consumer, + 'doc': doc, + 'update_log': update_log + } + return frappe.call(cmd, **args) + else: + return frappe.safe_eval(condition, frappe._dict(doc=doc)) + except Exception as e: + frappe.log_error(title='has_consumer_access error', message=e) + return False \ No newline at end of file diff --git a/frappe/event_streaming/doctype/event_consumer_document_type/event_consumer_document_type.json b/frappe/event_streaming/doctype/event_consumer_document_type/event_consumer_document_type.json index 71dcc63127..c243334a09 100644 --- a/frappe/event_streaming/doctype/event_consumer_document_type/event_consumer_document_type.json +++ b/frappe/event_streaming/doctype/event_consumer_document_type/event_consumer_document_type.json @@ -7,7 +7,8 @@ "field_order": [ "ref_doctype", "status", - "unsubscribed" + "unsubscribed", + "condition" ], "fields": [ { @@ -37,11 +38,17 @@ "in_list_view": 1, "label": "Unsubscribed", "read_only": 1 + }, + { + "fieldname": "condition", + "fieldtype": "Code", + "label": "Condition", + "read_only": 1 } ], "istable": 1, "links": [], - "modified": "2020-08-14 12:38:40.918620", + "modified": "2020-11-07 09:26:49.894294", "modified_by": "Administrator", "module": "Event Streaming", "name": "Event Consumer Document Type", diff --git a/frappe/event_streaming/doctype/event_producer/event_producer.json b/frappe/event_streaming/doctype/event_producer/event_producer.json index 8fafdc3bb2..d868f6c123 100644 --- a/frappe/event_streaming/doctype/event_producer/event_producer.json +++ b/frappe/event_streaming/doctype/event_producer/event_producer.json @@ -13,7 +13,6 @@ "api_secret", "column_break_6", "user", - "last_update", "incoming_change" ], "fields": [ @@ -25,12 +24,6 @@ "reqd": 1, "unique": 1 }, - { - "fieldname": "last_update", - "fieldtype": "Data", - "label": "Last Update", - "read_only": 1 - }, { "description": "API Key of the user(Event Subscriber) on the producer site", "fieldname": "api_key", @@ -77,7 +70,7 @@ } ], "links": [], - "modified": "2020-09-08 18:50:57.687979", + "modified": "2020-10-26 13:00:15.361316", "modified_by": "Administrator", "module": "Event Streaming", "name": "Event Producer", diff --git a/frappe/event_streaming/doctype/event_producer/event_producer.py b/frappe/event_streaming/doctype/event_producer/event_producer.py index b0ec998ab9..e43b4d131c 100644 --- a/frappe/event_streaming/doctype/event_producer/event_producer.py +++ b/frappe/event_streaming/doctype/event_producer/event_producer.py @@ -79,18 +79,36 @@ class EventProducer(Document): ) if response: response = json.loads(response) - self.last_update = response['last_update'] + self.set_last_update(response['last_update']) else: frappe.throw(_('Failed to create an Event Consumer or an Event Consumer for the current site is already registered.')) + def set_last_update(self, last_update): + last_update_doc_name = frappe.db.get_value('Event Producer Last Update', dict(event_producer=self.name)) + if not last_update_doc_name: + frappe.get_doc(dict( + doctype = 'Event Producer Last Update', + event_producer = self.producer_url, + last_update = last_update + )).insert(ignore_permissions=True) + else: + frappe.db.set_value('Event Producer Last Update', last_update_doc_name, 'last_update', last_update) + + def get_last_update(self): + return frappe.db.get_value('Event Producer Last Update', dict(event_producer=self.name), 'last_update') + def get_request_data(self): consumer_doctypes = [] for entry in self.producer_doctypes: if entry.has_mapping: # if mapping, subscribe to remote doctype on consumer's site - consumer_doctypes.append(frappe.db.get_value('Document Type Mapping', entry.mapping, 'remote_doctype')) + dt = frappe.db.get_value('Document Type Mapping', entry.mapping, 'remote_doctype') else: - consumer_doctypes.append(entry.ref_doctype) + dt = entry.ref_doctype + consumer_doctypes.append({ + "doctype": dt, + "condition": entry.condition + }) user_key = frappe.db.get_value('User', self.user, 'api_key') user_secret = get_decrypted_password('User', self.user, 'api_secret') @@ -131,7 +149,8 @@ class EventProducer(Document): event_consumer.consumer_doctypes.append({ 'ref_doctype': ref_doctype, 'status': get_approval_status(config, ref_doctype), - 'unsubscribed': entry.unsubscribe + 'unsubscribed': entry.unsubscribe, + 'condition': entry.condition }) event_consumer.user = self.user event_consumer.incoming_change = True @@ -184,7 +203,7 @@ def pull_from_node(event_producer): """pull all updates after the last update timestamp from event producer site""" event_producer = frappe.get_doc('Event Producer', event_producer) producer_site = get_producer_site(event_producer.producer_url) - last_update = event_producer.last_update + last_update = event_producer.get_last_update() (doctypes, mapping_config, naming_config) = get_config(event_producer.producer_doctypes) @@ -239,7 +258,7 @@ def sync(update, producer_site, event_producer, in_retry=False): return 'Failed' log_event_sync(update, event_producer.name, 'Failed', frappe.get_traceback()) - event_producer.db_set('last_update', update.creation) + event_producer.set_last_update(update.creation) frappe.db.commit() @@ -276,7 +295,7 @@ def set_update(update, producer_site): if data.changed: local_doc.update(data.changed) if data.removed: - update_row_removed(local_doc, data.removed) + local_doc = update_row_removed(local_doc, data.removed) if data.row_changed: update_row_changed(local_doc, data.row_changed) if data.added: @@ -299,7 +318,17 @@ def update_row_removed(local_doc, removed): for tablename, rownames in iteritems(removed): table = local_doc.get_table_field_doctype(tablename) for row in rownames: - frappe.db.delete(table, row) + table_rows = local_doc.get(tablename) + child_table_row = get_child_table_row(table_rows, row) + table_rows.remove(child_table_row) + local_doc.set(tablename, table_rows) + return local_doc + + +def get_child_table_row(table_rows, row): + for entry in table_rows: + if entry.get('name') == row: + return entry def update_row_changed(local_doc, changed): @@ -333,13 +362,13 @@ def set_delete(update): def get_updates(producer_site, last_update, doctypes): """Get all updates generated after the last update timestamp""" - docs = producer_site.get_list( - doctype='Event Update Log', - filters={'ref_doctype': ('in', doctypes), 'creation': ('>', last_update)}, - fields=['update_type', 'ref_doctype', 'docname', 'data', 'name', 'creation'] - ) - docs.reverse() - return [frappe._dict(d) for d in docs] + docs = producer_site.post_request({ + 'cmd': 'frappe.event_streaming.doctype.event_update_log.event_update_log.get_update_logs_for_consumer', + 'event_consumer': get_url(), + 'doctypes': frappe.as_json(doctypes), + 'last_update': last_update + }) + return [frappe._dict(d) for d in (docs or [])] def get_local_doc(update): diff --git a/frappe/event_streaming/doctype/event_producer/test_event_producer.py b/frappe/event_streaming/doctype/event_producer/test_event_producer.py index fa2461a9d8..4c259c3729 100644 --- a/frappe/event_streaming/doctype/event_producer/test_event_producer.py +++ b/frappe/event_streaming/doctype/event_producer/test_event_producer.py @@ -152,6 +152,82 @@ class TestEventProducer(unittest.TestCase): reset_configuration(producer_url) + def test_conditional_events(self): + producer = get_remote_site() + + # Add Condition + event_producer = frappe.get_doc('Event Producer', producer_url) + note_producer_entry = [ + x for x in event_producer.producer_doctypes if x.ref_doctype == 'Note' + ][0] + note_producer_entry.condition = 'doc.public == 1' + event_producer.save() + + # Make test doc + producer_note1 = frappe._dict(doctype='Note', public=0, title='test conditional sync') + delete_on_remote_if_exists(producer, 'Note', {'title': producer_note1['title']}) + producer_note1 = producer.insert(producer_note1) + + # Make Update + producer_note1['content'] = 'Test Conditional Sync Content' + producer_note1 = producer.update(producer_note1) + + self.pull_producer_data() + + # Check if synced here + self.assertFalse(frappe.db.exists('Note', producer_note1.name)) + + # Lets satisfy the condition + producer_note1['public'] = 1 + producer_note1 = producer.update(producer_note1) + + self.pull_producer_data() + + # it should sync now + self.assertTrue(frappe.db.exists('Note', producer_note1.name)) + local_note = frappe.get_doc('Note', producer_note1.name) + self.assertEqual(local_note.content, producer_note1.content) + + reset_configuration(producer_url) + + def test_conditional_events_with_cmd(self): + producer = get_remote_site() + + # Add Condition + event_producer = frappe.get_doc('Event Producer', producer_url) + note_producer_entry = [ + x for x in event_producer.producer_doctypes if x.ref_doctype == 'Note' + ][0] + note_producer_entry.condition = 'cmd: frappe.event_streaming.doctype.event_producer.test_event_producer.can_sync_note' + event_producer.save() + + # Make test doc + producer_note1 = frappe._dict(doctype='Note', public=0, title='test conditional sync cmd') + delete_on_remote_if_exists(producer, 'Note', {'title': producer_note1['title']}) + producer_note1 = producer.insert(producer_note1) + + # Make Update + producer_note1['content'] = 'Test Conditional Sync Content' + producer_note1 = producer.update(producer_note1) + + self.pull_producer_data() + + # Check if synced here + self.assertFalse(frappe.db.exists('Note', producer_note1.name)) + + # Lets satisfy the condition + producer_note1['public'] = 1 + producer_note1 = producer.update(producer_note1) + + self.pull_producer_data() + + # it should sync now + self.assertTrue(frappe.db.exists('Note', producer_note1.name)) + local_note = frappe.get_doc('Note', producer_note1.name) + self.assertEqual(local_note.content, producer_note1.content) + + reset_configuration(producer_url) + def test_update_log(self): producer = get_remote_site() producer_doc = insert_into_producer(producer, 'test update log') @@ -221,6 +297,8 @@ class TestEventProducer(unittest.TestCase): reset_configuration(producer_url) +def can_sync_note(consumer, doc, update_log): + return doc.public == 1 def setup_event_producer_for_inner_mapping(): event_producer = frappe.get_doc('Event Producer', producer_url, for_update=True) @@ -322,6 +400,7 @@ def create_event_producer(producer_url): def reset_configuration(producer_url): event_producer = frappe.get_doc('Event Producer', producer_url, for_update=True) event_producer.producer_doctypes = [] + event_producer.conditions = [] event_producer.producer_url = producer_url event_producer.append('producer_doctypes', { 'ref_doctype': 'ToDo', diff --git a/frappe/event_streaming/doctype/event_producer_document_type/event_producer_document_type.json b/frappe/event_streaming/doctype/event_producer_document_type/event_producer_document_type.json index e5fe9497f8..17fd51d12d 100644 --- a/frappe/event_streaming/doctype/event_producer_document_type/event_producer_document_type.json +++ b/frappe/event_streaming/doctype/event_producer_document_type/event_producer_document_type.json @@ -10,7 +10,8 @@ "use_same_name", "unsubscribe", "has_mapping", - "mapping" + "mapping", + "condition" ], "fields": [ { @@ -63,11 +64,16 @@ "fieldtype": "Check", "in_list_view": 1, "label": "Unsubscribe" + }, + { + "fieldname": "condition", + "fieldtype": "Code", + "label": "Condition" } ], "istable": 1, "links": [], - "modified": "2020-08-14 11:38:01.278996", + "modified": "2020-11-07 09:26:58.463868", "modified_by": "Administrator", "module": "Event Streaming", "name": "Event Producer Document Type", diff --git a/frappe/integrations/doctype/twilio_settings/__init__.py b/frappe/event_streaming/doctype/event_producer_last_update/__init__.py similarity index 100% rename from frappe/integrations/doctype/twilio_settings/__init__.py rename to frappe/event_streaming/doctype/event_producer_last_update/__init__.py diff --git a/frappe/event_streaming/doctype/event_producer_last_update/event_producer_last_update.js b/frappe/event_streaming/doctype/event_producer_last_update/event_producer_last_update.js new file mode 100644 index 0000000000..15730e4c5f --- /dev/null +++ b/frappe/event_streaming/doctype/event_producer_last_update/event_producer_last_update.js @@ -0,0 +1,8 @@ +// Copyright (c) 2020, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Event Producer Last Update', { + // refresh: function(frm) { + + // } +}); diff --git a/frappe/custom/doctype/custom_link/custom_link.json b/frappe/event_streaming/doctype/event_producer_last_update/event_producer_last_update.json similarity index 53% rename from frappe/custom/doctype/custom_link/custom_link.json rename to frappe/event_streaming/doctype/event_producer_last_update/event_producer_last_update.json index 350e6b1c2d..27f8ed2f81 100644 --- a/frappe/custom/doctype/custom_link/custom_link.json +++ b/frappe/event_streaming/doctype/event_producer_last_update/event_producer_last_update.json @@ -1,36 +1,36 @@ { "actions": [], - "autoname": "field:document_type", - "creation": "2020-04-08 15:16:44.342509", + "autoname": "field:event_producer", + "creation": "2020-10-26 12:53:11.940177", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "document_type", - "links" + "event_producer", + "last_update" ], "fields": [ { - "fieldname": "document_type", - "fieldtype": "Link", + "fieldname": "event_producer", + "fieldtype": "Data", "in_list_view": 1, - "label": "Document Type", - "options": "DocType", + "label": "Event Producer", "reqd": 1, "unique": 1 }, { - "fieldname": "links", - "fieldtype": "Table", - "label": "Links", - "options": "DocType Link" + "fieldname": "last_update", + "fieldtype": "Data", + "label": "Last Update" } ], + "in_create": 1, + "index_web_pages_for_search": 1, "links": [], - "modified": "2020-04-08 16:42:59.402671", + "modified": "2020-10-26 13:22:27.056599", "modified_by": "Administrator", - "module": "Custom", - "name": "Custom Link", + "module": "Event Streaming", + "name": "Event Producer Last Update", "owner": "Administrator", "permissions": [ { @@ -46,6 +46,7 @@ "write": 1 } ], + "read_only": 1, "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 diff --git a/frappe/custom/doctype/package_publish_target/package_publish_target.py b/frappe/event_streaming/doctype/event_producer_last_update/event_producer_last_update.py similarity index 85% rename from frappe/custom/doctype/package_publish_target/package_publish_target.py rename to frappe/event_streaming/doctype/event_producer_last_update/event_producer_last_update.py index 34eee02562..02e297bdd5 100644 --- a/frappe/custom/doctype/package_publish_target/package_publish_target.py +++ b/frappe/event_streaming/doctype/event_producer_last_update/event_producer_last_update.py @@ -6,5 +6,5 @@ from __future__ import unicode_literals # import frappe from frappe.model.document import Document -class PackagePublishTarget(Document): +class EventProducerLastUpdate(Document): pass diff --git a/frappe/custom/doctype/package_publish_tool/test_package_publish_tool.py b/frappe/event_streaming/doctype/event_producer_last_update/test_event_producer_last_update.py similarity index 77% rename from frappe/custom/doctype/package_publish_tool/test_package_publish_tool.py rename to frappe/event_streaming/doctype/event_producer_last_update/test_event_producer_last_update.py index 8332240543..0311cb2df9 100644 --- a/frappe/custom/doctype/package_publish_tool/test_package_publish_tool.py +++ b/frappe/event_streaming/doctype/event_producer_last_update/test_event_producer_last_update.py @@ -6,5 +6,5 @@ from __future__ import unicode_literals # import frappe import unittest -class TestPackagePublishTool(unittest.TestCase): +class TestEventProducerLastUpdate(unittest.TestCase): pass diff --git a/frappe/event_streaming/doctype/event_update_log/event_update_log.json b/frappe/event_streaming/doctype/event_update_log/event_update_log.json index 452a656b8b..a42bc7ec87 100644 --- a/frappe/event_streaming/doctype/event_update_log/event_update_log.json +++ b/frappe/event_streaming/doctype/event_update_log/event_update_log.json @@ -1,4 +1,5 @@ { + "actions": [], "creation": "2019-07-30 15:31:26.352527", "doctype": "DocType", "editable_grid": 1, @@ -7,7 +8,8 @@ "update_type", "ref_doctype", "docname", - "data" + "data", + "consumers" ], "fields": [ { @@ -31,7 +33,6 @@ "fieldtype": "Data", "in_list_view": 1, "label": "Document Name", - "options": "ref_doctype", "read_only": 1 }, { @@ -39,10 +40,18 @@ "fieldtype": "Code", "label": "Data", "read_only": 1 + }, + { + "fieldname": "consumers", + "fieldtype": "Table MultiSelect", + "label": "Consumers", + "options": "Event Update Log Consumer", + "read_only": 1 } ], "in_create": 1, - "modified": "2019-09-24 23:16:07.207707", + "links": [], + "modified": "2020-09-04 07:31:52.599804", "modified_by": "Administrator", "module": "Event Streaming", "name": "Event Update Log", diff --git a/frappe/event_streaming/doctype/event_update_log/event_update_log.py b/frappe/event_streaming/doctype/event_update_log/event_update_log.py index 646331a02c..1c31718c2b 100644 --- a/frappe/event_streaming/doctype/event_update_log/event_update_log.py +++ b/frappe/event_streaming/doctype/event_update_log/event_update_log.py @@ -140,3 +140,137 @@ def check_docstatus(out, old, new, for_child): if not for_child and old.docstatus != new.docstatus: out.changed['docstatus'] = new.docstatus return out + + +def is_consumer_uptodate(update_log, consumer): + """ + Checks if Consumer has read all the UpdateLogs before the specified update_log + :param update_log: The UpdateLog Doc in context + :param consumer: The EventConsumer doc + """ + if update_log.update_type == 'Create': + # consumer is obviously up to date + return True + + prev_logs = frappe.get_all( + 'Event Update Log', + filters={ + 'ref_doctype': update_log.ref_doctype, + 'docname': update_log.docname, + 'creation': ['<', update_log.creation] + }, + order_by='creation desc', + limit_page_length=1 + ) + + if not len(prev_logs): + return False + + prev_log_consumers = frappe.get_all( + 'Event Update Log Consumer', + fields=['consumer'], + filters={ + 'parent': prev_logs[0].name, + 'parenttype': 'Event Update Log', + 'consumer': consumer.name + } + ) + + return len(prev_log_consumers) > 0 + + +def mark_consumer_read(update_log_name, consumer_name): + """ + This function appends the Consumer to the list of Consumers that has 'read' an Update Log + """ + update_log = frappe.get_doc('Event Update Log', update_log_name) + if len([x for x in update_log.consumers if x.consumer == consumer_name]): + return + + frappe.get_doc(frappe._dict( + doctype='Event Update Log Consumer', + consumer=consumer_name, + parent=update_log_name, + parenttype='Event Update Log', + parentfield='consumers' + )).insert(ignore_permissions=True) + + +def get_unread_update_logs(consumer_name, dt, dn): + """ + Get old logs unread by the consumer on a particular document + """ + already_consumed = [x[0] for x in frappe.db.sql(""" + SELECT + update_log.name + FROM `tabEvent Update Log` update_log + JOIN `tabEvent Update Log Consumer` consumer ON consumer.parent = update_log.name + WHERE + consumer.consumer = %(consumer)s + AND update_log.ref_doctype = %(dt)s + AND update_log.docname = %(dn)s + """, {'consumer': consumer_name, "dt": dt, "dn": dn}, as_dict=0)] + + logs = frappe.get_all( + 'Event Update Log', + fields=['update_type', 'ref_doctype', + 'docname', 'data', 'name', 'creation'], + filters={ + 'ref_doctype': dt, + 'docname': dn, + 'name': ['not in', already_consumed] + }, + order_by='creation' + ) + + return logs + + +@frappe.whitelist() +def get_update_logs_for_consumer(event_consumer, doctypes, last_update): + """ + Fetches all the UpdateLogs for the consumer + It will inject old un-consumed Update Logs if a doc was just found to be accessible to the Consumer + """ + + if isinstance(doctypes, str): + doctypes = frappe.parse_json(doctypes) + + from frappe.event_streaming.doctype.event_consumer.event_consumer import has_consumer_access + + consumer = frappe.get_doc('Event Consumer', event_consumer) + docs = frappe.get_list( + doctype='Event Update Log', + filters={'ref_doctype': ('in', doctypes), + 'creation': ('>', last_update)}, + fields=['update_type', 'ref_doctype', + 'docname', 'data', 'name', 'creation'], + order_by='creation desc' + ) + + result = [] + to_update_history = [] + for d in docs: + if (d.ref_doctype, d.docname) in to_update_history: + # will be notified by background jobs + continue + + if not has_consumer_access(consumer=consumer, update_log=d): + continue + + if not is_consumer_uptodate(d, consumer): + to_update_history.append((d.ref_doctype, d.docname)) + # get_unread_update_logs will have the current log + old_logs = get_unread_update_logs(consumer.name, d.ref_doctype, d.docname) + if old_logs: + old_logs.reverse() + result.extend(old_logs) + else: + result.append(d) + + + for d in result: + mark_consumer_read(update_log_name=d.name, consumer_name=consumer.name) + + result.reverse() + return result \ No newline at end of file diff --git a/frappe/website/doctype/footer_item/__init__.py b/frappe/event_streaming/doctype/event_update_log_consumer/__init__.py similarity index 100% rename from frappe/website/doctype/footer_item/__init__.py rename to frappe/event_streaming/doctype/event_update_log_consumer/__init__.py diff --git a/frappe/event_streaming/doctype/event_update_log_consumer/event_update_log_consumer.json b/frappe/event_streaming/doctype/event_update_log_consumer/event_update_log_consumer.json new file mode 100644 index 0000000000..b3484c6481 --- /dev/null +++ b/frappe/event_streaming/doctype/event_update_log_consumer/event_update_log_consumer.json @@ -0,0 +1,32 @@ +{ + "actions": [], + "creation": "2020-06-30 10:54:53.301787", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "consumer" + ], + "fields": [ + { + "fieldname": "consumer", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Consumer", + "options": "Event Consumer", + "reqd": 1 + } + ], + "istable": 1, + "links": [], + "modified": "2020-06-30 10:54:53.301787", + "modified_by": "Administrator", + "module": "Event Streaming", + "name": "Event Update Log Consumer", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/custom/doctype/package_document_type/package_document_type.py b/frappe/event_streaming/doctype/event_update_log_consumer/event_update_log_consumer.py similarity index 85% rename from frappe/custom/doctype/package_document_type/package_document_type.py rename to frappe/event_streaming/doctype/event_update_log_consumer/event_update_log_consumer.py index 6e166eecbd..ee6d5d8ca9 100644 --- a/frappe/custom/doctype/package_document_type/package_document_type.py +++ b/frappe/event_streaming/doctype/event_update_log_consumer/event_update_log_consumer.py @@ -6,5 +6,5 @@ from __future__ import unicode_literals # import frappe from frappe.model.document import Document -class PackageDocumentType(Document): +class EventUpdateLogConsumer(Document): pass diff --git a/frappe/exceptions.py b/frappe/exceptions.py index 88428b875c..ab65e6e006 100644 --- a/frappe/exceptions.py +++ b/frappe/exceptions.py @@ -33,6 +33,9 @@ class PermissionError(Exception): class DoesNotExistError(ValidationError): http_status_code = 404 +class PageDoesNotExistError(ValidationError): + http_status_code = 404 + class NameError(Exception): http_status_code = 409 @@ -76,6 +79,7 @@ class UnknownDomainError(Exception): pass class MappingMismatchError(ValidationError): pass class InvalidStatusError(ValidationError): pass class MandatoryError(ValidationError): pass +class NonNegativeError(ValidationError): pass class InvalidSignatureError(ValidationError): pass class RateLimitExceededError(ValidationError): pass class CannotChangeConstantError(ValidationError): pass @@ -105,7 +109,10 @@ class InvalidDates(ValidationError): pass class DataTooLongException(ValidationError): pass class FileAlreadyAttachedException(Exception): pass class DocumentAlreadyRestored(Exception): pass +class AttachmentLimitReached(Exception): pass # OAuth exceptions class InvalidAuthorizationHeader(CSRFTokenError): pass class InvalidAuthorizationPrefix(CSRFTokenError): pass class InvalidAuthorizationToken(CSRFTokenError): pass +class InvalidDatabaseFile(ValidationError): pass +class ExecutableNotFound(FileNotFoundError): pass diff --git a/frappe/geo/country_info.json b/frappe/geo/country_info.json index 6a56107333..1e0ae161bc 100644 --- a/frappe/geo/country_info.json +++ b/frappe/geo/country_info.json @@ -558,6 +558,8 @@ "code": "cn", "currency": "CNY", "currency_name": "Yuan Renminbi", + "currency_fraction_units": 100, + "smallest_currency_fraction_value": 0.01, "date_format": "yyyy-mm-dd", "number_format": "#,###.##", "timezones": [ @@ -1389,7 +1391,10 @@ "code": "la", "currency": "LAK", "currency_name": "Kip", - "number_format": "#,###.##" + "number_format": "#,###.##", + "timezones":[ + "Asia/Vientiane" + ] }, "Latvia": { "code": "lv", @@ -2724,11 +2729,11 @@ }, "Zimbabwe": { "code": "zw", - "currency": "ZWD", - "currency_fraction": "Thebe", + "currency": "ZWL", + "currency_fraction": "Cent", "currency_fraction_units": 100, "currency_name": "Zimbabwe Dollar", - "currency_symbol": "P", + "currency_symbol": "ZWL$", "number_format": "# ###.##", "timezones": [ "Africa/Harare" diff --git a/frappe/geo/doctype/currency/currency.json b/frappe/geo/doctype/currency/currency.json index bb9abb7ce8..db3fa5a19f 100644 --- a/frappe/geo/doctype/currency/currency.json +++ b/frappe/geo/doctype/currency/currency.json @@ -1,345 +1,113 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, + "actions": [], "allow_import": 1, "allow_rename": 1, "autoname": "field:currency_name", - "beta": 0, "creation": "2013-01-28 10:06:02", - "custom": 0, "description": "**Currency** Master", - "docstatus": 0, "doctype": "DocType", "document_type": "Setup", - "editable_grid": 0, + "engine": "InnoDB", + "field_order": [ + "currency_name", + "enabled", + "fraction", + "fraction_units", + "smallest_currency_fraction_value", + "symbol", + "number_format" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "currency_name", "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, "label": "Currency Name", - "length": 0, - "no_copy": 0, "oldfieldname": "currency_name", "oldfieldtype": "Data", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, "unique": 1 }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, + "default": "0", "fieldname": "enabled", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, - "label": "Enabled", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Enabled" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "description": "Sub-currency. For e.g. \"Cent\"", "fieldname": "fraction", "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, - "label": "Fraction", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Fraction" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "description": "1 Currency = [?] Fraction\nFor e.g. 1 USD = 100 Cent", "fieldname": "fraction_units", "fieldtype": "Int", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, - "label": "Fraction Units", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Fraction Units" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "description": "Smallest circulating fraction unit (coin). For e.g. 1 cent for USD and it should be entered as 0.01", "fieldname": "smallest_currency_fraction_value", "fieldtype": "Currency", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, "label": "Smallest Currency Fraction Value", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "non_negative": 1 }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "description": "A symbol for this currency. For e.g. $", "fieldname": "symbol", "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, - "label": "Symbol", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Symbol" }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "description": "How should this currency be formatted? If not set, will use system defaults", "fieldname": "number_format", "fieldtype": "Select", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, "label": "Number Format", - "length": 0, - "no_copy": 0, - "options": "\n#,###.##\n#.###,##\n# ###.##\n# ###,##\n#'###.##\n#, ###.##\n#,##,###.##\n#,###.###\n#.###\n#,###", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "options": "\n#,###.##\n#.###,##\n# ###.##\n# ###,##\n#'###.##\n#, ###.##\n#,##,###.##\n#,###.###\n#.###\n#,###" } ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, "icon": "fa fa-bitcoin", "idx": 1, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2018-08-29 06:37:19.908254", + "index_web_pages_for_search": 1, + "links": [], + "modified": "2020-10-29 06:33:12.879978", "modified_by": "Administrator", "module": "Geo", "name": "Currency", "owner": "Administrator", "permissions": [ { - "amend": 0, - "cancel": 0, "create": 1, "delete": 1, "email": 1, "export": 1, - "if_owner": 0, "import": 1, - "permlevel": 0, "print": 1, "read": 1, "report": 1, "role": "System Manager", - "set_user_permissions": 0, "share": 1, - "submit": 0, "write": 1 }, { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 0, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 0, "read": 1, - "report": 0, - "role": "Accounts User", - "set_user_permissions": 0, - "share": 0, - "submit": 0, - "write": 0 + "role": "Accounts User" }, { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 0, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 0, "read": 1, - "report": 0, - "role": "Sales User", - "set_user_permissions": 0, - "share": 0, - "submit": 0, - "write": 0 + "role": "Sales User" }, { - "amend": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 0, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 0, "read": 1, - "report": 0, - "role": "Purchase User", - "set_user_permissions": 0, - "share": 0, - "submit": 0, - "write": 0 + "role": "Purchase User" } ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, + "sort_field": "modified", "sort_order": "DESC", - "track_changes": 1, - "track_seen": 0, - "track_views": 0 + "track_changes": 1 } \ No newline at end of file diff --git a/frappe/geo/utils.py b/frappe/geo/utils.py new file mode 100644 index 0000000000..d94a13ea41 --- /dev/null +++ b/frappe/geo/utils.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals + +import frappe + +from pymysql import InternalError + + +@frappe.whitelist() +def get_coords(doctype, filters, type): + '''Get a geojson dict representing a doctype.''' + filters_sql = get_coords_conditions(doctype, filters)[4:] + + coords = None + if type == 'location_field': + coords = return_location(doctype, filters_sql) + elif type == 'coordinates': + coords = return_coordinates(doctype, filters_sql) + + out = convert_to_geojson(type, coords) + return out + +def convert_to_geojson(type, coords): + '''Converts GPS coordinates to geoJSON string.''' + geojson = {"type": "FeatureCollection", "features": None} + + if type == 'location_field': + geojson['features'] = merge_location_features_in_one(coords) + elif type == 'coordinates': + geojson['features'] = create_gps_markers(coords) + + return geojson + + +def merge_location_features_in_one(coords): + '''Merging all features from location field.''' + geojson_dict = [] + for element in coords: + geojson_loc = frappe.parse_json(element['location']) + if not geojson_loc: + continue + for coord in geojson_loc['features']: + coord['properties']['name'] = element['name'] + geojson_dict.append(coord.copy()) + + return geojson_dict + + +def create_gps_markers(coords): + '''Build Marker based on latitude and longitude.''' + geojson_dict = [] + for i in coords: + node = {"type": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": None}} + node['properties']['name'] = i.name + node['geometry']['coordinates'] = [i.latitude, i.longitude] + geojson_dict.append(node.copy()) + + return geojson_dict + + +def return_location(doctype, filters_sql): + '''Get name and location fields for Doctype.''' + if filters_sql: + try: + coords = frappe.db.sql('''SELECT name, location FROM `tab{}` WHERE {}'''.format(doctype, filters_sql), as_dict=True) + except InternalError: + frappe.msgprint(frappe._('This Doctype does not contain location fields'), raise_exception=True) + return + else: + coords = frappe.get_all(doctype, fields=['name', 'location']) + return coords + + +def return_coordinates(doctype, filters_sql): + '''Get name, latitude and longitude fields for Doctype.''' + if filters_sql: + try: + coords = frappe.db.sql('''SELECT name, latitude, longitude FROM `tab{}` WHERE {}'''.format(doctype, filters_sql), as_dict=True) + except InternalError: + frappe.msgprint(frappe._('This Doctype does not contain latitude and longitude fields'), raise_exception=True) + return + else: + coords = frappe.get_all(doctype, fields=['name', 'latitude', 'longitude']) + return coords + + +def get_coords_conditions(doctype, filters=None): + '''Returns SQL conditions with user permissions and filters for event queries.''' + from frappe.desk.reportview import get_filters_cond + if not frappe.has_permission(doctype): + frappe.throw(frappe._("Not Permitted"), frappe.PermissionError) + + return get_filters_cond(doctype, filters, [], with_match_conditions=True) diff --git a/frappe/hooks.py b/frappe/hooks.py index dd0c22f252..3e206f0ad3 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -10,7 +10,7 @@ app_icon = "octicon octicon-circuit-board" app_color = "orange" source_link = "https://github.com/frappe/frappe" app_license = "MIT" -app_logo_url = '/assets/frappe/images/frappe-framework-logo.png' +app_logo_url = '/assets/frappe/images/frappe-framework-logo.svg' develop_version = '13.x.x-develop' @@ -18,7 +18,7 @@ app_email = "info@frappe.io" docs_app = "frappe_io" -translator_url = "https://translatev2.erpnext.com" +translator_url = "https://translate.erpnext.com" before_install = "frappe.utils.install.before_install" after_install = "frappe.utils.install.after_install" @@ -29,18 +29,17 @@ page_js = { # website app_include_js = [ - "assets/js/libs.min.js", - "assets/js/desk.min.js", - "assets/js/list.min.js", - "assets/js/form.min.js", - "assets/js/control.min.js", - "assets/js/report.min.js", + "/assets/js/libs.min.js", + "/assets/js/desk.min.js", + "/assets/js/list.min.js", + "/assets/js/form.min.js", + "/assets/js/control.min.js", + "/assets/js/report.min.js", ] app_include_css = [ - "assets/css/desk.min.css", - "assets/css/list.min.css", - "assets/css/form.min.css", - "assets/css/report.min.css", + "/assets/css/desk.min.css", + "/assets/css/list.min.css", + "/assets/css/report.min.css", ] doctype_js = { @@ -59,6 +58,11 @@ website_route_rules = [ {"from_route": "/kb/", "to_route": "Help Article"}, {"from_route": "/newsletters", "to_route": "Newsletter"}, {"from_route": "/profile", "to_route": "me"}, + {"from_route": "/app/", "to_route": "app"}, +] + +website_redirects = [ + {"source": r"/desk(.*)", "target": r"/app\1"}, ] base_template = "templates/base.html" @@ -94,6 +98,7 @@ permission_query_conditions = { "User": "frappe.core.doctype.user.user.get_permission_query_conditions", "Dashboard Settings": "frappe.desk.doctype.dashboard_settings.dashboard_settings.get_permission_query_conditions", "Notification Log": "frappe.desk.doctype.notification_log.notification_log.get_permission_query_conditions", + "Dashboard": "frappe.desk.doctype.dashboard.dashboard.get_permission_query_conditions", "Dashboard Chart": "frappe.desk.doctype.dashboard_chart.dashboard_chart.get_permission_query_conditions", "Number Card": "frappe.desk.doctype.number_card.number_card.get_permission_query_conditions", "Notification Settings": "frappe.desk.doctype.notification_settings.notification_settings.get_permission_query_conditions", @@ -140,9 +145,9 @@ doc_events = { "frappe.core.doctype.activity_log.feed.update_feed", "frappe.workflow.doctype.workflow_action.workflow_action.process_workflow_actions", "frappe.automation.doctype.assignment_rule.assignment_rule.apply", - "frappe.automation.doctype.milestone_tracker.milestone_tracker.evaluate_milestone", "frappe.core.doctype.file.file.attach_files_to_document", "frappe.event_streaming.doctype.event_update_log.event_update_log.notify_consumers", + "frappe.automation.doctype.assignment_rule.assignment_rule.update_due_date", ], "after_rename": "frappe.desk.notifications.clear_doctype_notifications", "on_cancel": [ @@ -155,7 +160,8 @@ doc_events = { "frappe.event_streaming.doctype.event_update_log.event_update_log.notify_consumers" ], "on_change": [ - "frappe.social.doctype.energy_point_rule.energy_point_rule.process_energy_points" + "frappe.social.doctype.energy_point_rule.energy_point_rule.process_energy_points", + "frappe.automation.doctype.milestone_tracker.milestone_tracker.evaluate_milestone" ] }, "Event": { @@ -205,7 +211,7 @@ scheduler_events = { "frappe.utils.password.delete_password_reset_cache" ], "daily": [ - "frappe.email.queue.clear_outbox", + "frappe.email.queue.set_expiry_for_email_queue", "frappe.desk.notifications.clear_notifications", "frappe.core.doctype.error_log.error_log.set_old_logs_as_seen", "frappe.desk.doctype.event.event.send_event_digest", @@ -214,7 +220,6 @@ scheduler_events = { "frappe.realtime.remove_old_task_logs", "frappe.utils.scheduler.restrict_scheduler_events_if_dormant", "frappe.email.doctype.auto_email_report.auto_email_report.send_daily", - "frappe.core.doctype.activity_log.activity_log.clear_authentication_logs", "frappe.website.doctype.personal_data_deletion_request.personal_data_deletion_request.remove_unverified_record", "frappe.desk.form.document_follow.send_daily_updates", "frappe.social.doctype.energy_point_settings.energy_point_settings.allocate_review_points", @@ -222,7 +227,8 @@ scheduler_events = { "frappe.automation.doctype.auto_repeat.auto_repeat.make_auto_repeat_entry", "frappe.automation.doctype.auto_repeat.auto_repeat.set_auto_repeat_as_completed", "frappe.email.doctype.unhandled_email.unhandled_email.remove_old_unhandled_emails", - "frappe.core.doctype.prepared_report.prepared_report.delete_expired_prepared_reports" + "frappe.core.doctype.prepared_report.prepared_report.delete_expired_prepared_reports", + "frappe.core.doctype.log_settings.log_settings.run_log_clean_up" ], "daily_long": [ "frappe.integrations.doctype.dropbox_settings.dropbox_settings.take_backups_daily", diff --git a/frappe/installer.py b/frappe/installer.py index 2a912695e5..0cd5b136ae 100755 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -3,8 +3,90 @@ import json import os +import sys import frappe +from frappe.defaults import _clear_cache + + +def _new_site( + db_name, + site, + mariadb_root_username=None, + mariadb_root_password=None, + admin_password=None, + verbose=False, + install_apps=None, + source_sql=None, + force=False, + no_mariadb_socket=False, + reinstall=False, + db_password=None, + db_type=None, + db_host=None, + db_port=None, + new_site=False, +): + """Install a new Frappe site""" + + if not force and os.path.exists(site): + print("Site {0} already exists".format(site)) + sys.exit(1) + + if no_mariadb_socket and not db_type == "mariadb": + print("--no-mariadb-socket requires db_type to be set to mariadb.") + sys.exit(1) + + if not db_name: + import hashlib + db_name = "_" + hashlib.sha1(site.encode()).hexdigest()[:16] + + frappe.init(site=site) + + from frappe.commands.scheduler import _is_scheduler_enabled + from frappe.utils import get_site_path, scheduler, touch_file + + try: + # enable scheduler post install? + enable_scheduler = _is_scheduler_enabled() + except Exception: + enable_scheduler = False + + make_site_dirs() + + installing = touch_file(get_site_path("locks", "installing.lock")) + + install_db( + root_login=mariadb_root_username, + root_password=mariadb_root_password, + db_name=db_name, + admin_password=admin_password, + verbose=verbose, + source_sql=source_sql, + force=force, + reinstall=reinstall, + db_password=db_password, + db_type=db_type, + db_host=db_host, + db_port=db_port, + no_mariadb_socket=no_mariadb_socket, + ) + apps_to_install = ( + ["frappe"] + (frappe.conf.get("install_apps") or []) + (list(install_apps) or []) + ) + + for app in apps_to_install: + install_app(app, verbose=verbose, set_as_patched=not source_sql) + + os.remove(installing) + + scheduler.toggle_scheduler(enable_scheduler) + frappe.db.commit() + + scheduler_status = ( + "disabled" if frappe.utils.scheduler.is_scheduler_disabled() else "enabled" + ) + print("*** Scheduler is", scheduler_status, "***") def install_db(root_login="root", root_password=None, db_name=None, source_sql=None, @@ -36,9 +118,9 @@ def install_db(root_login="root", root_password=None, db_name=None, source_sql=N def install_app(name, verbose=False, set_as_patched=True): from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs - from frappe.utils.fixtures import sync_fixtures from frappe.model.sync import sync_for from frappe.modules.utils import sync_customizations + from frappe.utils.fixtures import sync_fixtures frappe.flags.in_install = name frappe.flags.ignore_in_install = False @@ -111,8 +193,8 @@ def remove_from_installed_apps(app_name): installed_apps = frappe.get_installed_apps() if app_name in installed_apps: installed_apps.remove(app_name) - frappe.db.set_global("installed_apps", json.dumps(installed_apps)) - frappe.get_single("Installed Applications").update_versions() + frappe.db.set_value("DefaultValue", {"defkey": "installed_apps"}, "defvalue", json.dumps(installed_apps)) + _clear_cache("__global") frappe.db.commit() if frappe.flags.in_install: post_install() @@ -122,64 +204,80 @@ def remove_app(app_name, dry_run=False, yes=False, no_backup=False, force=False) """Remove app and all linked to the app's module with the app from a site.""" import click + site = frappe.local.site + # dont allow uninstall app if not installed unless forced if not force: if app_name not in frappe.get_installed_apps(): - click.secho("App {0} not installed on Site {1}".format(app_name, frappe.local.site), fg="yellow") + click.secho(f"App {app_name} not installed on Site {site}", fg="yellow") return - print("Uninstalling App {0} from Site {1}...".format(app_name, frappe.local.site)) + print(f"Uninstalling App {app_name} from Site {site}...") if not dry_run and not yes: - confirm = click.confirm("All doctypes (including custom), modules related to this app will be deleted. Are you sure you want to continue?") + confirm = click.confirm( + "All doctypes (including custom), modules related to this app will be" + " deleted. Are you sure you want to continue?" + ) if not confirm: return - if not no_backup: + if not (dry_run or no_backup): from frappe.utils.backups import scheduled_backup + print("Backing up...") scheduled_backup(ignore_files=True) frappe.flags.in_uninstall = True drop_doctypes = [] - modules = (x.name for x in frappe.get_all("Module Def", filters={"app_name": app_name})) + modules = frappe.get_all("Module Def", filters={"app_name": app_name}, pluck="name") for module_name in modules: - print("Deleting Module '{0}'".format(module_name)) + print(f"Deleting Module '{module_name}'") - for doctype in frappe.get_list("DocType", filters={"module": module_name}, fields=["name", "issingle"]): - print("* removing DocType '{0}'...".format(doctype.name)) + for doctype in frappe.get_all( + "DocType", filters={"module": module_name}, fields=["name", "issingle"] + ): + print(f"* removing DocType '{doctype.name}'...") if not dry_run: - frappe.delete_doc("DocType", doctype.name) + frappe.delete_doc("DocType", doctype.name, ignore_on_trash=True) if not doctype.issingle: drop_doctypes.append(doctype.name) - linked_doctypes = frappe.get_all("DocField", filters={"fieldtype": "Link", "options": "Module Def"}, fields=['parent']) - ordered_doctypes = ["Desk Page", "Report", "Page", "Web Form"] - doctypes_with_linked_modules = ordered_doctypes + [doctype.parent for doctype in linked_doctypes if doctype.parent not in ordered_doctypes] - + linked_doctypes = frappe.get_all( + "DocField", filters={"fieldtype": "Link", "options": "Module Def"}, fields=["parent"] + ) + ordered_doctypes = ["Workspace", "Report", "Page", "Web Form"] + all_doctypes_with_linked_modules = ordered_doctypes + [ + doctype.parent + for doctype in linked_doctypes + if doctype.parent not in ordered_doctypes + ] + doctypes_with_linked_modules = [ + x for x in all_doctypes_with_linked_modules if frappe.db.exists("DocType", x) + ] for doctype in doctypes_with_linked_modules: - for record in frappe.get_list(doctype, filters={"module": module_name}): - print("* removing {0} '{1}'...".format(doctype, record.name)) + for record in frappe.get_all(doctype, filters={"module": module_name}, pluck="name"): + print(f"* removing {doctype} '{record}'...") if not dry_run: - frappe.delete_doc(doctype, record.name) + frappe.delete_doc(doctype, record, ignore_on_trash=True, force=True) - print("* removing Module Def '{0}'...".format(module_name)) + print(f"* removing Module Def '{module_name}'...") if not dry_run: - frappe.delete_doc("Module Def", module_name) + frappe.delete_doc("Module Def", module_name, ignore_on_trash=True, force=True) + + for doctype in set(drop_doctypes): + print(f"* dropping Table for '{doctype}'...") + if not dry_run: + frappe.db.sql_ddl(f"drop table `tab{doctype}`") if not dry_run: remove_from_installed_apps(app_name) - - for doctype in set(drop_doctypes): - print("* dropping Table for '{0}'...".format(doctype)) - frappe.db.sql("drop table `tab{0}`".format(doctype)) - frappe.db.commit() - click.secho("Uninstalled App {0} from Site {1}".format(app_name, frappe.local.site), fg="green") + click.secho(f"Uninstalled App {app_name} from Site {site}", fg="green") frappe.flags.in_uninstall = False @@ -331,6 +429,28 @@ def remove_missing_apps(): frappe.db.set_global("installed_apps", json.dumps(installed_apps)) +def extract_sql_from_archive(sql_file_path): + """Return the path of an SQL file if the passed argument is the path of a gzipped + SQL file or an SQL file path. The path may be absolute or relative from the bench + root directory or the sites sub-directory. + + Args: + sql_file_path (str): Path of the SQL file + + Returns: + str: Path of the decompressed SQL file + """ + from frappe.utils import get_bench_relative_path + sql_file_path = get_bench_relative_path(sql_file_path) + # Extract the gzip file if user has passed *.sql.gz file instead of *.sql file + if sql_file_path.endswith('sql.gz'): + decompressed_file_name = extract_sql_gzip(sql_file_path) + else: + decompressed_file_name = sql_file_path + + return decompressed_file_name + + def extract_sql_gzip(sql_gz_path): import subprocess @@ -346,9 +466,12 @@ def extract_sql_gzip(sql_gz_path): return decompressed_file -def extract_tar_files(site_name, file_path, folder_name): - import subprocess +def extract_files(site_name, file_path): import shutil + import subprocess + from frappe.utils import get_bench_relative_path + + file_path = get_bench_relative_path(file_path) # Need to do frappe.init to maintain the site locals frappe.init(site=site_name) @@ -362,7 +485,10 @@ def extract_tar_files(site_name, file_path, folder_name): tar_path = os.path.join(abs_site_path, tar_name) try: - subprocess.check_output(['tar', 'xvf', tar_path, '--strip', '2'], cwd=abs_site_path) + if file_path.endswith(".tar"): + subprocess.check_output(['tar', 'xvf', tar_path, '--strip', '2'], cwd=abs_site_path) + elif file_path.endswith(".tgz"): + subprocess.check_output(['tar', 'zxvf', tar_path, '--strip', '2'], cwd=abs_site_path) except: raise finally: @@ -373,6 +499,12 @@ def extract_tar_files(site_name, file_path, folder_name): def is_downgrade(sql_file_path, verbose=False): """checks if input db backup will get downgraded on current bench""" + + # This function is only tested with mariadb + # TODO: Add postgres support + if frappe.conf.db_type not in (None, "mariadb"): + return False + from semantic_version import Version head = "INSERT INTO `tabInstalled Application` VALUES" @@ -404,3 +536,69 @@ def is_downgrade(sql_file_path, verbose=False): print("Your site will be downgraded from Frappe {0} to {1}".format(current_version, backup_version)) return downgrade + + +def is_partial(sql_file_path): + with open(sql_file_path) as f: + header = " ".join([f.readline() for _ in range(5)]) + if "Partial Backup" in header: + return True + return False + + +def partial_restore(sql_file_path, verbose=False): + sql_file = extract_sql_from_archive(sql_file_path) + + if frappe.conf.db_type in (None, "mariadb"): + from frappe.database.mariadb.setup_db import import_db_from_sql + elif frappe.conf.db_type == "postgres": + from frappe.database.postgres.setup_db import import_db_from_sql + import warnings + from click import style + warn = style( + "Delete the tables you want to restore manually before attempting" + " partial restore operation for PostreSQL databases", + fg="yellow" + ) + warnings.warn(warn) + + import_db_from_sql(source_sql=sql_file, verbose=verbose) + + # Removing temporarily created file + if sql_file != sql_file_path: + os.remove(sql_file) + + +def validate_database_sql(path, _raise=True): + """Check if file has contents and if DefaultValue table exists + + Args: + path (str): Path of the decompressed SQL file + _raise (bool, optional): Raise exception if invalid file. Defaults to True. + """ + empty_file = False + missing_table = True + + error_message = "" + + if not os.path.getsize(path): + error_message = f"{path} is an empty file!" + empty_file = True + + # dont bother checking if empty file + if not empty_file: + with open(path, "r") as f: + for line in f: + if 'tabDefaultValue' in line: + missing_table = False + break + + if missing_table: + error_message = "Table `tabDefaultValue` not found in file." + + if error_message: + import click + click.secho(error_message, fg="red") + + if _raise and (missing_table or empty_file): + raise frappe.InvalidDatabaseFile diff --git a/frappe/integrations/desk_page/integrations/integrations.json b/frappe/integrations/desk_page/integrations/integrations.json deleted file mode 100644 index cbf7c9c085..0000000000 --- a/frappe/integrations/desk_page/integrations/integrations.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "cards": [ - { - "hidden": 0, - "label": "Backup", - "links": "[\n {\n \"description\": \"Dropbox backup settings\",\n \"label\": \"Dropbox Settings\",\n \"name\": \"Dropbox Settings\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"S3 Backup Settings\",\n \"label\": \"S3 Backup Settings\",\n \"name\": \"S3 Backup Settings\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Google Drive Backup.\",\n \"label\": \"Google Drive\",\n \"name\": \"Google Drive\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Google Services", - "links": "[\n {\n \"description\": \"Google API Settings.\",\n \"label\": \"Google Settings\",\n \"name\": \"Google Settings\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Google Contacts Integration.\",\n \"label\": \"Google Contacts\",\n \"name\": \"Google Contacts\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Google Calendar Integration.\",\n \"label\": \"Google Calendar\",\n \"name\": \"Google Calendar\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Google Drive Integration.\",\n \"label\": \"Google Drive\",\n \"name\": \"Google Drive\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Authentication", - "links": "[\n {\n \"description\": \"Enter keys to enable login via Facebook, Google, GitHub.\",\n \"label\": \"Social Login Key\",\n \"name\": \"Social Login Key\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Ldap settings\",\n \"label\": \"LDAP Settings\",\n \"name\": \"LDAP Settings\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Register OAuth Client App\",\n \"label\": \"OAuth Client\",\n \"name\": \"OAuth Client\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Settings for OAuth Provider\",\n \"label\": \"OAuth Provider Settings\",\n \"name\": \"OAuth Provider Settings\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Payments", - "links": "[\n {\n \"description\": \"Braintree payment gateway settings\",\n \"label\": \"Braintree Settings\",\n \"name\": \"Braintree Settings\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"PayPal payment gateway settings\",\n \"label\": \"PayPal Settings\",\n \"name\": \"PayPal Settings\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Razorpay Payment gateway settings\",\n \"label\": \"Razorpay Settings\",\n \"name\": \"Razorpay Settings\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Stripe payment gateway settings\",\n \"label\": \"Stripe Settings\",\n \"name\": \"Stripe Settings\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Paytm payment gateway settings\",\n \"label\": \"Paytm Settings\",\n \"name\": \"Paytm Settings\",\n \"type\": \"doctype\"\n }\n]" - }, - { - "hidden": 0, - "label": "Settings", - "links": "[\n {\n \"description\": \"Webhooks calling API requests into web apps\",\n \"label\": \"Webhook\",\n \"name\": \"Webhook\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Slack Webhooks for internal integration\",\n \"label\": \"Slack Webhook URL\",\n \"name\": \"Slack Webhook URL\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Twilio Settings for WhatsApp integration\",\n \"label\": \"Twilio Settings\",\n \"name\": \"Twilio Settings\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"SMS Settings for sending sms\",\n \"label\": \"SMS Settings\",\n \"name\": \"SMS Settings\",\n \"type\": \"doctype\"\n }\n]" - } - ], - "category": "Administration", - "charts": [], - "creation": "2020-03-02 15:16:18.714190", - "developer_mode_only": 0, - "disable_user_customization": 1, - "docstatus": 0, - "doctype": "Desk Page", - "extends_another_page": 0, - "hide_custom": 0, - "idx": 0, - "is_standard": 1, - "label": "Integrations", - "modified": "2020-08-20 23:04:04.528572", - "modified_by": "Administrator", - "module": "Integrations", - "name": "Integrations", - "owner": "Administrator", - "pin_to_bottom": 0, - "pin_to_top": 0, - "shortcuts": [] -} \ No newline at end of file diff --git a/frappe/public/css/variables.css b/frappe/integrations/doctype/connected_app/__init__.py similarity index 100% rename from frappe/public/css/variables.css rename to frappe/integrations/doctype/connected_app/__init__.py diff --git a/frappe/integrations/doctype/connected_app/connected_app.js b/frappe/integrations/doctype/connected_app/connected_app.js new file mode 100644 index 0000000000..4d20f65559 --- /dev/null +++ b/frappe/integrations/doctype/connected_app/connected_app.js @@ -0,0 +1,38 @@ +// Copyright (c) 2019, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Connected App', { + refresh: frm => { + frm.add_custom_button(__('Get OpenID Configuration'), async () => { + if (!frm.doc.openid_configuration) { + frappe.msgprint(__('Please enter OpenID Configuration URL')); + } else { + try { + const response = await fetch(frm.doc.openid_configuration); + const oidc = await response.json(); + frm.set_value('authorization_uri', oidc.authorization_endpoint); + frm.set_value('token_uri', oidc.token_endpoint); + frm.set_value('userinfo_uri', oidc.userinfo_endpoint); + frm.set_value('introspection_uri', oidc.introspection_endpoint); + frm.set_value('revocation_uri', oidc.revocation_endpoint); + } catch (error) { + frappe.msgprint(__('Please check OpenID Configuration URL')); + } + } + }); + + if (!frm.is_new()) { + frm.add_custom_button(__('Connect to {}', [frm.doc.provider_name]), async () => { + frappe.call({ + method: 'initiate_web_application_flow', + doc: frm.doc, + callback: function(r) { + window.open(r.message, '_blank'); + } + }); + }); + } + + frm.toggle_display('sb_client_credentials_section', !frm.is_new()); + } +}); diff --git a/frappe/integrations/doctype/connected_app/connected_app.json b/frappe/integrations/doctype/connected_app/connected_app.json new file mode 100644 index 0000000000..e5dbb0472a --- /dev/null +++ b/frappe/integrations/doctype/connected_app/connected_app.json @@ -0,0 +1,166 @@ +{ + "actions": [], + "beta": 1, + "creation": "2019-01-24 15:51:06.362222", + "doctype": "DocType", + "document_type": "Document", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "provider_name", + "cb_00", + "openid_configuration", + "sb_client_credentials_section", + "client_id", + "redirect_uri", + "cb_01", + "client_secret", + "sb_scope_section", + "scopes", + "sb_endpoints_section", + "authorization_uri", + "token_uri", + "revocation_uri", + "cb_02", + "userinfo_uri", + "introspection_uri", + "section_break_18", + "query_parameters" + ], + "fields": [ + { + "fieldname": "provider_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Provider Name", + "reqd": 1 + }, + { + "fieldname": "cb_00", + "fieldtype": "Column Break" + }, + { + "fieldname": "openid_configuration", + "fieldtype": "Data", + "label": "OpenID Configuration" + }, + { + "collapsible": 1, + "fieldname": "sb_client_credentials_section", + "fieldtype": "Section Break", + "label": "Client Credentials" + }, + { + "fieldname": "client_id", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Client Id" + }, + { + "fieldname": "redirect_uri", + "fieldtype": "Data", + "label": "Redirect URI", + "read_only": 1 + }, + { + "fieldname": "cb_01", + "fieldtype": "Column Break" + }, + { + "fieldname": "client_secret", + "fieldtype": "Password", + "label": "Client Secret" + }, + { + "collapsible": 1, + "fieldname": "sb_scope_section", + "fieldtype": "Section Break", + "label": "Scopes" + }, + { + "collapsible": 1, + "fieldname": "sb_endpoints_section", + "fieldtype": "Section Break", + "label": "Endpoints" + }, + { + "fieldname": "cb_02", + "fieldtype": "Column Break" + }, + { + "fieldname": "scopes", + "fieldtype": "Table", + "label": "Scopes", + "options": "OAuth Scope" + }, + { + "fieldname": "authorization_uri", + "fieldtype": "Data", + "label": "Authorization URI" + }, + { + "fieldname": "token_uri", + "fieldtype": "Data", + "label": "Token URI" + }, + { + "fieldname": "revocation_uri", + "fieldtype": "Data", + "label": "Revocation URI" + }, + { + "fieldname": "userinfo_uri", + "fieldtype": "Data", + "label": "Userinfo URI" + }, + { + "fieldname": "introspection_uri", + "fieldtype": "Data", + "label": "Introspection URI" + }, + { + "fieldname": "section_break_18", + "fieldtype": "Section Break", + "label": "Extra Parameters" + }, + { + "fieldname": "query_parameters", + "fieldtype": "Table", + "label": "Query Parameters", + "options": "Query Parameters" + } + ], + "links": [ + { + "link_doctype": "Token Cache", + "link_fieldname": "connected_app" + } + ], + "modified": "2020-11-16 16:29:50.277405", + "modified_by": "Administrator", + "module": "Integrations", + "name": "Connected App", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "read": 1, + "role": "All" + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "title_field": "provider_name", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/integrations/doctype/connected_app/connected_app.py b/frappe/integrations/doctype/connected_app/connected_app.py new file mode 100644 index 0000000000..ec08f8e4be --- /dev/null +++ b/frappe/integrations/doctype/connected_app/connected_app.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2019, Frappe Technologies and contributors +# For license information, please see license.txt + +import os +from urllib.parse import urljoin +from urllib.parse import urlencode + +import frappe +from frappe import _ +from frappe.model.document import Document +from requests_oauthlib import OAuth2Session + +if any((os.getenv('CI'), frappe.conf.developer_mode, frappe.conf.allow_tests)): + # Disable mandatory TLS in developer mode and tests + os.environ['OAUTHLIB_INSECURE_TRANSPORT'] = '1' + +class ConnectedApp(Document): + """Connect to a remote oAuth Server. Retrieve and store user's access token + in a Token Cache. + """ + + def validate(self): + base_url = frappe.utils.get_url() + callback_path = '/api/method/frappe.integrations.doctype.connected_app.connected_app.callback/' + self.name + self.redirect_uri = urljoin(base_url, callback_path) + + def get_oauth2_session(self, user=None, init=False): + token = None + token_updater = None + + if not init: + user = user or frappe.session.user + token_cache = self.get_user_token(user) + token = token_cache.get_json() + token_updater = token_cache.update_data + + return OAuth2Session( + client_id=self.client_id, + token=token, + token_updater=token_updater, + auto_refresh_url=self.token_uri, + redirect_uri=self.redirect_uri, + scope=self.get_scopes() + ) + + def initiate_web_application_flow(self, user=None, success_uri=None): + """Return an authorization URL for the user. Save state in Token Cache.""" + user = user or frappe.session.user + oauth = self.get_oauth2_session(init=True) + query_params = self.get_query_params() + authorization_url, state = oauth.authorization_url(self.authorization_uri, **query_params) + token_cache = self.get_token_cache(user) + + if not token_cache: + token_cache = frappe.new_doc('Token Cache') + token_cache.user = user + token_cache.connected_app = self.name + + token_cache.success_uri = success_uri + token_cache.state = state + token_cache.save(ignore_permissions=True) + frappe.db.commit() + + return authorization_url + + def get_user_token(self, user=None, success_uri=None): + """Return an existing user token or initiate a Web Application Flow.""" + user = user or frappe.session.user + token_cache = self.get_token_cache(user) + + if token_cache: + return token_cache + + redirect = self.initiate_web_application_flow(user, success_uri) + frappe.local.response['type'] = 'redirect' + frappe.local.response['location'] = redirect + return redirect + + def get_token_cache(self, user): + token_cache = None + token_cache_name = self.name + '-' + user + + if frappe.db.exists('Token Cache', token_cache_name): + token_cache = frappe.get_doc('Token Cache', token_cache_name) + + return token_cache + + def get_scopes(self): + return [row.scope for row in self.scopes] + + def get_query_params(self): + return {param.key: param.value for param in self.query_parameters} + + +@frappe.whitelist(allow_guest=True) +def callback(code=None, state=None): + """Handle client's code. + + Called during the oauthorization flow by the remote oAuth2 server to + transmit a code that can be used by the local server to obtain an access + token. + """ + if frappe.request.method != 'GET': + frappe.throw(_('Invalid request method: {}').format(frappe.request.method)) + + if frappe.session.user == 'Guest': + frappe.local.response['type'] = 'redirect' + frappe.local.response['location'] = '/login?' + urlencode({'redirect-to': frappe.request.url}) + return + + path = frappe.request.path[1:].split('/') + if len(path) != 4 or not path[3]: + frappe.throw(_('Invalid Parameters.')) + + connected_app = frappe.get_doc('Connected App', path[3]) + token_cache = frappe.get_doc('Token Cache', connected_app.name + '-' + frappe.session.user) + + if state != token_cache.state: + frappe.throw(_('Invalid state.')) + + oauth_session = connected_app.get_oauth2_session(init=True) + query_params = connected_app.get_query_params() + token = oauth_session.fetch_token(connected_app.token_uri, + code=code, + client_secret=connected_app.get_password('client_secret'), + include_client_id=True, + **query_params + ) + token_cache.update_data(token) + + frappe.local.response['type'] = 'redirect' + frappe.local.response['location'] = token_cache.get('success_uri') or connected_app.get_url() diff --git a/frappe/integrations/doctype/connected_app/test_connected_app.py b/frappe/integrations/doctype/connected_app/test_connected_app.py new file mode 100644 index 0000000000..b4304f6ee8 --- /dev/null +++ b/frappe/integrations/doctype/connected_app/test_connected_app.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2019, Frappe Technologies and contributors +# See license.txt +from __future__ import unicode_literals + +import unittest +import requests +from urllib.parse import urljoin + +import frappe +from frappe.integrations.doctype.social_login_key.test_social_login_key import create_or_update_social_login_key + + +def get_user(usr, pwd): + user = frappe.new_doc('User') + user.email = usr + user.enabled = 1 + user.first_name = "_Test" + user.new_password = pwd + user.roles = [] + user.append('roles', { + 'doctype': 'Has Role', + 'parentfield': 'roles', + 'role': 'System Manager' + }) + user.insert() + + return user + + +def get_connected_app(): + doctype = 'Connected App' + connected_app = frappe.new_doc(doctype) + connected_app.provider_name = 'frappe' + connected_app.scopes = [] + connected_app.append('scopes', {'scope': 'all'}) + connected_app.insert() + + return connected_app + + +def get_oauth_client(): + oauth_client = frappe.new_doc('OAuth Client') + oauth_client.app_name = '_Test Connected App' + oauth_client.redirect_uris = 'to be replaced' + oauth_client.default_redirect_uri = 'to be replaced' + oauth_client.grant_type = 'Authorization Code' + oauth_client.response_type = 'Code' + oauth_client.skip_authorization = 1 + oauth_client.insert() + + return oauth_client + + +class TestConnectedApp(unittest.TestCase): + + def setUp(self): + """Set up a Connected App that connects to our own oAuth provider. + + Frappe comes with it's own oAuth2 provider that we can test against. The + client credentials can be obtained from an "OAuth Client". All depends + on "Social Login Key" so we create one as well. + + The redirect URIs from "Connected App" and "OAuth Client" have to match. + Frappe's "Authorization URL" and "Access Token URL" (actually they're + just endpoints) are stored in "Social Login Key" so we get them from + there. + """ + self.user_name = 'test-connected-app@example.com' + self.user_password = 'Eastern_43A1W' + + self.user = get_user(self.user_name, self.user_password) + self.connected_app = get_connected_app() + self.oauth_client = get_oauth_client() + social_login_key = create_or_update_social_login_key() + self.base_url = social_login_key.get('base_url') + + frappe.db.commit() + self.connected_app.reload() + self.oauth_client.reload() + + redirect_uri = self.connected_app.get('redirect_uri') + self.oauth_client.update({ + 'redirect_uris': redirect_uri, + 'default_redirect_uri': redirect_uri + }) + self.oauth_client.save() + + self.connected_app.update({ + 'authorization_uri': urljoin(self.base_url, social_login_key.get('authorize_url')), + 'client_id': self.oauth_client.get('client_id'), + 'client_secret': self.oauth_client.get('client_secret'), + 'token_uri': urljoin(self.base_url, social_login_key.get('access_token_url')) + }) + self.connected_app.save() + + frappe.db.commit() + self.connected_app.reload() + self.oauth_client.reload() + + def test_web_application_flow(self): + """Simulate a logged in user who opens the authorization URL.""" + def login(): + return session.get(urljoin(self.base_url, '/api/method/login'), params={ + 'usr': self.user_name, + 'pwd': self.user_password + }) + + session = requests.Session() + + first_login = login() + self.assertEqual(first_login.status_code, 200) + + authorization_url = self.connected_app.initiate_web_application_flow(user=self.user_name) + + auth_response = session.get(authorization_url) + self.assertEqual(auth_response.status_code, 200) + + callback_response = session.get(auth_response.url) + self.assertEqual(callback_response.status_code, 200) + + self.token_cache = self.connected_app.get_token_cache(self.user_name) + token = self.token_cache.get_password('access_token') + self.assertNotEqual(token, None) + + oauth2_session = self.connected_app.get_oauth2_session(self.user_name) + resp = oauth2_session.get(urljoin(self.base_url, '/api/method/frappe.auth.get_logged_user')) + self.assertEqual(resp.json().get('message'), self.user_name) + + def tearDown(self): + def delete_if_exists(attribute): + doc = getattr(self, attribute, None) + if doc: + doc.delete() + + delete_if_exists('token_cache') + delete_if_exists('connected_app') + + if getattr(self, 'oauth_client', None): + tokens = frappe.get_all('OAuth Bearer Token', filters={ + 'client': self.oauth_client.name + }) + for token in tokens: + doc = frappe.get_doc('OAuth Bearer Token', token.name) + doc.delete() + + codes = frappe.get_all('OAuth Authorization Code', filters={ + 'client': self.oauth_client.name + }) + for code in codes: + doc = frappe.get_doc('OAuth Authorization Code', code.name) + doc.delete() + + delete_if_exists('user') + delete_if_exists('oauth_client') + + frappe.db.commit() diff --git a/frappe/integrations/doctype/connected_app/test_records.json b/frappe/integrations/doctype/connected_app/test_records.json new file mode 100644 index 0000000000..4d19369248 --- /dev/null +++ b/frappe/integrations/doctype/connected_app/test_records.json @@ -0,0 +1,13 @@ +[ + { + "doctype": "Connected App", + "provider_name": "frappe", + "client_id": "test_client_id", + "client_secret": "test_client_secret", + "scopes": [ + { + "scope": "all" + } + ] + } +] diff --git a/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py b/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py index 6b95a3f5bf..71445b44d7 100644 --- a/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py +++ b/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py @@ -9,7 +9,7 @@ import frappe import os from frappe import _ from frappe.model.document import Document -from frappe.integrations.offsite_backup_utils import get_latest_backup_file, send_email, validate_file_size +from frappe.integrations.offsite_backup_utils import get_latest_backup_file, send_email, validate_file_size, get_chunk_site from frappe.integrations.utils import make_post_request from frappe.utils import (cint, get_request_site_address, get_files_path, get_backups_path, get_url, encode) @@ -167,8 +167,9 @@ def upload_file_to_dropbox(filename, folder, dropbox_client): return create_folder_if_not_exists(folder, dropbox_client) - chunk_size = 15 * 1024 * 1024 file_size = os.path.getsize(encode(filename)) + chunk_size = get_chunk_site(file_size) + mode = (dropbox.files.WriteMode.overwrite) f = open(encode(filename), 'rb') diff --git a/frappe/integrations/doctype/google_calendar/google_calendar.js b/frappe/integrations/doctype/google_calendar/google_calendar.js index f941cf0575..f30c52b2f2 100644 --- a/frappe/integrations/doctype/google_calendar/google_calendar.js +++ b/frappe/integrations/doctype/google_calendar/google_calendar.js @@ -4,7 +4,7 @@ frappe.ui.form.on("Google Calendar", { refresh: function(frm) { if (frm.is_new()) { - frm.dashboard.set_headline(__("To use Google Calendar, enable {0}.", [`${__('Google Settings')}`])); + frm.dashboard.set_headline(__("To use Google Calendar, enable {0}.", [`${__('Google Settings')}`])); } frappe.realtime.on("import_google_calendar", (data) => { diff --git a/frappe/integrations/doctype/google_calendar/google_calendar.py b/frappe/integrations/doctype/google_calendar/google_calendar.py index 4a9acd9e84..fbedd75029 100644 --- a/frappe/integrations/doctype/google_calendar/google_calendar.py +++ b/frappe/integrations/doctype/google_calendar/google_calendar.py @@ -120,7 +120,7 @@ def authorize_access(g_calendar, reauthorize=None): frappe.db.commit() frappe.local.response["type"] = "redirect" - frappe.local.response["location"] = "/desk#Form/{0}/{1}".format(quote("Google Calendar"), quote(google_calendar.name)) + frappe.local.response["location"] = "/app/Form/{0}/{1}".format(quote("Google Calendar"), quote(google_calendar.name)) frappe.msgprint(_("Google Calendar has been configured.")) except Exception as e: diff --git a/frappe/integrations/doctype/google_contacts/google_contacts.js b/frappe/integrations/doctype/google_contacts/google_contacts.js index af194d4978..7cbef46699 100644 --- a/frappe/integrations/doctype/google_contacts/google_contacts.js +++ b/frappe/integrations/doctype/google_contacts/google_contacts.js @@ -4,7 +4,7 @@ frappe.ui.form.on('Google Contacts', { refresh: function(frm) { if (!frm.doc.enable) { - frm.dashboard.set_headline(__("To use Google Contacts, enable {0}.", [`${__('Google Settings')}`])); + frm.dashboard.set_headline(__("To use Google Contacts, enable {0}.", [`${__('Google Settings')}`])); } frappe.realtime.on('import_google_contacts', (data) => { diff --git a/frappe/integrations/doctype/google_contacts/google_contacts.py b/frappe/integrations/doctype/google_contacts/google_contacts.py index 6455623281..4c8c3b67f6 100644 --- a/frappe/integrations/doctype/google_contacts/google_contacts.py +++ b/frappe/integrations/doctype/google_contacts/google_contacts.py @@ -79,7 +79,7 @@ def authorize_access(g_contact, reauthorize=None): frappe.db.commit() frappe.local.response["type"] = "redirect" - frappe.local.response["location"] = "/desk#Form/Google%20Contacts/{}".format(google_contact.name) + frappe.local.response["location"] = "/app/Form/Google%20Contacts/{}".format(google_contact.name) frappe.msgprint(_("Google Contacts has been configured.")) except Exception as e: diff --git a/frappe/integrations/doctype/google_drive/google_drive.js b/frappe/integrations/doctype/google_drive/google_drive.js index f184c6d75c..c314d02e7e 100644 --- a/frappe/integrations/doctype/google_drive/google_drive.js +++ b/frappe/integrations/doctype/google_drive/google_drive.js @@ -4,7 +4,7 @@ frappe.ui.form.on('Google Drive', { refresh: function(frm) { if (!frm.doc.enable) { - frm.dashboard.set_headline(__("To use Google Drive, enable {0}.", [`${__('Google Settings')}`])); + frm.dashboard.set_headline(__("To use Google Drive, enable {0}.", [`${__('Google Settings')}`])); } frappe.realtime.on("upload_to_google_drive", (data) => { diff --git a/frappe/integrations/doctype/google_drive/google_drive.py b/frappe/integrations/doctype/google_drive/google_drive.py index c1c73d7726..859c769018 100644 --- a/frappe/integrations/doctype/google_drive/google_drive.py +++ b/frappe/integrations/doctype/google_drive/google_drive.py @@ -88,7 +88,7 @@ def authorize_access(reauthorize=None): frappe.db.commit() frappe.local.response["type"] = "redirect" - frappe.local.response["location"] = "/desk#Form/{0}".format(quote("Google Drive")) + frappe.local.response["location"] = "/app/Form/{0}".format(quote("Google Drive")) frappe.msgprint(_("Google Drive has been configured.")) except Exception as e: diff --git a/frappe/integrations/doctype/integration_request/integration_request.py b/frappe/integrations/doctype/integration_request/integration_request.py index 2cb656459b..f1d59beb5a 100644 --- a/frappe/integrations/doctype/integration_request/integration_request.py +++ b/frappe/integrations/doctype/integration_request/integration_request.py @@ -6,6 +6,8 @@ from __future__ import unicode_literals import frappe from frappe.model.document import Document import json +from six import string_types +from frappe.integrations.utils import json_handler class IntegrationRequest(Document): def autoname(self): @@ -20,3 +22,17 @@ class IntegrationRequest(Document): self.status = status self.save(ignore_permissions=True) frappe.db.commit() + + def handle_success(self, response): + """update the output field with the response along with the relevant status""" + if isinstance(response, string_types): + response = json.loads(response) + self.db_set("status", "Completed") + self.db_set("output", json.dumps(response, default=json_handler)) + + def handle_failure(self, response): + """update the error field with the response along with the relevant status""" + if isinstance(response, string_types): + response = json.loads(response) + self.db_set("status", "Failed") + self.db_set("error", json.dumps(response, default=json_handler)) \ No newline at end of file diff --git a/frappe/integrations/doctype/oauth_client/test_records.json b/frappe/integrations/doctype/oauth_client/test_records.json index cff06457c5..11e6338a87 100644 --- a/frappe/integrations/doctype/oauth_client/test_records.json +++ b/frappe/integrations/doctype/oauth_client/test_records.json @@ -1,7 +1,6 @@ [ { - "app_name": "_Test OAuth Client", - "client_id": "test_client_id", + "app_name": "_Test OAuth Client", "client_secret": "test_client_secret", "default_redirect_uri": "http://localhost", "docstatus": 0, diff --git a/frappe/public/website_theme/.gitkeep b/frappe/integrations/doctype/oauth_scope/__init__.py similarity index 100% rename from frappe/public/website_theme/.gitkeep rename to frappe/integrations/doctype/oauth_scope/__init__.py diff --git a/frappe/integrations/doctype/twilio_number_group/twilio_number_group.json b/frappe/integrations/doctype/oauth_scope/oauth_scope.json similarity index 53% rename from frappe/integrations/doctype/twilio_number_group/twilio_number_group.json rename to frappe/integrations/doctype/oauth_scope/oauth_scope.json index 9d51e4b452..3a6e528999 100644 --- a/frappe/integrations/doctype/twilio_number_group/twilio_number_group.json +++ b/frappe/integrations/doctype/oauth_scope/oauth_scope.json @@ -1,32 +1,26 @@ { "actions": [], - "autoname": "field:phone_number", - "creation": "2020-02-24 13:58:58.036914", + "creation": "2020-07-15 22:08:14.616585", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "phone_number" + "scope" ], "fields": [ { - "fieldname": "phone_number", + "fieldname": "scope", "fieldtype": "Data", "in_list_view": 1, - "label": "Phone Number", - "options": "Phone", - "show_days": 1, - "show_seconds": 1, - "unique": 1 + "label": "Scope" } ], - "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-08-20 22:48:57.166791", + "modified": "2020-07-15 22:15:18.930632", "modified_by": "Administrator", "module": "Integrations", - "name": "Twilio Number Group", + "name": "OAuth Scope", "owner": "Administrator", "permissions": [], "quick_entry": 1, diff --git a/frappe/desk/doctype/desk_chart/desk_chart.py b/frappe/integrations/doctype/oauth_scope/oauth_scope.py similarity index 89% rename from frappe/desk/doctype/desk_chart/desk_chart.py rename to frappe/integrations/doctype/oauth_scope/oauth_scope.py index dbbfae6cd7..a5dfe7e1ce 100644 --- a/frappe/desk/doctype/desk_chart/desk_chart.py +++ b/frappe/integrations/doctype/oauth_scope/oauth_scope.py @@ -6,5 +6,5 @@ from __future__ import unicode_literals # import frappe from frappe.model.document import Document -class DeskChart(Document): +class OAuthScope(Document): pass diff --git a/frappe/integrations/doctype/query_parameters/__init__.py b/frappe/integrations/doctype/query_parameters/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/integrations/doctype/query_parameters/query_parameters.json b/frappe/integrations/doctype/query_parameters/query_parameters.json new file mode 100644 index 0000000000..de31c28df7 --- /dev/null +++ b/frappe/integrations/doctype/query_parameters/query_parameters.json @@ -0,0 +1,37 @@ +{ + "actions": [], + "creation": "2020-11-16 14:54:37.226914", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "key", + "value" + ], + "fields": [ + { + "fieldname": "key", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Key", + "reqd": 1 + }, + { + "fieldname": "value", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Value", + "reqd": 1 + } + ], + "istable": 1, + "links": [], + "modified": "2020-11-16 15:18:35.887149", + "modified_by": "Administrator", + "module": "Integrations", + "name": "Query Parameters", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC" +} \ No newline at end of file diff --git a/frappe/desk/doctype/desk_card/desk_card.py b/frappe/integrations/doctype/query_parameters/query_parameters.py similarity index 87% rename from frappe/desk/doctype/desk_card/desk_card.py rename to frappe/integrations/doctype/query_parameters/query_parameters.py index 01d835bbfb..bfb8eae0b6 100644 --- a/frappe/desk/doctype/desk_card/desk_card.py +++ b/frappe/integrations/doctype/query_parameters/query_parameters.py @@ -6,5 +6,5 @@ from __future__ import unicode_literals # import frappe from frappe.model.document import Document -class DeskCard(Document): +class QueryParameters(Document): pass diff --git a/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.json b/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.json index 123bb21e88..2ca1723cb2 100755 --- a/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.json +++ b/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.json @@ -18,12 +18,9 @@ "bucket", "endpoint_url", "column_break_13", - "region", "backup_details_section", "frequency", - "backup_files", - "column_break_18", - "backup_limit" + "backup_files" ], "fields": [ { @@ -42,7 +39,7 @@ }, { "default": "1", - "description": "Note: By default emails for failed backups are sent.", + "description": "By default, emails are only sent for failed backups.", "fieldname": "send_email_for_successful_backup", "fieldtype": "Check", "label": "Send Email for Successful Backup" @@ -73,14 +70,7 @@ "reqd": 1 }, { - "default": "us-east-1", - "description": "See https://docs.aws.amazon.com/general/latest/gr/s3.html for details.", - "fieldname": "region", - "fieldtype": "Select", - "label": "Region", - "options": "us-east-1\nus-east-2\nus-west-1\nus-west-2\naf-south-1\nap-east-1\nap-south-1\nap-southeast-1\nap-southeast-2\nap-northeast-1\nap-northeast-2\nap-northeast-3\nca-central-1\ncn-north-1\ncn-northwest-1\neu-central-1\neu-west-1\neu-west-2\neu-west-3\neu-south-1\neu-north-1\nme-south-1\nsa-east-1" - }, - { + "default": "https://s3.amazonaws.com", "fieldname": "endpoint_url", "fieldtype": "Data", "label": "Endpoint URL" @@ -92,14 +82,6 @@ "mandatory_depends_on": "enabled", "reqd": 1 }, - { - "description": "Set to 0 for no limit on the number of backups taken", - "fieldname": "backup_limit", - "fieldtype": "Int", - "label": "Backup Limit", - "mandatory_depends_on": "enabled", - "reqd": 1 - }, { "depends_on": "enabled", "fieldname": "api_access_section", @@ -142,16 +124,12 @@ "fieldname": "backup_files", "fieldtype": "Check", "label": "Backup Files" - }, - { - "fieldname": "column_break_18", - "fieldtype": "Column Break" } ], "hide_toolbar": 1, "issingle": 1, "links": [], - "modified": "2020-07-27 17:27:21.400000", + "modified": "2020-12-07 15:30:55.047689", "modified_by": "Administrator", "module": "Integrations", "name": "S3 Backup Settings", @@ -172,4 +150,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} +} \ No newline at end of file diff --git a/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.py b/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.py index 7c90d37f82..308d34c5c2 100755 --- a/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.py +++ b/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.py @@ -24,6 +24,7 @@ class S3BackupSettings(Document): if not self.endpoint_url: self.endpoint_url = 'https://s3.amazonaws.com' + conn = boto3.client( 's3', aws_access_key_id=self.access_key_id, @@ -31,25 +32,21 @@ class S3BackupSettings(Document): endpoint_url=self.endpoint_url ) - bucket_lower = str(self.bucket) - - try: - conn.list_buckets() - - except ClientError: - frappe.throw(_("Invalid Access Key ID or Secret Access Key.")) - try: # Head_bucket returns a 200 OK if the bucket exists and have access to it. - conn.head_bucket(Bucket=bucket_lower) + # Requires ListBucket permission + conn.head_bucket(Bucket=self.bucket) except ClientError as e: error_code = e.response['Error']['Code'] + bucket_name = frappe.bold(self.bucket) if error_code == '403': - frappe.throw(_("Do not have permission to access {0} bucket.").format(bucket_lower)) - else: # '400'-Bad request or '404'-Not Found return - # try to create bucket - conn.create_bucket(Bucket=bucket_lower, CreateBucketConfiguration={ - 'LocationConstraint': self.region}) + msg = _("Do not have permission to access bucket {0}.").format(bucket_name) + elif error_code == '404': + msg = _("Bucket {0} not found.").format(bucket_name) + else: + msg = e.args[0] + + frappe.throw(msg) @frappe.whitelist() @@ -70,11 +67,13 @@ def take_backups_weekly(): def take_backups_monthly(): take_backups_if("Monthly") + def take_backups_if(freq): if cint(frappe.db.get_value("S3 Backup Settings", None, "enabled")): if frappe.db.get_value("S3 Backup Settings", None, "frequency") == freq: take_backups_s3() + @frappe.whitelist() def take_backups_s3(retry_count=0): try: @@ -146,42 +145,13 @@ def backup_to_s3(): if files_filename: upload_file_to_s3(files_filename, folder, conn, bucket) - delete_old_backups(doc.backup_limit, bucket) - def upload_file_to_s3(filename, folder, conn, bucket): destpath = os.path.join(folder, os.path.basename(filename)) try: print("Uploading file:", filename) - conn.upload_file(filename, bucket, destpath) + conn.upload_file(filename, bucket, destpath) # Requires PutObject permission except Exception as e: frappe.log_error() print("Error uploading: %s" % (e)) - - -def delete_old_backups(limit, bucket): - all_backups = [] - doc = frappe.get_single("S3 Backup Settings") - backup_limit = int(limit) - - s3 = boto3.resource( - 's3', - aws_access_key_id=doc.access_key_id, - aws_secret_access_key=doc.get_password('secret_access_key'), - endpoint_url=doc.endpoint_url or 'https://s3.amazonaws.com' - ) - - bucket = s3.Bucket(bucket) - objects = bucket.meta.client.list_objects_v2(Bucket=bucket.name, Delimiter='/') - if objects: - for obj in objects.get('CommonPrefixes'): - all_backups.append(obj.get('Prefix')) - - oldest_backup = sorted(all_backups)[0] if all_backups else '' - - if len(all_backups) > backup_limit: - print("Deleting Backup: {0}".format(oldest_backup)) - for obj in bucket.objects.filter(Prefix=oldest_backup): - # delete all keys that are inside the oldest_backup - s3.Object(bucket.name, obj.key).delete() diff --git a/frappe/integrations/doctype/social_login_key/social_login_key.json b/frappe/integrations/doctype/social_login_key/social_login_key.json index 6c0fbdb26c..bb97d8f625 100644 --- a/frappe/integrations/doctype/social_login_key/social_login_key.json +++ b/frappe/integrations/doctype/social_login_key/social_login_key.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_import": 1, "creation": "2017-11-18 15:36:09.676722", "doctype": "DocType", @@ -82,6 +83,7 @@ "label": "Identity Details" }, { + "depends_on": "eval:doc.social_login_provider==\"Custom\"", "fieldname": "icon", "fieldtype": "Data", "label": "Icon" @@ -157,7 +159,9 @@ "label": "User ID Property" } ], - "modified": "2019-12-03 13:13:46.989099", + "index_web_pages_for_search": 1, + "links": [], + "modified": "2020-09-30 14:37:13.616002", "modified_by": "Administrator", "module": "Integrations", "name": "Social Login Key", diff --git a/frappe/integrations/doctype/social_login_key/social_login_key.py b/frappe/integrations/doctype/social_login_key/social_login_key.py index 81df3cca97..d84e6ef11d 100644 --- a/frappe/integrations/doctype/social_login_key/social_login_key.py +++ b/frappe/integrations/doctype/social_login_key/social_login_key.py @@ -20,6 +20,7 @@ class SocialLoginKey(Document): self.name = frappe.scrub(self.provider_name) def validate(self): + self.set_icon() if self.custom_base_url and not self.base_url: frappe.throw(_("Please enter Base URL"), exc=BaseUrlNotSetError) if not self.authorize_url: @@ -33,6 +34,21 @@ class SocialLoginKey(Document): if self.enable_social_login and not self.client_secret: frappe.throw(_("Please enter Client Secret before social login is enabled"), exc=ClientSecretNotSetError) + def set_icon(self): + icon_map = { + "Google": "google.svg", + "Frappe": "frappe.svg", + "Facebook": "facebook.svg", + "Office 365": "office_365.svg", + "GitHub": "github.svg", + "Salesforce": "salesforce.svg", + "fairlogin": "fair.svg" + } + + if self.provider_name in icon_map: + icon_file = icon_map[self.provider_name] + self.icon = '/assets/frappe/icons/social/{0}'.format(icon_file) + def get_social_login_provider(self, provider, initialize=False): providers = {} @@ -108,7 +124,7 @@ class SocialLoginKey(Document): "provider_name": "Frappe", "enable_social_login": 1, "custom_base_url": 1, - "icon":"/assets/frappe/images/favicon.png", + "icon":"/assets/frappe/images/frappe-favicon.svg", "redirect_url": "/api/method/frappe.www.login.login_via_frappe", "api_endpoint": "/api/method/frappe.integrations.oauth2.openid_profile", "api_endpoint_args":None, diff --git a/frappe/integrations/doctype/social_login_key/test_social_login_key.py b/frappe/integrations/doctype/social_login_key/test_social_login_key.py index 58bd48d64a..e0b99ad391 100644 --- a/frappe/integrations/doctype/social_login_key/test_social_login_key.py +++ b/frappe/integrations/doctype/social_login_key/test_social_login_key.py @@ -22,3 +22,17 @@ def make_social_login_key(**kwargs): kwargs["provider_name"] = "Test OAuth2 Provider" doc = frappe.get_doc(kwargs) return doc + +def create_or_update_social_login_key(): + # used in other tests (connected app, oauth20) + try: + social_login_key = frappe.get_doc("Social Login Key", "frappe") + except frappe.DoesNotExistError: + social_login_key = frappe.new_doc("Social Login Key") + social_login_key.get_social_login_provider("Frappe", initialize=True) + social_login_key.base_url = frappe.utils.get_url() + social_login_key.enable_social_login = 0 + social_login_key.save() + frappe.db.commit() + + return social_login_key diff --git a/frappe/integrations/doctype/token_cache/__init__.py b/frappe/integrations/doctype/token_cache/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/integrations/doctype/token_cache/test_records.json b/frappe/integrations/doctype/token_cache/test_records.json new file mode 100644 index 0000000000..05840221a6 --- /dev/null +++ b/frappe/integrations/doctype/token_cache/test_records.json @@ -0,0 +1,18 @@ +[ + { + "doctype": "Token Cache", + "user": "test@example.com", + "access_token": "test-access-token", + "refresh_token": "test-refresh-token", + "token_type": "Bearer", + "expires_in": 1000, + "scopes": [ + { + "scope": "all" + }, + { + "scope": "openid" + } + ] + } +] \ No newline at end of file diff --git a/frappe/integrations/doctype/token_cache/test_token_cache.py b/frappe/integrations/doctype/token_cache/test_token_cache.py new file mode 100644 index 0000000000..73c9f38fce --- /dev/null +++ b/frappe/integrations/doctype/token_cache/test_token_cache.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2019, Frappe Technologies and contributors +# See license.txt +from __future__ import unicode_literals + +import unittest +import frappe + +test_dependencies = ['User', 'Connected App', 'Token Cache'] + +class TestTokenCache(unittest.TestCase): + + def setUp(self): + self.token_cache = frappe.get_last_doc('Token Cache') + self.token_cache.update({'connected_app': frappe.get_last_doc('Connected App').name}) + self.token_cache.save() + + def test_get_auth_header(self): + self.token_cache.get_auth_header() + + def test_update_data(self): + self.token_cache.update_data({ + 'access_token': 'new-access-token', + 'refresh_token': 'new-refresh-token', + 'token_type': 'bearer', + 'expires_in': 2000, + 'scope': 'new scope' + }) + + def test_get_expires_in(self): + self.token_cache.get_expires_in() + + def test_is_expired(self): + self.token_cache.is_expired() + + def get_json(self): + self.token_cache.get_json() diff --git a/frappe/integrations/doctype/token_cache/token_cache.js b/frappe/integrations/doctype/token_cache/token_cache.js new file mode 100644 index 0000000000..b7cac9b804 --- /dev/null +++ b/frappe/integrations/doctype/token_cache/token_cache.js @@ -0,0 +1,8 @@ +// Copyright (c) 2019, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Token Cache', { + // refresh: function(frm) { + + // } +}); diff --git a/frappe/integrations/doctype/token_cache/token_cache.json b/frappe/integrations/doctype/token_cache/token_cache.json new file mode 100644 index 0000000000..c016405031 --- /dev/null +++ b/frappe/integrations/doctype/token_cache/token_cache.json @@ -0,0 +1,110 @@ +{ + "actions": [], + "autoname": "format:{connected_app}-{user}", + "beta": 1, + "creation": "2019-01-24 16:56:55.631096", + "doctype": "DocType", + "document_type": "System", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "user", + "connected_app", + "provider_name", + "access_token", + "refresh_token", + "expires_in", + "state", + "scopes", + "success_uri", + "token_type" + ], + "fields": [ + { + "fieldname": "user", + "fieldtype": "Link", + "label": "User", + "options": "User", + "read_only": 1 + }, + { + "fieldname": "connected_app", + "fieldtype": "Link", + "label": "Connected App", + "options": "Connected App", + "read_only": 1 + }, + { + "fieldname": "access_token", + "fieldtype": "Password", + "label": "Access Token", + "read_only": 1 + }, + { + "fieldname": "refresh_token", + "fieldtype": "Password", + "label": "Refresh Token", + "read_only": 1 + }, + { + "fieldname": "expires_in", + "fieldtype": "Int", + "label": "Expires In", + "read_only": 1 + }, + { + "fieldname": "state", + "fieldtype": "Data", + "label": "State", + "read_only": 1 + }, + { + "fieldname": "scopes", + "fieldtype": "Table", + "label": "Scopes", + "options": "OAuth Scope", + "read_only": 1 + }, + { + "fieldname": "success_uri", + "fieldtype": "Data", + "label": "Success URI", + "read_only": 1 + }, + { + "fieldname": "token_type", + "fieldtype": "Data", + "label": "Token Type", + "read_only": 1 + }, + { + "fetch_from": "connected_app.provider_name", + "fieldname": "provider_name", + "fieldtype": "Data", + "label": "Provider Name", + "read_only": 1 + } + ], + "links": [], + "modified": "2020-11-13 13:35:53.714352", + "modified_by": "Administrator", + "module": "Integrations", + "name": "Token Cache", + "owner": "Administrator", + "permissions": [ + { + "delete": 1, + "read": 1, + "role": "System Manager" + }, + { + "delete": 1, + "if_owner": 1, + "read": 1, + "role": "All" + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/integrations/doctype/token_cache/token_cache.py b/frappe/integrations/doctype/token_cache/token_cache.py new file mode 100644 index 0000000000..7cac58fae0 --- /dev/null +++ b/frappe/integrations/doctype/token_cache/token_cache.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2019, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +from datetime import datetime, timedelta + +import frappe +from frappe import _ +from frappe.utils import cstr, cint +from frappe.model.document import Document + +class TokenCache(Document): + + def get_auth_header(self): + if self.access_token: + headers = {'Authorization': 'Bearer ' + self.get_password('access_token')} + return headers + + raise frappe.exceptions.DoesNotExistError + + def update_data(self, data): + """ + Store data returned by authorization flow. + + Params: + data - Dict with access_token, refresh_token, expires_in and scope. + """ + token_type = cstr(data.get('token_type', '')).lower() + if token_type not in ['bearer', 'mac']: + frappe.throw(_('Received an invalid token type.')) + # 'Bearer' or 'MAC' + token_type = token_type.title() if token_type == 'bearer' else token_type.upper() + + self.token_type = token_type + self.access_token = cstr(data.get('access_token', '')) + self.refresh_token = cstr(data.get('refresh_token', '')) + self.expires_in = cint(data.get('expires_in', 0)) + + new_scopes = data.get('scope') + if new_scopes: + if isinstance(new_scopes, str): + new_scopes = new_scopes.split(' ') + if isinstance(new_scopes, list): + self.scopes = None + for scope in new_scopes: + self.append('scopes', {'scope': scope}) + + self.state = None + self.save(ignore_permissions=True) + frappe.db.commit() + return self + + def get_expires_in(self): + expiry_time = frappe.utils.get_datetime(self.modified) + timedelta(self.expires_in) + return (datetime.now() - expiry_time).total_seconds() + + def is_expired(self): + return self.get_expires_in() < 0 + + def get_json(self): + return { + 'access_token': self.get_password('access_token', ''), + 'refresh_token': self.get_password('refresh_token', ''), + 'expires_in': self.get_expires_in(), + 'token_type': self.token_type + } diff --git a/frappe/integrations/doctype/twilio_number_group/twilio_number_group.py b/frappe/integrations/doctype/twilio_number_group/twilio_number_group.py deleted file mode 100644 index 04cb9ae146..0000000000 --- a/frappe/integrations/doctype/twilio_number_group/twilio_number_group.py +++ /dev/null @@ -1,10 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt - -from __future__ import unicode_literals -# import frappe -from frappe.model.document import Document - -class TwilioNumberGroup(Document): - pass diff --git a/frappe/integrations/doctype/twilio_settings/twilio_settings.js b/frappe/integrations/doctype/twilio_settings/twilio_settings.js deleted file mode 100644 index 59ebcf2e7d..0000000000 --- a/frappe/integrations/doctype/twilio_settings/twilio_settings.js +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2020, Frappe Technologies and contributors -// For license information, please see license.txt - -frappe.ui.form.on('Twilio Settings', { - refresh: function(frm) { - frm.dashboard.set_headline(__("For more information, {0}.", [`${__('Click here')}`])); - } -}); diff --git a/frappe/integrations/doctype/twilio_settings/twilio_settings.json b/frappe/integrations/doctype/twilio_settings/twilio_settings.json deleted file mode 100644 index 9eb2c0c512..0000000000 --- a/frappe/integrations/doctype/twilio_settings/twilio_settings.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "actions": [], - "creation": "2020-01-28 15:21:44.457163", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "enabled", - "account_sid", - "auth_token", - "column_break_2", - "twilio_number" - ], - "fields": [ - { - "fieldname": "account_sid", - "fieldtype": "Data", - "label": "Account SID", - "mandatory_depends_on": "eval: doc.enabled" - }, - { - "fieldname": "auth_token", - "fieldtype": "Password", - "label": "Auth Token", - "mandatory_depends_on": "eval: doc.enabled" - }, - { - "fieldname": "column_break_2", - "fieldtype": "Column Break" - }, - { - "fieldname": "twilio_number", - "fieldtype": "Table", - "label": "Twilio Number", - "options": "Twilio Number Group" - }, - { - "default": "0", - "fieldname": "enabled", - "fieldtype": "Check", - "label": "Enabled" - } - ], - "index_web_pages_for_search": 1, - "issingle": 1, - "links": [], - "modified": "2020-09-03 10:17:21.318743", - "modified_by": "Administrator", - "module": "Integrations", - "name": "Twilio Settings", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 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/integrations/doctype/twilio_settings/twilio_settings.py b/frappe/integrations/doctype/twilio_settings/twilio_settings.py deleted file mode 100644 index b8f991e829..0000000000 --- a/frappe/integrations/doctype/twilio_settings/twilio_settings.py +++ /dev/null @@ -1,63 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt - -from __future__ import unicode_literals -import frappe -from frappe.model.document import Document -from frappe import _ -from frappe.utils.password import get_decrypted_password -from twilio.rest import Client -from six import string_types -from json import loads - -class TwilioSettings(Document): - def on_update(self): - if self.enabled: - self.validate_twilio_credentials() - - def validate_twilio_credentials(self): - try: - auth_token = get_decrypted_password("Twilio Settings", "Twilio Settings", 'auth_token') - client = Client(self.account_sid, auth_token) - client.api.accounts(self.account_sid).fetch() - except Exception: - frappe.throw(_("Invalid Account SID or Auth Token.")) - -def send_whatsapp_message(sender, receiver_list, message): - twilio_settings = frappe.get_doc("Twilio Settings") - if not twilio_settings.enabled: - frappe.throw(_("Please enable twilio settings before sending WhatsApp messages")) - - if isinstance(receiver_list, string_types): - receiver_list = loads(receiver_list) - if not isinstance(receiver_list, list): - receiver_list = [receiver_list] - - auth_token = get_decrypted_password("Twilio Settings", "Twilio Settings", 'auth_token') - client = Client(twilio_settings.account_sid, auth_token) - args = { - "from_": 'whatsapp:+{}'.format(sender), - "body": message - } - - failed_delivery = [] - - for rec in receiver_list: - args.update({"to": 'whatsapp:{}'.format(rec)}) - resp = _send_whatsapp(args, client) - if not resp or resp.error_message: - failed_delivery.append(rec) - - if failed_delivery: - frappe.log_error(_("The message wasn't correctly delivered to: {}".format(", ".join(failed_delivery))), _('Delivery Failed')) - - -def _send_whatsapp(message_dict, client): - response = frappe._dict() - try: - response = client.messages.create(**message_dict) - except Exception as e: - frappe.log_error(e, title = _('Twilio WhatsApp Message Error')) - - return response \ No newline at end of file diff --git a/frappe/integrations/doctype/webhook/webhook.py b/frappe/integrations/doctype/webhook/webhook.py index f1556aa661..ad64d9f714 100644 --- a/frappe/integrations/doctype/webhook/webhook.py +++ b/frappe/integrations/doctype/webhook/webhook.py @@ -85,7 +85,7 @@ def enqueue_webhook(doc, webhook): for i in range(3): try: - r = requests.post(webhook.request_url, data=json.dumps(data), headers=headers, timeout=5) + r = requests.post(webhook.request_url, data=json.dumps(data, default=str), headers=headers, timeout=5) r.raise_for_status() frappe.logger().debug({"webhook_success": r.text}) break diff --git a/frappe/integrations/frappe_providers/frappecloud.py b/frappe/integrations/frappe_providers/frappecloud.py index e09f09a44b..f60344ee8f 100644 --- a/frappe/integrations/frappe_providers/frappecloud.py +++ b/frappe/integrations/frappe_providers/frappecloud.py @@ -6,7 +6,7 @@ import frappe def frappecloud_migrator(local_site): - print("Retreiving Site Migrator...") + print("Retrieving Site Migrator...") remote_site = frappe.conf.frappecloud_url or "frappecloud.com" request_url = "https://{}/api/method/press.api.script".format(remote_site) request = requests.get(request_url) diff --git a/frappe/integrations/oauth2.py b/frappe/integrations/oauth2.py index c8dfc52c95..c444964a16 100644 --- a/frappe/integrations/oauth2.py +++ b/frappe/integrations/oauth2.py @@ -1,41 +1,51 @@ from __future__ import unicode_literals -import frappe, json -from frappe.oauth import OAuthWebRequestValidator, WebApplicationServer + +import hashlib +import json +from urllib.parse import quote, urlencode, urlparse + +import jwt from oauthlib.oauth2 import FatalClientError, OAuth2Error -from werkzeug import url_fix -from six.moves.urllib.parse import quote, urlencode, urlparse -from frappe.integrations.doctype.oauth_provider_settings.oauth_provider_settings import get_oauth_settings + +import frappe from frappe import _ +from frappe.oauth import OAuthWebRequestValidator, WebApplicationServer +from frappe.integrations.doctype.oauth_provider_settings.oauth_provider_settings import get_oauth_settings def get_oauth_server(): if not getattr(frappe.local, 'oauth_server', None): oauth_validator = OAuthWebRequestValidator() - frappe.local.oauth_server = WebApplicationServer(oauth_validator) + frappe.local.oauth_server = WebApplicationServer(oauth_validator) return frappe.local.oauth_server -def get_urlparams_from_kwargs(param_kwargs): +def sanitize_kwargs(param_kwargs): + """Remove 'data' and 'cmd' keys, if present.""" arguments = param_kwargs - if arguments.get("data"): - arguments.pop("data") - if arguments.get("cmd"): - arguments.pop("cmd") + arguments.pop('data', None) + arguments.pop('cmd', None) - return urlencode(arguments) + return arguments @frappe.whitelist() def approve(*args, **kwargs): r = frappe.request - uri = url_fix(r.url.replace("+"," ")) - http_method = r.method - body = r.get_data() - headers = r.headers try: - scopes, frappe.flags.oauth_credentials = get_oauth_server().validate_authorization_request(uri, http_method, body, headers) + scopes, frappe.flags.oauth_credentials = get_oauth_server().validate_authorization_request( + r.url, + r.method, + r.get_data(), + r.headers + ) - headers, body, status = get_oauth_server().create_authorization_response(uri=frappe.flags.oauth_credentials['redirect_uri'], \ - body=body, headers=headers, scopes=scopes, credentials=frappe.flags.oauth_credentials) + headers, body, status = get_oauth_server().create_authorization_response( + uri=frappe.flags.oauth_credentials['redirect_uri'], + body=r.get_data(), + headers=r.headers, + scopes=scopes, + credentials=frappe.flags.oauth_credentials + ) uri = headers.get('Location', None) frappe.local.response["type"] = "redirect" @@ -47,34 +57,28 @@ def approve(*args, **kwargs): return e @frappe.whitelist(allow_guest=True) -def authorize(*args, **kwargs): - #Fetch provider URL from settings - oauth_settings = get_oauth_settings() - params = get_urlparams_from_kwargs(kwargs) - request_url = urlparse(frappe.request.url) - success_url = request_url.scheme + "://" + request_url.netloc + "/api/method/frappe.integrations.oauth2.approve?" + params +def authorize(**kwargs): + success_url = "/api/method/frappe.integrations.oauth2.approve?" + encode_params(sanitize_kwargs(kwargs)) failure_url = frappe.form_dict["redirect_uri"] + "?error=access_denied" - if frappe.session['user']=='Guest': + if frappe.session.user == 'Guest': #Force login, redirect to preauth again. frappe.local.response["type"] = "redirect" - frappe.local.response["location"] = "/login?redirect-to=/api/method/frappe.integrations.oauth2.authorize?" + quote(params.replace("+"," ")) - - elif frappe.session['user']!='Guest': + frappe.local.response["location"] = "/login?" + encode_params({'redirect-to': frappe.request.url}) + else: try: r = frappe.request - uri = url_fix(r.url) - http_method = r.method - body = r.get_data() - headers = r.headers - - scopes, frappe.flags.oauth_credentials = get_oauth_server().validate_authorization_request(uri, http_method, body, headers) + scopes, frappe.flags.oauth_credentials = get_oauth_server().validate_authorization_request( + r.url, + r.method, + r.get_data(), + r.headers + ) skip_auth = frappe.db.get_value("OAuth Client", frappe.flags.oauth_credentials['client_id'], "skip_authorization") unrevoked_tokens = frappe.get_all("OAuth Bearer Token", filters={"status":"Active"}) - if skip_auth or (oauth_settings["skip_authorization"] == "Auto" and len(unrevoked_tokens)): - + if skip_auth or (get_oauth_settings().skip_authorization == "Auto" and unrevoked_tokens): frappe.local.response["type"] = "redirect" frappe.local.response["location"] = success_url else: @@ -87,7 +91,6 @@ def authorize(*args, **kwargs): }) resp_html = frappe.render_template("templates/includes/oauth_confirmation.html", response_html_params) frappe.respond_as_web_page("Confirm Access", resp_html) - except FatalClientError as e: return e except OAuth2Error as e: @@ -95,20 +98,20 @@ def authorize(*args, **kwargs): @frappe.whitelist(allow_guest=True) def get_token(*args, **kwargs): - r = frappe.request - - uri = url_fix(r.url) - http_method = r.method - body = r.form - headers = r.headers - #Check whether frappe server URL is set frappe_server_url = frappe.db.get_value("Social Login Key", "frappe", "base_url") or None if not frappe_server_url: frappe.throw(_("Please set Base URL in Social Login Key for Frappe")) try: - headers, body, status = get_oauth_server().create_token_response(uri, http_method, body, headers, frappe.flags.oauth_credentials) + r = frappe.request + headers, body, status = get_oauth_server().create_token_response( + r.url, + r.method, + r.form, + r.headers, + frappe.flags.oauth_credentials + ) out = frappe._dict(json.loads(body)) if not out.error and "openid" in out.scope: token_user = frappe.db.get_value("OAuth Bearer Token", out.access_token, "user") @@ -116,7 +119,7 @@ def get_token(*args, **kwargs): client_secret = frappe.db.get_value("OAuth Client", token_client, "client_secret") if token_user in ["Guest", "Administrator"]: frappe.throw(_("Logged in as Guest or Administrator")) - import hashlib + id_token_header = { "typ":"jwt", "alg":"HS256" @@ -128,9 +131,10 @@ def get_token(*args, **kwargs): "iss": frappe_server_url, "at_hash": frappe.oauth.calculate_at_hash(out.access_token, hashlib.sha256) } - import jwt + id_token_encoded = jwt.encode(id_token, client_secret, algorithm='HS256', headers=id_token_header) - out.update({"id_token":str(id_token_encoded)}) + out.update({"id_token": str(id_token_encoded)}) + frappe.local.response = out except FatalClientError as e: @@ -140,12 +144,12 @@ def get_token(*args, **kwargs): @frappe.whitelist(allow_guest=True) def revoke_token(*args, **kwargs): r = frappe.request - uri = url_fix(r.url) - http_method = r.method - body = r.form - headers = r.headers - - headers, body, status = get_oauth_server().create_revocation_response(uri, headers=headers, body=body, http_method=http_method) + headers, body, status = get_oauth_server().create_revocation_response( + r.url, + headers=r.headers, + body=r.form, + http_method=r.method + ) frappe.local.response['http_status_code'] = status if status == 200: @@ -159,10 +163,13 @@ def openid_profile(*args, **kwargs): first_name, last_name, avatar, name = frappe.db.get_value("User", frappe.session.user, ["first_name", "last_name", "user_image", "name"]) frappe_userid = frappe.db.get_value("User Social Login", {"parent":frappe.session.user, "provider": "frappe"}, "userid") request_url = urlparse(frappe.request.url) + base_url = frappe.db.get_value("Social Login Key", "frappe", "base_url") or None if avatar: if validate_url(avatar): picture = avatar + elif base_url: + picture = base_url + '/' + avatar else: picture = request_url.scheme + "://" + request_url.netloc + avatar @@ -174,15 +181,22 @@ def openid_profile(*args, **kwargs): "email": name, "picture": picture }) - + frappe.local.response = user_profile def validate_url(url_string): try: result = urlparse(url_string) - if result.scheme and result.scheme in ["http", "https", "ftp", "ftps"]: - return True - else: - return False + return result.scheme and result.scheme in ["http", "https", "ftp", "ftps"] except: - return False \ No newline at end of file + return False + +def encode_params(params): + """ + Encode a dict of params into a query string. + + Use `quote_via=urllib.parse.quote` so that whitespaces will be encoded as + `%20` instead of as `+`. This is needed because oauthlib cannot handle `+` + as a whitespace. + """ + return urlencode(params, quote_via=quote) diff --git a/frappe/integrations/offsite_backup_utils.py b/frappe/integrations/offsite_backup_utils.py index db176538e4..48a2c89107 100644 --- a/frappe/integrations/offsite_backup_utils.py +++ b/frappe/integrations/offsite_backup_utils.py @@ -6,8 +6,7 @@ from __future__ import unicode_literals import frappe import glob import os -from frappe.utils import split_emails, get_backups_path - +from frappe.utils import split_emails, cint def send_email(success, service_name, doctype, email_field, error_status=None): recipients = get_recipients(doctype, email_field) @@ -81,6 +80,22 @@ def get_file_size(file_path, unit): return file_size +def get_chunk_site(file_size): + ''' this function will return chunk size in megabytes based on file size ''' + + file_size_in_gb = cint(file_size/1024/1024) + + MB = 1024 * 1024 + if file_size_in_gb > 5000: + return 200 * MB + elif file_size_in_gb >= 3000: + return 150 * MB + elif file_size_in_gb >= 1000: + return 100 * MB + elif file_size_in_gb >= 500: + return 50 * MB + else: + return 15 * MB def validate_file_size(): frappe.flags.create_new_backup = True @@ -98,4 +113,4 @@ def generate_files_backup(): db_type=frappe.conf.db_type, db_port=frappe.conf.db_port) backup.set_backup_file_name() - backup.zip_files() \ No newline at end of file + backup.zip_files() diff --git a/frappe/integrations/utils.py b/frappe/integrations/utils.py index 808affe47a..1af9682073 100644 --- a/frappe/integrations/utils.py +++ b/frappe/integrations/utils.py @@ -49,16 +49,20 @@ def make_post_request(url, auth=None, headers=None, data=None): frappe.log_error() raise exc -def create_request_log(data, integration_type, service_name, name=None): +def create_request_log(data, integration_type, service_name, name=None, error=None): if isinstance(data, string_types): data = json.loads(data) + if isinstance(error, string_types): + error = json.loads(error) + integration_request = frappe.get_doc({ "doctype": "Integration Request", "integration_type": integration_type, "integration_request_service": service_name, "reference_doctype": data.get("reference_doctype"), "reference_docname": data.get("reference_docname"), + "error": json.dumps(error, default=json_handler), "data": json.dumps(data, default=json_handler) }) diff --git a/frappe/integrations/workspace/integrations/integrations.json b/frappe/integrations/workspace/integrations/integrations.json new file mode 100644 index 0000000000..db96304207 --- /dev/null +++ b/frappe/integrations/workspace/integrations/integrations.json @@ -0,0 +1,260 @@ +{ + "category": "Administration", + "charts": [], + "creation": "2020-03-02 15:16:18.714190", + "developer_mode_only": 0, + "disable_user_customization": 1, + "docstatus": 0, + "doctype": "Workspace", + "extends_another_page": 0, + "hide_custom": 0, + "icon": "integration", + "idx": 0, + "is_standard": 1, + "label": "Integrations", + "links": [ + { + "hidden": 0, + "is_query_report": 0, + "label": "Backup", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Dropbox Settings", + "link_to": "Dropbox Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "S3 Backup Settings", + "link_to": "S3 Backup Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Google Drive", + "link_to": "Google Drive", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Google Services", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Google Settings", + "link_to": "Google Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Google Contacts", + "link_to": "Google Contacts", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Google Calendar", + "link_to": "Google Calendar", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Google Drive", + "link_to": "Google Drive", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Authentication", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Social Login Key", + "link_to": "Social Login Key", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "LDAP Settings", + "link_to": "LDAP Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "OAuth Client", + "link_to": "OAuth Client", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "OAuth Provider Settings", + "link_to": "OAuth Provider Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Payments", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Braintree Settings", + "link_to": "Braintree Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "PayPal Settings", + "link_to": "PayPal Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Razorpay Settings", + "link_to": "Razorpay Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Stripe Settings", + "link_to": "Stripe Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Paytm Settings", + "link_to": "Paytm Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Settings", + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Webhook", + "link_to": "Webhook", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Slack Webhook URL", + "link_to": "Slack Webhook URL", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Twilio Settings", + "link_to": "Twilio Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "SMS Settings", + "link_to": "SMS Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2020-12-01 13:38:39.706680", + "modified_by": "Administrator", + "module": "Integrations", + "name": "Integrations", + "owner": "Administrator", + "pin_to_bottom": 0, + "pin_to_top": 0, + "shortcuts": [] +} \ No newline at end of file diff --git a/frappe/model/__init__.py b/frappe/model/__init__.py index c39a73ccd7..af06696621 100644 --- a/frappe/model/__init__.py +++ b/frappe/model/__init__.py @@ -118,7 +118,7 @@ core_doctypes_list = ( 'Customize Form Field', 'Property Setter', 'Custom Field', - 'Custom Script' + 'Client Script' ) log_types = ( diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 5497090e72..c1e5f01e03 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -12,11 +12,9 @@ from frappe.model.naming import set_new_name from frappe.model.utils.link_count import notify_link_count from frappe.modules import load_doctype_module from frappe.model import display_fieldtypes -from frappe.utils.password import get_decrypted_password, set_encrypted_password from frappe.utils import (cint, flt, now, cstr, strip_html, sanitize_html, sanitize_email, cast_fieldtype) from frappe.utils.html_utils import unescape_html -from bs4 import BeautifulSoup max_positive_value = { 'smallint': 2 ** 15, @@ -48,7 +46,7 @@ def get_controller(doctype): else: class_overrides = frappe.get_hooks('override_doctype_class') if class_overrides and class_overrides.get(doctype): - import_path = frappe.get_hooks('override_doctype_class').get(doctype)[-1] + import_path = class_overrides[doctype][-1] module_path, classname = import_path.rsplit('.', 1) module = frappe.get_module(module_path) if not hasattr(module, classname): @@ -70,8 +68,11 @@ def get_controller(doctype): if frappe.local.dev_server: return _get_controller() - key = '{}:doctype_classes'.format(frappe.local.site) - return frappe.cache().hget(key, doctype, generator=_get_controller, shared=True) + site_controllers = frappe.controllers.setdefault(frappe.local.site, {}) + if doctype not in site_controllers: + site_controllers[doctype] = _get_controller() + + return site_controllers[doctype] class BaseDocument(object): ignore_in_getter = ("doctype", "_meta", "meta", "_table_fields", "_valid_columns") @@ -91,6 +92,14 @@ class BaseDocument(object): return self._meta def update(self, d): + """ Update multiple fields of a doctype using a dictionary of key-value pairs. + + Example: + doc.update({ + "user": "admin", + "balance": 42000 + }) + """ if "doctype" in d: self.set("doctype", d.get("doctype")) @@ -156,6 +165,15 @@ class BaseDocument(object): del self.__dict__[key] def append(self, key, value=None): + """ Append an item to a child table. + + Example: + doc.append("childtable", { + "child_table_field": "value", + "child_table_int_field": 0, + ... + }) + """ if value==None: value={} if isinstance(value, (dict, BaseDocument)): @@ -347,7 +365,7 @@ class BaseDocument(object): if self.meta.autoname=="hash": # hash collision? try again frappe.flags.retry_count = (frappe.flags.retry_count or 0) + 1 - if frappe.flags.retry_count > 5: + if frappe.flags.retry_count > 5 and not frappe.flags.in_test: raise self.name = None self.db_insert() @@ -399,25 +417,60 @@ class BaseDocument(object): doc.db_update() def show_unique_validation_message(self, e): - # TODO: Find a better way to extract fieldname if frappe.db.db_type != 'postgres': fieldname = str(e).split("'")[-2] label = None - # unique_first_fieldname_second_fieldname is the constraint name - # created using frappe.db.add_unique - if "unique_" in fieldname: - fieldname = fieldname.split("_", 1)[1] + # MariaDB gives key_name in error. Extracting fieldname from key name + try: + fieldname = self.get_field_name_by_key_name(fieldname) + except IndexError: + pass - df = self.meta.get_field(fieldname) - if df: - label = df.label + label = self.get_label_from_fieldname(fieldname) frappe.msgprint(_("{0} must be unique").format(label or fieldname)) # this is used to preserve traceback raise frappe.UniqueValidationError(self.doctype, self.name, e) + def get_field_name_by_key_name(self, key_name): + """MariaDB stores a mapping between `key_name` and `column_name`. + This function returns the `column_name` associated with the `key_name` passed + + Args: + key_name (str): The name of the database index. + + Raises: + IndexError: If the key is not found in the table. + + Returns: + str: The column name associated with the key. + """ + return frappe.db.sql(f""" + SHOW + INDEX + FROM + `tab{self.doctype}` + WHERE + key_name=%s + AND + Non_unique=0 + """, key_name, as_dict=True)[0].get("Column_name") + + def get_label_from_fieldname(self, fieldname): + """Returns the associated label for fieldname + + Args: + fieldname (str): The fieldname in the DocType to use to pull the label. + + Returns: + str: The label associated with the fieldname, if found, otherwise `None`. + """ + df = self.meta.get_field(fieldname) + if df: + return df.label + def update_modified(self): """Update modified timestamp""" self.set("modified", now()) @@ -701,6 +754,8 @@ class BaseDocument(object): - Ignore if 'Ignore XSS Filter' is checked or fieldtype is 'Code' """ + from bs4 import BeautifulSoup + if frappe.flags.in_install: return @@ -737,6 +792,8 @@ class BaseDocument(object): def _save_passwords(self): """Save password field values in __Auth table""" + from frappe.utils.password import set_encrypted_password + if self.flags.ignore_save_passwords is True: return @@ -751,6 +808,8 @@ class BaseDocument(object): self.set(df.fieldname, '*'*len(new_password)) def get_password(self, fieldname='password', raise_exception=True): + from frappe.utils.password import get_decrypted_password + if self.get(fieldname) and not self.is_dummy_password(self.get(fieldname)): return self.get(fieldname) @@ -802,12 +861,12 @@ class BaseDocument(object): if translated: val = _(val) - if absolute_value and isinstance(val, (int, float)): - val = abs(self.get(fieldname)) - if not doc: doc = getattr(self, "parent_doc", None) or self + if (absolute_value or doc.get('absolute_value')) and isinstance(val, (int, float)): + val = abs(self.get(fieldname)) + return format_value(val, df=df, doc=doc, currency=currency) def is_print_hide(self, fieldname, df=None, for_print=True): diff --git a/frappe/model/create_new.py b/frappe/model/create_new.py index fcf648e718..e0087a9e40 100644 --- a/frappe/model/create_new.py +++ b/frappe/model/create_new.py @@ -10,7 +10,7 @@ import copy import frappe import frappe.defaults from frappe.model import data_fieldtypes -from frappe.utils import nowdate, nowtime, now_datetime +from frappe.utils import nowdate, nowtime, now_datetime, cstr from frappe.core.doctype.user_permission.user_permission import get_user_permissions from frappe.permissions import filter_allowed_docs_for_doctype @@ -99,7 +99,7 @@ def get_static_default_value(df, doctype_user_permissions, allowed_records): elif df.default == "Today": return nowdate() - elif not df.default.startswith(":"): + elif not cstr(df.default).startswith(":"): # a simple default value is_allowed_default_value = (not user_permissions_exist(df, doctype_user_permissions) or (df.default in allowed_records)) @@ -116,7 +116,7 @@ def set_dynamic_default_values(doc, parent_doc, parentfield): for df in frappe.get_meta(doc["doctype"]).get("fields"): if df.get("default"): - if df.default.startswith(":"): + if cstr(df.default).startswith(":"): default_value = get_default_based_on_another_field(df, user_permissions, parent_doc) if default_value is not None and not doc.get(df.fieldname): doc[df.fieldname] = default_value diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index fb8a027d20..8eac75eb65 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -18,6 +18,7 @@ from frappe.client import check_parent_permission from frappe.model.utils.user_settings import get_user_settings, update_user_settings from frappe.utils import flt, cint, get_time, make_filter_tuple, get_filter, add_to_date, cstr, get_timespan_date_range from frappe.model.meta import get_table_columns +from frappe.core.doctype.server_script.server_script_utils import get_server_script_map class DatabaseQuery(object): def __init__(self, doctype, user=None): @@ -38,8 +39,11 @@ 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): - if not ignore_permissions and not frappe.has_permission(self.doctype, "read", user=user): + return_query=False, strict=True, pluck=None, ignore_ddl=False): + if not ignore_permissions and \ + not frappe.has_permission(self.doctype, "select", user=user) and \ + not frappe.has_permission(self.doctype, "read", user=user): + frappe.flags.error_message = _('Insufficient Permission for {0}').format(frappe.bold(self.doctype)) raise frappe.PermissionError(self.doctype) @@ -58,7 +62,10 @@ class DatabaseQuery(object): if fields: self.fields = fields else: - self.fields = ["`tab{0}`.`name`".format(self.doctype)] + if pluck: + self.fields = ["`tab{0}`.`{1}`".format(self.doctype, pluck)] + else: + self.fields = ["`tab{0}`.`name`".format(self.doctype)] if start: limit_start = start if page_length: limit_page_length = page_length @@ -83,6 +90,7 @@ class DatabaseQuery(object): self.user_settings_fields = copy.deepcopy(self.fields) self.return_query = return_query self.strict = strict + self.ignore_ddl = ignore_ddl # for contextual user permission check # to determine which user permission is applicable on link field of specific doctype @@ -91,6 +99,11 @@ class DatabaseQuery(object): if user_settings: self.user_settings = json.loads(user_settings) + self.columns = self.get_table_columns() + + # no table & ignore_ddl, return + if not self.columns: return [] + if query: result = self.run_custom_query(query) else: @@ -131,7 +144,8 @@ class DatabaseQuery(object): if self.return_query: return query else: - return frappe.db.sql(query, as_dict=not self.as_list, debug=self.debug, update=self.update) + return frappe.db.sql(query, as_dict=not self.as_list, debug=self.debug, + update=self.update, ignore_ddl=self.ignore_ddl) def prepare_args(self): self.parse_args() @@ -168,8 +182,16 @@ class DatabaseQuery(object): fields = [] + # Wrapping fields with grave quotes to allow support for sql keywords + # TODO: Add support for wrapping fields with sql functions and distinct keyword for field in self.fields: - if (field.strip().startswith(("`", "*")) or "(" in field): + stripped_field = field.strip().lower() + skip_wrapping = any([ + stripped_field.startswith(("`", "*", '"', "'")), + "(" in stripped_field, + "distinct" in stripped_field, + ]) + if skip_wrapping: fields.append(field) elif "as" in field.lower().split(" "): col, _, new = field.split() @@ -296,7 +318,10 @@ class DatabaseQuery(object): def append_table(self, table_name): self.tables.append(table_name) doctype = table_name[4:-1] - if (not self.flags.ignore_permissions) and (not frappe.has_permission(doctype)): + ptype = 'select' if frappe.only_has_select_perm(doctype) else 'read' + + if (not self.flags.ignore_permissions) and\ + (not frappe.has_permission(doctype, ptype=ptype)): frappe.flags.error_message = _('Insufficient Permission for {0}').format(frappe.bold(doctype)) raise frappe.PermissionError(doctype) @@ -312,15 +337,22 @@ class DatabaseQuery(object): if '.' not in field and not _in_standard_sql_methods(field): self.fields[idx] = '{0}.{1}'.format(self.tables[0], field) + def get_table_columns(self): + try: + return get_table_columns(self.doctype) + except frappe.db.TableMissingError: + if self.ignore_ddl: + return None + else: + raise + def set_optional_columns(self): """Removes optional columns like `_user_tags`, `_comments` etc. if not in table""" - columns = get_table_columns(self.doctype) - # remove from fields to_remove = [] for fld in self.fields: for f in optional_fields: - if f in fld and not f in columns: + if f in fld and not f in self.columns: to_remove.append(fld) for fld in to_remove: @@ -333,7 +365,7 @@ class DatabaseQuery(object): each = [each] for element in each: - if element in optional_fields and element not in columns: + if element in optional_fields and element not in self.columns: to_remove.append(each) for each in to_remove: @@ -550,7 +582,7 @@ class DatabaseQuery(object): self.shared = frappe.share.get_shared(self.doctype, self.user) if (not meta.istable and - not role_permissions.get("read") and + not (role_permissions.get("select") or role_permissions.get("read")) and not self.flags.ignore_permissions and not has_any_user_permission_for_doctype(self.doctype, self.user, self.reference_doctype)): only_if_shared = True @@ -565,7 +597,7 @@ class DatabaseQuery(object): self.match_conditions.append("`tab{0}`.`owner` = {1}".format(self.doctype, frappe.db.escape(self.user, percent=False))) # add user permission only if role has read perm - elif role_permissions.get("read"): + elif role_permissions.get("read") or role_permissions.get("select"): # get user permissions user_permissions = frappe.permissions.get_user_permissions(self.user) self.add_user_permissions(user_permissions) @@ -658,15 +690,23 @@ class DatabaseQuery(object): self.match_filters.append(match_filters) def get_permission_query_conditions(self): + conditions = [] condition_methods = frappe.get_hooks("permission_query_conditions", {}).get(self.doctype, []) if condition_methods: - conditions = [] for method in condition_methods: c = frappe.call(frappe.get_attr(method), self.user) if c: conditions.append(c) - return " and ".join(conditions) if conditions else None + permision_script_name = get_server_script_map().get("permission_query", {}).get(self.doctype) + if permision_script_name: + script = frappe.get_doc("Server Script", permision_script_name) + condition = script.get_permission_query_conditions(self.user) + if condition: + conditions.append(condition) + + return " and ".join(conditions) if conditions else "" + def run_custom_query(self, query): if '%(key)s' in query: diff --git a/frappe/model/delete_doc.py b/frappe/model/delete_doc.py index a38470e3f5..d0e0a6fb1a 100644 --- a/frappe/model/delete_doc.py +++ b/frappe/model/delete_doc.py @@ -68,7 +68,7 @@ def delete_doc(doctype=None, name=None, force=0, ignore_doctypes=None, for_reloa check_permission_and_not_submitted(doc) frappe.db.sql("delete from `tabCustom Field` where dt = %s", name) - frappe.db.sql("delete from `tabCustom Script` where dt = %s", name) + frappe.db.sql("delete from `tabClient Script` where dt = %s", name) frappe.db.sql("delete from `tabProperty Setter` where doc_type = %s", name) frappe.db.sql("delete from `tabReport` where ref_doctype=%s", name) frappe.db.sql("delete from `tabCustom DocPerm` where parent=%s", name) @@ -76,7 +76,12 @@ def delete_doc(doctype=None, name=None, force=0, ignore_doctypes=None, for_reloa delete_from_table(doctype, name, ignore_doctypes, None) - if not (for_reload or frappe.flags.in_migrate or frappe.flags.in_install or frappe.flags.in_uninstall or frappe.flags.in_test): + if frappe.conf.developer_mode and not doc.custom and not ( + for_reload + or frappe.flags.in_migrate + or frappe.flags.in_install + or frappe.flags.in_uninstall + ): try: delete_controllers(name, doc.module) except (FileNotFoundError, OSError, KeyError): @@ -287,8 +292,8 @@ def check_if_doc_is_dynamically_linked(doc, method="Delete"): raise_link_exists_exception(doc, reference_doctype, reference_docname, at_position) def raise_link_exists_exception(doc, reference_doctype, reference_docname, row=''): - doc_link = '{1}'.format(doc.doctype, doc.name) - reference_link = '{1}'.format(reference_doctype, reference_docname) + doc_link = '{1}'.format(doc.doctype, doc.name) + reference_link = '{1}'.format(reference_doctype, reference_docname) #hack to display Single doctype only once in message if reference_doctype == reference_docname: @@ -335,19 +340,25 @@ def clear_timeline_references(link_doctype, link_name): WHERE `tabCommunication Link`.link_doctype=%s AND `tabCommunication Link`.link_name=%s""", (link_doctype, link_name)) def insert_feed(doc): - from frappe.utils import get_fullname - - if frappe.flags.in_install or frappe.flags.in_import or getattr(doc, "no_feed_on_delete", False): + if ( + frappe.flags.in_install + or frappe.flags.in_uninstall + or frappe.flags.in_import + or getattr(doc, "no_feed_on_delete", False) + ): return + from frappe.utils import get_fullname + frappe.get_doc({ "doctype": "Comment", "comment_type": "Deleted", "reference_doctype": doc.doctype, "subject": "{0} {1}".format(_(doc.doctype), doc.name), - "full_name": get_fullname(doc.owner) + "full_name": get_fullname(doc.owner), }).insert(ignore_permissions=True) + def delete_controllers(doctype, module): """ Delete controller code in the doctype folder diff --git a/frappe/model/document.py b/frappe/model/document.py index 53fcd99f78..d426dadd06 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -6,7 +6,6 @@ import frappe import time from frappe import _, msgprint from frappe.utils import flt, cstr, now, get_datetime_str, file_lock, date_diff -from frappe.utils.background_jobs import enqueue from frappe.model.base_document import BaseDocument, get_controller from frappe.model.naming import set_new_name from six import iteritems, string_types @@ -493,6 +492,7 @@ class Document(BaseDocument): self._validate_mandatory() self._validate_data_fields() self._validate_selects() + self._validate_non_negative() self._validate_length() self._extract_images_from_text_editor() self._sanitize_content() @@ -503,6 +503,7 @@ class Document(BaseDocument): for d in children: d._validate_data_fields() d._validate_selects() + d._validate_non_negative() d._validate_length() d._extract_images_from_text_editor() d._sanitize_content() @@ -514,6 +515,21 @@ class Document(BaseDocument): else: self.validate_set_only_once() + def _validate_non_negative(self): + def get_msg(df): + if self.parentfield: + return "{} {} #{}: {} {}".format(frappe.bold(_(self.doctype)), + _("Row"), self.idx, _("Value cannot be negative for"), frappe.bold(_(df.label))) + else: + return _("Value cannot be negative for {0}: {1}").format(_(df.parent), frappe.bold(_(df.label))) + + for df in self.meta.get('fields', {'non_negative': ('=', 1), + 'fieldtype': ('in', ['Int', 'Float', 'Currency'])}): + + if flt(self.get(df.fieldname)) < 0: + msg = get_msg(df) + frappe.throw(msg, frappe.NonNegativeError, title=_("Negative Value")) + def validate_workflow(self): """Validate if the workflow transition is valid""" if frappe.flags.in_install == 'frappe': return @@ -922,15 +938,17 @@ class Document(BaseDocument): self.load_doc_before_save() self.reset_seen() + # before_validate method should be executed before ignoring validations + if self._action in ("save", "submit"): + self.run_method("before_validate") + if self.flags.ignore_validate: return if self._action=="save": - self.run_method("before_validate") self.run_method("validate") self.run_method("before_save") elif self._action=="submit": - self.run_method("before_validate") self.run_method("validate") self.run_method("before_submit") elif self._action=="cancel": @@ -996,6 +1014,8 @@ class Document(BaseDocument): def notify_update(self): """Publish realtime that the current document is modified""" + if frappe.flags.in_patch: return + frappe.publish_realtime("doc_update", {"modified": self.modified, "doctype": self.doctype, "name": self.name}, doctype=self.doctype, docname=self.name, after_commit=True) @@ -1171,8 +1191,8 @@ class Document(BaseDocument): doc.set(fieldname, flt(doc.get(fieldname), self.precision(fieldname, doc.parentfield))) def get_url(self): - """Returns Desk URL for this document. `/desk#Form/{doctype}/{name}`""" - return "/desk#Form/{doctype}/{name}".format(doctype=self.doctype, name=self.name) + """Returns Desk URL for this document. `/app/Form/{doctype}/{name}`""" + return "/app/Form/{doctype}/{name}".format(doctype=self.doctype, name=self.name) def add_comment(self, comment_type='Comment', text=None, comment_email=None, link_doctype=None, link_name=None, comment_by=None): """Add a comment to this document. @@ -1248,6 +1268,8 @@ class Document(BaseDocument): # call _submit instead of submit, so you can override submit to call # run_delayed based on some action # See: Stock Reconciliation + from frappe.utils.background_jobs import enqueue + if hasattr(self, '_' + action): action = '_' + action diff --git a/frappe/model/dynamic_links.py b/frappe/model/dynamic_links.py index e5ce9102e2..7404ba407e 100644 --- a/frappe/model/dynamic_links.py +++ b/frappe/model/dynamic_links.py @@ -42,9 +42,12 @@ def get_dynamic_link_map(for_delete=False): # always check in Single DocTypes dynamic_link_map.setdefault(meta.name, []).append(df) else: - links = frappe.db.sql_list("""select distinct {options} from `tab{parent}`""".format(**df)) - for doctype in links: - dynamic_link_map.setdefault(doctype, []).append(df) + try: + links = frappe.db.sql_list("""select distinct {options} from `tab{parent}`""".format(**df)) + for doctype in links: + dynamic_link_map.setdefault(doctype, []).append(df) + except frappe.db.TableMissingError: # noqa: E722 + pass frappe.local.dynamic_link_map = dynamic_link_map return frappe.local.dynamic_link_map diff --git a/frappe/model/meta.py b/frappe/model/meta.py index 1cc3abba5b..5dc7ca2d4d 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -19,7 +19,7 @@ from __future__ import unicode_literals, print_function from datetime import datetime from six.moves import range import frappe, json, os -from frappe.utils import cstr, cint +from frappe.utils import cstr, cint, cast_fieldtype from frappe.model import default_fields, no_value_fields, optional_fields, data_fieldtypes, table_fields from frappe.model.document import Document from frappe.model.base_document import BaseDocument @@ -68,7 +68,7 @@ def load_doctype_from_file(doctype): class Meta(Document): _metaclass = True default_fields = list(default_fields)[1:] - special_doctypes = ("DocField", "DocPerm", "Role", "DocType", "Module Def", 'DocType Action', 'DocType Link') + special_doctypes = ("DocField", "DocPerm", "DocType", "Module Def", 'DocType Action', 'DocType Link') def __init__(self, doctype): self._fields = {} @@ -103,6 +103,7 @@ class Meta(Document): self.sort_fields() self.get_valid_columns() self.set_custom_permissions() + self.add_custom_links_and_actions() def as_dict(self, no_nulls = False): def serialize(doc): @@ -208,7 +209,8 @@ class Meta(Document): 'owner': _('Created By'), 'modified_by': _('Modified By'), 'creation': _('Created On'), - 'modified': _('Last Modified On') + 'modified': _('Last Modified On'), + '_assign': _('Assigned To') }.get(fieldname) or _('No Label') return label @@ -305,6 +307,11 @@ class Meta(Document): self.extend("fields", custom_fields) def apply_property_setters(self): + """ + Property Setters are set via Customize Form. They override standard properties + of the doctype or its child properties like fields, links etc. This method + applies the customized properties over the standard meta object + """ if not frappe.db.table_exists('Property Setter'): return @@ -313,26 +320,52 @@ class Meta(Document): if not property_setters: return - integer_docfield_properties = [d.fieldname for d in frappe.get_meta('DocField').fields - if d.fieldtype in ('Int', 'Check')] - for ps in property_setters: if ps.doctype_or_field=='DocType': - if ps.property_type in ('Int', 'Check'): - ps.value = cint(ps.value) + self.set(ps.property, cast_fieldtype(ps.property_type, ps.value)) - self.set(ps.property, ps.value) - else: - docfield = self.get("fields", {"fieldname":ps.field_name}, limit=1) - if docfield: - docfield = docfield[0] - else: - continue + elif ps.doctype_or_field=='DocField': + for d in self.fields: + if d.fieldname == ps.field_name: + d.set(ps.property, cast_fieldtype(ps.property_type, ps.value)) + break - if ps.property in integer_docfield_properties: - ps.value = cint(ps.value) + elif ps.doctype_or_field=='DocType Link': + for d in self.links: + if d.name == ps.row_name: + d.set(ps.property, cast_fieldtype(ps.property_type, ps.value)) + break - docfield.set(ps.property, ps.value) + elif ps.doctype_or_field=='DocType Action': + for d in self.actions: + if d.name == ps.row_name: + d.set(ps.property, cast_fieldtype(ps.property_type, ps.value)) + break + + def add_custom_links_and_actions(self): + for doctype, fieldname in (('DocType Link', 'links'), ('DocType Action', 'actions')): + # ignore_ddl because the `custom` column was added later via a patch + for d in frappe.get_all(doctype, fields='*', filters=dict(parent=self.name, custom=1), ignore_ddl=True): + self.append(fieldname, d) + + # set the fields in order if specified + # order is saved as `links_order` + order = json.loads(self.get('{}_order'.format(fieldname)) or '[]') + if order: + name_map = {d.name:d for d in self.get(fieldname)} + new_list = [] + for name in order: + if name in name_map: + new_list.append(name_map[name]) + + # add the missing items that have not be added + # maybe these items were added to the standard product + # after the customization was done + for d in self.get(fieldname): + if d not in new_list: + new_list.append(d) + + self.set(fieldname, new_list) def sort_fields(self): """sort on basis of insert_after""" @@ -417,6 +450,25 @@ class Meta(Document): return self.high_permlevel_fields + def get_permlevel_access(self, permission_type='read', parenttype=None): + has_access_to = [] + roles = frappe.get_roles() + for perm in self.get_permissions(parenttype): + if perm.role in roles and perm.permlevel > 0 and perm.get(permission_type): + if perm.permlevel not in has_access_to: + has_access_to.append(perm.permlevel) + + return has_access_to + + def get_permissions(self, parenttype=None): + if self.istable and parenttype: + # use parent permissions + permissions = frappe.get_meta(parenttype).permissions + else: + permissions = self.get('permissions', []) + + return permissions + def get_dashboard_data(self): '''Returns dashboard setup related to this doctype. @@ -448,16 +500,18 @@ class Meta(Document): if hasattr(self, 'links') and self.links: dashboard_links.extend(self.links) - if frappe.get_all("Custom Link", {"document_type": self.name}): - dashboard_links.extend(frappe.get_doc("Custom Link", self.name).links) - if not data.transactions: # init groups data.transactions = [] + + if not data.non_standard_fieldnames: data.non_standard_fieldnames = {} for link in dashboard_links: link.added = False + if link.hidden: + continue + for group in data.transactions: group = frappe._dict(group) # group found diff --git a/frappe/model/naming.py b/frappe/model/naming.py index 9ea5fc0ca4..e954debe6f 100644 --- a/frappe/model/naming.py +++ b/frappe/model/naming.py @@ -67,7 +67,6 @@ def set_new_name(doc): frappe.get_meta(doc.doctype).get_field("name_case") ) - def set_name_from_naming_options(autoname, doc): """ Get a name based on the autoname field option @@ -93,15 +92,12 @@ def set_naming_from_document_naming_rule(doc): if doc.doctype in log_types: return - try: - for d in frappe.get_all('Document Naming Rule', - dict(document_type=doc.doctype, disabled=0), order_by='priority desc'): - frappe.get_cached_doc('Document Naming Rule', d.name).apply(doc) - if doc.name: - break - except frappe.db.TableMissingError: # noqa: E722 - # not yet bootstrapped - pass + # ignore_ddl if naming is not yet bootstrapped + for d in frappe.get_all('Document Naming Rule', + dict(document_type=doc.doctype, disabled=0), order_by='priority desc', ignore_ddl=True): + frappe.get_cached_doc('Document Naming Rule', d.name).apply(doc) + if doc.name: + break def set_name_by_naming_series(doc): """Sets name by the `naming_series` property""" diff --git a/frappe/model/rename_doc.py b/frappe/model/rename_doc.py index 7a2129e76e..2c9dc5d823 100644 --- a/frappe/model/rename_doc.py +++ b/frappe/model/rename_doc.py @@ -1,14 +1,15 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # MIT License. See license.txt -from __future__ import unicode_literals, print_function +from __future__ import print_function, unicode_literals + import frappe from frappe import _, bold -from frappe.utils import cint -from frappe.model.naming import validate_name from frappe.model.dynamic_links import get_dynamic_link_map -from frappe.utils.password import rename_password +from frappe.model.naming import validate_name from frappe.model.utils.user_settings import sync_user_settings, update_user_settings_data +from frappe.utils import cint +from frappe.utils.password import rename_password @frappe.whitelist() @@ -20,8 +21,16 @@ def update_document_title(doctype, docname, title_field=None, old_title=None, ne docname = rename_doc(doctype=doctype, old=docname, new=new_name, merge=merge) if old_title and new_title and not old_title == new_title: - frappe.db.set_value(doctype, docname, title_field, new_title) - frappe.msgprint(_('Saved'), alert=True, indicator='green') + try: + frappe.db.set_value(doctype, docname, title_field, new_title) + frappe.msgprint(_('Saved'), alert=True, indicator='green') + except Exception as e: + if frappe.db.is_duplicate_entry(e): + frappe.throw( + _("{0} {1} already exists").format(doctype, frappe.bold(docname)), + title=_("Duplicate Name"), + exc=frappe.DuplicateEntryError + ) return docname @@ -42,16 +51,13 @@ def rename_doc(doctype, old, new, force=False, merge=False, ignore_permissions=F force = cint(force) merge = cint(merge) - meta = frappe.get_meta(doctype) # call before_rename old_doc = frappe.get_doc(doctype, old) out = old_doc.run_method("before_rename", old, new, merge) or {} new = (out.get("new") or new) if isinstance(out, dict) else (out or new) - - if doctype != "DocType": - new = validate_rename(doctype, new, meta, merge, force, ignore_permissions) + new = validate_rename(doctype, new, meta, merge, force, ignore_permissions) if not merge: rename_parent_and_child(doctype, old, new, meta) @@ -249,14 +255,21 @@ def update_link_field_values(link_fields, old, new, doctype): # or no longer exists pass else: - # because the table hasn't been renamed yet! - parent = field['parent'] if field['parent']!=new else old + parent = field['parent'] + docfield = field["fieldname"] + + # Handles the case where one of the link fields belongs to + # the DocType being renamed. + # Here this field could have the current DocType as its value too. + + # In this case while updating link field value, the field's parent + # or the current DocType table name hasn't been renamed yet, + # so consider it's old name. + if parent == new and doctype == "DocType": + parent = old + + frappe.db.set_value(parent, {docfield: old}, docfield, new) - frappe.db.sql(""" - update `tab{table_name}` set `{fieldname}`=%s - where `{fieldname}`=%s""".format( - table_name=parent, - fieldname=field['fieldname']), (new, old)) # update cached link_fields as per new if doctype=='DocType' and field['parent'] == old: field['parent'] = new @@ -306,8 +319,7 @@ def get_link_fields(doctype): def update_options_for_fieldtype(fieldtype, old, new): if frappe.conf.developer_mode: - for name in frappe.db.sql_list("""select parent from - tabDocField where options=%s""", old): + for name in frappe.get_all("DocField", filters={"options": old}, pluck="parent"): doctype = frappe.get_doc("DocType", name) save = False for f in doctype.fields: @@ -413,20 +425,21 @@ def update_parenttype_values(old, new): child_doctypes += custom_child_doctypes fields = [d['fieldname'] for d in child_doctypes] - property_setter_child_doctypes = frappe.db.sql("""\ - select value as options from `tabProperty Setter` - where doc_type=%s and property='options' and - field_name in ("%s")""" % ('%s', '", "'.join(fields)), - (new,)) + property_setter_child_doctypes = frappe.get_all( + "Property Setter", + filters={ + "doc_type": new, + "property": "options", + "field_name": ("in", fields) + }, + pluck="value" + ) + child_doctypes = list(d['options'] for d in child_doctypes) child_doctypes += property_setter_child_doctypes - child_doctypes = (d['options'] for d in child_doctypes) for doctype in child_doctypes: - frappe.db.sql("""\ - update `tab%s` set parenttype=%s - where parenttype=%s""" % (doctype, '%s', '%s'), - (new, old)) + frappe.db.sql(f"update `tab{doctype}` set parenttype=%s where parenttype=%s", (new, old)) def rename_dynamic_links(doctype, old, new): for df in get_dynamic_link_map().get(doctype, []): @@ -482,60 +495,30 @@ def bulk_rename(doctype, rows=None, via_console = False): return rename_log def update_linked_doctypes(doctype, docname, linked_to, value, ignore_doctypes=None): - """ - linked_doctype_info_list = list formed by get_fetch_fields() function - docname = Master DocType's name in which modification are made - value = Value for the field thats set in other DocType's by fetching from Master DocType - """ - linked_doctype_info_list = get_fetch_fields(doctype, linked_to, ignore_doctypes) + from frappe.model.utils.rename_doc import update_linked_doctypes + show_deprecation_warning("update_linked_doctypes") + + return update_linked_doctypes( + doctype=doctype, + docname=docname, + linked_to=linked_to, + value=value, + ignore_doctypes=ignore_doctypes, + ) - for d in linked_doctype_info_list: - frappe.db.sql(""" - update - `tab{doctype}` - set - {linked_to_fieldname} = "{value}" - where - {master_fieldname} = {docname} - and {linked_to_fieldname} != "{value}" - """.format( - doctype = d['doctype'], - linked_to_fieldname = d['linked_to_fieldname'], - value = value, - master_fieldname = d['master_fieldname'], - docname = frappe.db.escape(docname) - )) def get_fetch_fields(doctype, linked_to, ignore_doctypes=None): - """ - doctype = Master DocType in which the changes are being made - linked_to = DocType name of the field thats being updated in Master + from frappe.model.utils.rename_doc import get_fetch_fields + show_deprecation_warning("get_fetch_fields") - This function fetches list of all DocType where both doctype and linked_to is found - as link fields. - Forms a list of dict in the form - - [{doctype: , master_fieldname: , linked_to_fieldname: ] - where - doctype = DocType where changes need to be made - master_fieldname = Fieldname where options = doctype - linked_to_fieldname = Fieldname where options = linked_to - """ + return get_fetch_fields( + doctype=doctype, linked_to=linked_to, ignore_doctypes=ignore_doctypes + ) - master_list = get_link_fields(doctype) - linked_to_list = get_link_fields(linked_to) - out = [] - - from itertools import product - product_list = product(master_list, linked_to_list) - - for d in product_list: - linked_doctype_info = frappe._dict() - if d[0]['parent'] == d[1]['parent'] \ - and (not ignore_doctypes or d[0]['parent'] not in ignore_doctypes) \ - and not d[1]['issingle']: - linked_doctype_info['doctype'] = d[0]['parent'] - linked_doctype_info['master_fieldname'] = d[0]['fieldname'] - linked_doctype_info['linked_to_fieldname'] = d[1]['fieldname'] - out.append(linked_doctype_info) - - return out +def show_deprecation_warning(funct): + from click import secho + message = ( + f"Function frappe.model.rename_doc.{funct} has been deprecated and " + "moved to the frappe.model.utils.rename_doc" + ) + secho(message, fg="yellow") diff --git a/frappe/model/sync.py b/frappe/model/sync.py index b7d9d4d548..61983d322c 100644 --- a/frappe/model/sync.py +++ b/frappe/model/sync.py @@ -52,23 +52,22 @@ def sync_for(app_name, force=0, sync_everything = False, verbose=False, reset_pe ("desk", "onboarding_step"), ("desk", "onboarding_step_map"), ("desk", "module_onboarding"), - ("desk", "desk_card"), - ("desk", "desk_chart"), - ("desk", "desk_shortcut"), - ("desk", "desk_page")): + ("desk", "workspace_link"), + ("desk", "workspace_chart"), + ("desk", "workspace_shortcut"), + ("desk", "workspace")): files.append(os.path.join(frappe.get_app_path("frappe"), d[0], "doctype", d[1], d[1] + ".json")) for module_name in frappe.local.app_modules.get(app_name) or []: folder = os.path.dirname(frappe.get_module(app_name + "." + module_name).__file__) - get_doc_files(files, folder, force, sync_everything, verbose=verbose) + get_doc_files(files, folder) l = len(files) if l: for i, doc_path in enumerate(files): import_file_by_path(doc_path, force=force, ignore_version=True, reset_permissions=reset_permissions, for_sync=True) - #print module_name + ' | ' + doctype + ' | ' + name frappe.db.commit() @@ -78,13 +77,13 @@ def sync_for(app_name, force=0, sync_everything = False, verbose=False, reset_pe # print each progress bar on new line print() -def get_doc_files(files, start_path, force=0, sync_everything = False, verbose=False): +def get_doc_files(files, start_path): """walk and sync all doctypes and pages""" # load in sequence - warning for devs document_types = ['doctype', 'page', 'report', 'dashboard_chart_source', 'print_format', 'website_theme', 'web_form', 'web_template', 'notification', 'print_style', - 'data_migration_mapping', 'data_migration_plan', 'desk_page', + 'data_migration_mapping', 'data_migration_plan', 'workspace', 'onboarding_step', 'module_onboarding'] for doctype in document_types: diff --git a/frappe/model/utils/rename_doc.py b/frappe/model/utils/rename_doc.py new file mode 100644 index 0000000000..bf71d36a42 --- /dev/null +++ b/frappe/model/utils/rename_doc.py @@ -0,0 +1,58 @@ +from itertools import product + +import frappe +from frappe.model.rename_doc import get_link_fields + + +def update_linked_doctypes(doctype, docname, linked_to, value, ignore_doctypes=None): + """ + linked_doctype_info_list = list formed by get_fetch_fields() function + docname = Master DocType's name in which modification are made + value = Value for the field thats set in other DocType's by fetching from Master DocType + """ + linked_doctype_info_list = get_fetch_fields(doctype, linked_to, ignore_doctypes) + + for d in linked_doctype_info_list: + frappe.db.set_value( + d.doctype, + { + d.master_fieldname : docname, + d.linked_to_fieldname : ("!=", value), + }, + d.linked_to_fieldname, + value, + ) + + +def get_fetch_fields(doctype, linked_to, ignore_doctypes=None): + """ + doctype = Master DocType in which the changes are being made + linked_to = DocType name of the field thats being updated in Master + This function fetches list of all DocType where both doctype and linked_to is found + as link fields. + Forms a list of dict in the form - + [{doctype: , master_fieldname: , linked_to_fieldname: ] + where + doctype = DocType where changes need to be made + master_fieldname = Fieldname where options = doctype + linked_to_fieldname = Fieldname where options = linked_to + """ + + out = [] + master_list = get_link_fields(doctype) + linked_to_list = get_link_fields(linked_to) + product_list = product(master_list, linked_to_list) + + for d in product_list: + linked_doctype_info = frappe._dict() + if ( + d[0]["parent"] == d[1]["parent"] + and (not ignore_doctypes or d[0]["parent"] not in ignore_doctypes) + and not d[1]["issingle"] + ): + linked_doctype_info.doctype = d[0]["parent"] + linked_doctype_info.master_fieldname = d[0]["fieldname"] + linked_doctype_info.linked_to_fieldname = d[1]["fieldname"] + out.append(linked_doctype_info) + + return out diff --git a/frappe/model/workflow.py b/frappe/model/workflow.py index 7239b202bd..3e8125f9b1 100644 --- a/frappe/model/workflow.py +++ b/frappe/model/workflow.py @@ -29,6 +29,8 @@ def get_transitions(doc, workflow = None, raise_exception=False): if doc.is_new(): return [] + doc.load_from_db() + frappe.has_permission(doc, 'read', throw=True) roles = frappe.get_roles() @@ -51,14 +53,17 @@ def get_transitions(doc, workflow = None, raise_exception=False): return transitions def get_workflow_safe_globals(): - # access to frappe.db.get_value and frappe.db.get_list + # access to frappe.db.get_value, frappe.db.get_list, and date time utils. return dict( frappe=frappe._dict( - db=frappe._dict( - get_value=frappe.db.get_value, - get_list=frappe.db.get_list + db=frappe._dict(get_value=frappe.db.get_value, get_list=frappe.db.get_list), + session=frappe.session, + utils=frappe._dict( + now_datetime=frappe.utils.now_datetime, + add_to_date=frappe.utils.add_to_date, + get_datetime=frappe.utils.get_datetime, + now=frappe.utils.now, ), - session=frappe.session ) ) @@ -115,9 +120,8 @@ def apply_workflow(doc, action): return doc @frappe.whitelist() -def can_cancel_document(doc): - doc = frappe.get_doc(frappe.parse_json(doc)) - workflow = get_workflow(doc.doctype) +def can_cancel_document(doctype): + workflow = get_workflow(doctype) for state_doc in workflow.states: if state_doc.doc_status == '2': for transition in workflow.transitions: diff --git a/frappe/modules/utils.py b/frappe/modules/utils.py index bba2f62856..b3debfc43c 100644 --- a/frappe/modules/utils.py +++ b/frappe/modules/utils.py @@ -90,10 +90,11 @@ def sync_customizations(app=None): folder = frappe.get_app_path(app_name, module_name, 'custom') if os.path.exists(folder): for fname in os.listdir(folder): - with open(os.path.join(folder, fname), 'r') as f: - data = json.loads(f.read()) - if data.get('sync_on_migrate'): - sync_customizations_for_doctype(data, folder) + if fname.endswith('.json'): + with open(os.path.join(folder, fname), 'r') as f: + data = json.loads(f.read()) + if data.get('sync_on_migrate'): + sync_customizations_for_doctype(data, folder) def sync_customizations_for_doctype(data, folder): diff --git a/frappe/oauth.py b/frappe/oauth.py index bf225ac118..09af5ad809 100644 --- a/frappe/oauth.py +++ b/frappe/oauth.py @@ -148,7 +148,7 @@ class OAuthWebRequestValidator(RequestValidator): print("Failed body authentication: Application %s does not exist".format(cid=request.client_id)) cookie_dict = get_cookie_dict_from_headers(request) - user_id = unquote(cookie_dict['user_id']) if 'user_id' in cookie_dict else "Guest" + user_id = unquote(cookie_dict.get('user_id').value) if 'user_id' in cookie_dict else "Guest" return frappe.session.user == user_id def authenticate_client_id(self, client_id, request, *args, **kwargs): diff --git a/frappe/patches.txt b/frappe/patches.txt index acc322b164..d43690eac2 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -7,7 +7,7 @@ frappe.patches.v7_0.update_auth frappe.patches.v8_0.drop_in_dialog #2017-09-22 frappe.patches.v7_2.remove_in_filter execute:frappe.reload_doc('core', 'doctype', 'doctype_action', force=True) #2019-09-23 -execute:frappe.reload_doc('core', 'doctype', 'doctype_link', force=True) #2019-09-23 +execute:frappe.reload_doc('core', 'doctype', 'doctype_link', force=True) #2020-10-17 execute:frappe.reload_doc('core', 'doctype', 'doctype', force=True) #2017-09-22 execute:frappe.reload_doc('core', 'doctype', 'docfield', force=True) #2018-02-20 frappe.patches.v11_0.drop_column_apply_user_permissions @@ -15,12 +15,14 @@ execute:frappe.reload_doc('core', 'doctype', 'custom_docperm') execute:frappe.reload_doc('core', 'doctype', 'docperm') #2018-05-29 execute:frappe.reload_doc('core', 'doctype', 'comment') frappe.patches.v8_0.drop_is_custom_from_docperm +execute:frappe.reload_doc('core', 'doctype', 'document_naming_rule', force=True) execute:frappe.reload_doc('core', 'doctype', 'module_def') #2020-08-28 execute:frappe.reload_doc('core', 'doctype', 'version') #2017-04-01 execute:frappe.reload_doc('email', 'doctype', 'document_follow') execute:frappe.reload_doc('core', 'doctype', 'communication_link') #2019-10-02 execute:frappe.reload_doc('core', 'doctype', 'has_role') execute:frappe.reload_doc('core', 'doctype', 'communication') #2019-10-02 +execute:frappe.reload_doc('core', 'doctype', 'server_script') frappe.patches.v11_0.replicate_old_user_permissions frappe.patches.v11_0.reload_and_rename_view_log #2019-01-03 frappe.patches.v7_1.rename_scheduler_log_to_error_log @@ -33,6 +35,7 @@ frappe.patches.v11_0.change_email_signature_fieldtype execute:frappe.reload_doc('core', 'doctype', 'activity_log') execute:frappe.reload_doc('core', 'doctype', 'deleted_document') execute:frappe.reload_doc('core', 'doctype', 'domain_settings') +frappe.patches.v13_0.rename_custom_client_script frappe.patches.v8_0.rename_page_role_to_has_role #2017-03-16 frappe.patches.v7_2.setup_custom_perms #2017-01-19 frappe.patches.v8_0.set_user_permission_for_page_and_report #2017-03-20 @@ -207,7 +210,7 @@ frappe.patches.v9_1.resave_domain_settings frappe.patches.v9_1.revert_domain_settings frappe.patches.v9_1.move_feed_to_activity_log execute:frappe.delete_doc('Page', 'data-import-tool', ignore_missing=True) -frappe.patches.v10_0.reload_countries_and_currencies # 20-10-2020 +frappe.patches.v10_0.reload_countries_and_currencies # 2021-02-03 frappe.patches.v10_0.refactor_social_login_keys frappe.patches.v10_0.enable_chat_by_default_within_system_settings frappe.patches.v10_0.remove_custom_field_for_disabled_domain @@ -242,7 +245,6 @@ frappe.patches.v12_0.set_primary_key_in_series execute:frappe.delete_doc("Page", "modules", ignore_missing=True) frappe.patches.v11_0.set_default_letter_head_source frappe.patches.v12_0.setup_comments_from_communications -frappe.patches.v12_0.init_desk_settings #16-05-2019 frappe.patches.v12_0.replace_null_values_in_tables frappe.patches.v12_0.reset_home_settings frappe.patches.v12_0.update_print_format_type @@ -264,7 +266,6 @@ frappe.patches.v12_0.copy_to_parent_for_tags frappe.patches.v12_0.create_notification_settings_for_user frappe.patches.v11_0.make_all_prepared_report_attachments_private #2019-11-26 frappe.patches.v12_0.setup_email_linking -frappe.patches.v12_0.fix_home_settings_for_all_users frappe.patches.v12_0.change_existing_dashboard_chart_filters frappe.patches.v12_0.set_correct_assign_value_in_docs #2020-07-13 execute:frappe.delete_doc("Test Runner") @@ -298,13 +299,14 @@ frappe.patches.v13_0.update_duration_options frappe.patches.v13_0.replace_old_data_import # 2020-06-24 frappe.patches.v13_0.create_custom_dashboards_cards_and_charts frappe.patches.v13_0.rename_is_custom_field_in_dashboard_chart -frappe.patches.v13_0.add_standard_navbar_items +frappe.patches.v13_0.add_standard_navbar_items # 2020-12-15 frappe.patches.v13_0.generate_theme_files_in_public_folder frappe.patches.v13_0.increase_password_length frappe.patches.v12_0.fix_email_id_formatting frappe.patches.v13_0.add_toggle_width_in_navbar_settings frappe.patches.v13_0.rename_notification_fields frappe.patches.v13_0.remove_duplicate_navbar_items +frappe.patches.v13_0.set_social_icons frappe.patches.v12_0.set_default_password_reset_limit execute:frappe.reload_doc('core', 'doctype', 'doctype', force=True) frappe.patches.v13_0.set_route_for_blog_category @@ -312,4 +314,20 @@ frappe.patches.v13_0.enable_custom_script frappe.patches.v13_0.update_newsletter_content_type execute:frappe.db.set_value('Website Settings', 'Website Settings', {'navbar_template': 'Standard Navbar', 'footer_template': 'Standard Footer'}) frappe.patches.v13_0.delete_event_producer_and_consumer_keys -frappe.patches.v13_0.web_template_set_module +frappe.patches.v13_0.web_template_set_module #2020-10-05 +frappe.patches.v13_0.remove_custom_link +execute:frappe.delete_doc("DocType", "Footer Item") +execute:frappe.reload_doctype('user') +execute:frappe.reload_doctype('docperm') +frappe.patches.v13_0.replace_field_target_with_open_in_new_tab +frappe.core.doctype.role.patches.v13_set_default_desk_properties +frappe.patches.v13_0.add_switch_theme_to_navbar_settings +frappe.patches.v13_0.update_icons_in_customized_desk_pages +execute:frappe.db.set_default('desktop:home_page', 'space') +execute:frappe.delete_doc_if_exists('Page', 'workspace') +execute:frappe.delete_doc_if_exists('Page', 'dashboard', force=1) +frappe.core.doctype.page.patches.drop_unused_pages +execute:frappe.get_doc('Role', 'Guest').save() # remove desk access +frappe.patches.v13_0.rename_desk_page_to_workspace # 02.02.2021 +frappe.patches.v13_0.delete_package_publish_tool +frappe.patches.v13_0.rename_list_view_setting_to_list_view_settings diff --git a/frappe/patches/v12_0/fix_home_settings_for_all_users.py b/frappe/patches/v12_0/fix_home_settings_for_all_users.py deleted file mode 100644 index e26cbd9eef..0000000000 --- a/frappe/patches/v12_0/fix_home_settings_for_all_users.py +++ /dev/null @@ -1,41 +0,0 @@ -import frappe -from frappe.config import get_modules_from_all_apps_for_user -import json -def execute(): - users = frappe.get_all('User', fields=['name', 'home_settings']) - - for user in users: - - if not user.home_settings: - continue - - home_settings = json.loads(user.home_settings) - - modules_by_category = home_settings.get('modules_by_category') - if not modules_by_category: - continue - visible_modules = [] - category_to_check = [] - - for category, modules in modules_by_category.items(): - visible_modules += modules - category_to_check.append(category) - - all_modules = get_modules_from_all_apps_for_user(user.name) - all_modules = set([m.get('name') or m.get('module_name') or m.get('label') \ - for m in all_modules if m.get('category') in category_to_check]) - - hidden_modules = home_settings.get("hidden_modules", []) - - modules_in_home_settings = set(visible_modules + hidden_modules) - - all_modules = all_modules.union(modules_in_home_settings) - - missing_modules = all_modules - modules_in_home_settings - - if missing_modules: - home_settings['hidden_modules'] = hidden_modules + list(missing_modules) - home_settings = json.dumps(home_settings) - frappe.set_value('User', user.name, 'home_settings', home_settings) - - frappe.cache().delete_key('home_settings') diff --git a/frappe/patches/v12_0/reset_home_settings.py b/frappe/patches/v12_0/reset_home_settings.py index db16c31f15..e4b9de6cb2 100644 --- a/frappe/patches/v12_0/reset_home_settings.py +++ b/frappe/patches/v12_0/reset_home_settings.py @@ -1,6 +1,7 @@ import frappe def execute(): + frappe.reload_doc('core', 'doctype', 'user') frappe.db.sql(''' UPDATE `tabUser` SET `home_settings` = '' diff --git a/frappe/patches/v13_0/add_switch_theme_to_navbar_settings.py b/frappe/patches/v13_0/add_switch_theme_to_navbar_settings.py new file mode 100644 index 0000000000..29b99464b5 --- /dev/null +++ b/frappe/patches/v13_0/add_switch_theme_to_navbar_settings.py @@ -0,0 +1,21 @@ +from __future__ import unicode_literals +import frappe + +def execute(): + navbar_settings = frappe.get_single("Navbar Settings") + + if frappe.db.exists('Navbar Item', {'item_label': 'Toggle Theme'}): + return + + for navbar_item in navbar_settings.settings_dropdown[6:]: + navbar_item.idx = navbar_item.idx + 1 + + navbar_settings.append('settings_dropdown', { + 'item_label': 'Toggle Theme', + 'item_type': 'Action', + 'action': 'new frappe.ui.ThemeSwitcher().show()', + 'is_standard': 1, + 'idx': 7 + }) + + navbar_settings.save() \ No newline at end of file diff --git a/frappe/patches/v13_0/cleanup_desk_cards.py b/frappe/patches/v13_0/cleanup_desk_cards.py new file mode 100644 index 0000000000..6ac8604041 --- /dev/null +++ b/frappe/patches/v13_0/cleanup_desk_cards.py @@ -0,0 +1,68 @@ +import frappe +from six import string_types +from json import loads +from frappe.desk.doctype.workspace.workspace import get_link_type, get_report_type + +def execute(): + frappe.reload_doc('desk', 'doctype', 'workspace') + + pages = frappe.db.sql("Select `name` from `tabDesk Page`") + # pages = frappe.get_all("Workspace", filters={"is_standard": 0}, pluck="name") + + for page in pages: + rebuild_links(page[0]) + + frappe.delete_doc("DocType", "Desk Card") + +def rebuild_links(page): + # Empty links table + + try: + doc = frappe.get_doc("Workspace", page) + except frappe.DoesNotExistError: + db_doc = get_doc_from_db(page) + + doc = frappe.get_doc(db_doc) + doc.insert(ignore_permissions=True) + + doc.links = [] + + for card in get_all_cards(page): + if isinstance(card.links, string_types): + links = loads(card.links) + else: + links = card.links + + doc.append('links', { + "label": card.label, + "type": "Card Break", + "icon": card.icon, + "hidden": card.hidden or False + }) + + for link in links: + if not frappe.db.exists(get_link_type(link.get('type')), link.get('name')): + continue + + doc.append('links', { + "label": link.get('label') or link.get('name'), + "type": "Link", + "link_type": get_link_type(link.get('type')), + "link_to": link.get('name'), + "onboard": link.get('onboard'), + "dependencies": ', '.join(link.get('dependencies', [])), + "is_query_report": get_report_type(link.get('name')) if link.get('type').lower() == "report" else 0 + }) + + try: + doc.save(ignore_permissions=True) + except frappe.LinkValidationError: + print(doc.as_dict()) + +def get_doc_from_db(page): + result = frappe.db.sql("SELECT * FROM `tabDesk Page` WHERE name=%s", [page], as_dict=True) + if result: + return result[0].update({"doctype": "Workspace"}) + +def get_all_cards(page): + return frappe.db.get_all("Desk Card", filters={"parent": page}, fields=['*'], order_by="idx") \ No newline at end of file diff --git a/frappe/patches/v13_0/delete_package_publish_tool.py b/frappe/patches/v13_0/delete_package_publish_tool.py new file mode 100644 index 0000000000..25024f58dd --- /dev/null +++ b/frappe/patches/v13_0/delete_package_publish_tool.py @@ -0,0 +1,11 @@ +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +from __future__ import unicode_literals +import frappe + + +def execute(): + frappe.delete_doc("DocType", "Package Publish Tool", ignore_missing=True) + frappe.delete_doc("DocType", "Package Document Type", ignore_missing=True) + frappe.delete_doc("DocType", "Package Publish Target", ignore_missing=True) diff --git a/frappe/patches/v13_0/enable_custom_script.py b/frappe/patches/v13_0/enable_custom_script.py index 92284e6dcc..edc242e700 100644 --- a/frappe/patches/v13_0/enable_custom_script.py +++ b/frappe/patches/v13_0/enable_custom_script.py @@ -5,9 +5,8 @@ from __future__ import unicode_literals import frappe def execute(): - """Enable all the existing custom script""" - frappe.reload_doc("Custom", "doctype", "Custom Script") + """Enable all the existing Client script""" frappe.db.sql(""" - UPDATE `tabCustom Script` SET enabled=1 + UPDATE `tabClient Script` SET enabled=1 """) \ No newline at end of file diff --git a/frappe/patches/v13_0/generate_theme_files_in_public_folder.py b/frappe/patches/v13_0/generate_theme_files_in_public_folder.py index 4315f06ebe..bcb47bec24 100644 --- a/frappe/patches/v13_0/generate_theme_files_in_public_folder.py +++ b/frappe/patches/v13_0/generate_theme_files_in_public_folder.py @@ -6,6 +6,7 @@ import frappe def execute(): + frappe.reload_doc("website", "doctype", "website_theme_ignore_app") themes = frappe.db.get_all( "Website Theme", filters={"theme_url": ("not like", "/files/website_theme/%")} ) diff --git a/frappe/patches/v13_0/remove_custom_link.py b/frappe/patches/v13_0/remove_custom_link.py new file mode 100644 index 0000000000..f38bb642f0 --- /dev/null +++ b/frappe/patches/v13_0/remove_custom_link.py @@ -0,0 +1,15 @@ +import frappe + +def execute(): + ''' + Remove the doctype "Custom Link" that was used to add Custom Links to the + Dashboard since this is now managed by Customize Form. + Update `parent` property to the DocType and delte the doctype + ''' + frappe.reload_doctype('DocType Link') + if frappe.db.has_table('Custom Link'): + for custom_link in frappe.get_all('Custom Link', ['name', 'document_type']): + frappe.db.sql('update `tabDocType Link` set custom=1, parent=%s where parent=%s', + (custom_link.document_type, custom_link.name)) + + frappe.delete_doc('DocType', 'Custom Link') \ No newline at end of file diff --git a/frappe/patches/v13_0/rename_custom_client_script.py b/frappe/patches/v13_0/rename_custom_client_script.py new file mode 100644 index 0000000000..718f1f6a46 --- /dev/null +++ b/frappe/patches/v13_0/rename_custom_client_script.py @@ -0,0 +1,9 @@ +import frappe + + +def execute(): + if frappe.db.exists("DocType", "Client Script"): + return + + frappe.rename_doc("DocType", "Custom Script", "Client Script") + frappe.reload_doctype("Client Script", force=True) diff --git a/frappe/patches/v13_0/rename_desk_page_to_workspace.py b/frappe/patches/v13_0/rename_desk_page_to_workspace.py new file mode 100644 index 0000000000..6483fc380c --- /dev/null +++ b/frappe/patches/v13_0/rename_desk_page_to_workspace.py @@ -0,0 +1,21 @@ +import frappe +from frappe.model.rename_doc import rename_doc + +def execute(): + if frappe.db.exists("DocType", "Desk Page"): + if frappe.db.exists('DocType', 'Workspace'): + # this patch was not added initially, so this page might still exist + frappe.delete_doc('DocType', 'Desk Page') + else: + frappe.flags.ignore_route_conflict_validation = True + rename_doc('DocType', 'Desk Page', 'Workspace') + frappe.flags.ignore_route_conflict_validation = False + + rename_doc('DocType', 'Desk Chart', 'Workspace Chart', ignore_if_exists=True) + rename_doc('DocType', 'Desk Shortcut', 'Workspace Shortcut', ignore_if_exists=True) + rename_doc('DocType', 'Desk Link', 'Workspace Link', ignore_if_exists=True) + + frappe.reload_doc('desk', 'doctype', 'workspace', force=True) + frappe.reload_doc('desk', 'doctype', 'workspace_link', force=True) + frappe.reload_doc('desk', 'doctype', 'workspace_chart', force=True) + frappe.reload_doc('desk', 'doctype', 'workspace_shortcut', force=True) diff --git a/frappe/patches/v13_0/rename_list_view_setting_to_list_view_settings.py b/frappe/patches/v13_0/rename_list_view_setting_to_list_view_settings.py new file mode 100644 index 0000000000..fcf8afc826 --- /dev/null +++ b/frappe/patches/v13_0/rename_list_view_setting_to_list_view_settings.py @@ -0,0 +1,20 @@ +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +from __future__ import unicode_literals +import frappe + + +def execute(): + if frappe.db.table_exists('List View Setting'): + existing_list_view_settings = frappe.get_all('List View Settings', as_list=True) + for list_view_setting in frappe.get_all('List View Setting', fields = ['disable_count', 'disable_sidebar_stats', 'disable_auto_refresh', 'name']): + name = list_view_setting.pop('name') + if name not in [x[0] for x in existing_list_view_settings]: + list_view_setting['doctype'] = 'List View Settings' + list_view_settings = frappe.get_doc(list_view_setting) + # setting name here is necessary because autoname is set as prompt + list_view_settings.name = name + list_view_settings.insert() + frappe.delete_doc("DocType", "List View Setting", force=True) + frappe.db.commit() diff --git a/frappe/patches/v13_0/replace_field_target_with_open_in_new_tab.py b/frappe/patches/v13_0/replace_field_target_with_open_in_new_tab.py new file mode 100644 index 0000000000..21b2d8ef03 --- /dev/null +++ b/frappe/patches/v13_0/replace_field_target_with_open_in_new_tab.py @@ -0,0 +1,11 @@ +import frappe + + +def execute(): + doctype = "Top Bar Item" + if not frappe.db.table_exists(doctype) \ + or not frappe.db.has_column(doctype, "target"): + return + + frappe.reload_doc("website", "doctype", "top_bar_item") + frappe.db.set_value(doctype, {"target": 'target = "_blank"'}, 'open_in_new_tab', 1) diff --git a/frappe/patches/v13_0/set_social_icons.py b/frappe/patches/v13_0/set_social_icons.py new file mode 100644 index 0000000000..b1c63b48e0 --- /dev/null +++ b/frappe/patches/v13_0/set_social_icons.py @@ -0,0 +1,9 @@ +import frappe + +def execute(): + providers = frappe.get_all("Social Login Key") + + for provider in providers: + doc = frappe.get_doc("Social Login Key", provider) + doc.set_icon() + doc.save() \ No newline at end of file diff --git a/frappe/patches/v13_0/update_icons_in_customized_desk_pages.py b/frappe/patches/v13_0/update_icons_in_customized_desk_pages.py new file mode 100644 index 0000000000..93bf5c766e --- /dev/null +++ b/frappe/patches/v13_0/update_icons_in_customized_desk_pages.py @@ -0,0 +1,15 @@ +from __future__ import unicode_literals +import frappe + +def execute(): + if not frappe.db.exists('Desk Page'): return + + pages = frappe.get_all("Desk Page", filters={ "is_standard": False }, fields=["name", "extends", "for_user"]) + default_icon = {} + for page in pages: + if page.extends and page.for_user: + if not default_icon.get(page.extends): + default_icon[page.extends] = frappe.db.get_value("Desk Page", page.extends, "icon") + + icon = default_icon.get(page.extends) + frappe.db.set_value("Desk Page", page.name, "icon", icon) \ No newline at end of file diff --git a/frappe/patches/v13_0/web_template_set_module.py b/frappe/patches/v13_0/web_template_set_module.py index b4ccb21ef2..df008557d8 100644 --- a/frappe/patches/v13_0/web_template_set_module.py +++ b/frappe/patches/v13_0/web_template_set_module.py @@ -6,7 +6,9 @@ import frappe def execute(): """Set default module for standard Web Template, if none.""" - frappe.reload_doc('website', 'doctype', 'Web Template') + frappe.reload_doc('website', 'doctype', 'Web Template Field') + frappe.reload_doc('website', 'doctype', 'web_template') + standard_templates = frappe.get_list('Web Template', {'standard': 1}) for template in standard_templates: doc = frappe.get_doc('Web Template', template.name) diff --git a/frappe/patches/v13_0/website_theme_custom_scss.py b/frappe/patches/v13_0/website_theme_custom_scss.py index 0035283428..a5f08324e8 100644 --- a/frappe/patches/v13_0/website_theme_custom_scss.py +++ b/frappe/patches/v13_0/website_theme_custom_scss.py @@ -2,9 +2,23 @@ import frappe def execute(): frappe.reload_doctype('Website Theme') + frappe.reload_doc('website', 'doctype', 'website_theme_ignore_app') + frappe.reload_doc('website', 'doctype', 'color') + for theme in frappe.get_all('Website Theme'): doc = frappe.get_doc('Website Theme', theme.name) if not doc.get('custom_scss') and doc.theme_scss: # move old theme to new theme doc.custom_scss = doc.theme_scss + + if doc.background_color: + setup_color_record(doc.background_color) + doc.save() + +def setup_color_record(color): + frappe.get_doc({ + "doctype": "Color", + "__newname": color, + "color": color, + }).save() diff --git a/frappe/permissions.py b/frappe/permissions.py index 0d766aec8d..abb1f6653a 100644 --- a/frappe/permissions.py +++ b/frappe/permissions.py @@ -7,7 +7,7 @@ import frappe, copy, json from frappe import _, msgprint from frappe.utils import cint import frappe.share -rights = ("read", "write", "create", "delete", "submit", "cancel", "amend", +rights = ("select", "read", "write", "create", "delete", "submit", "cancel", "amend", "print", "email", "report", "import", "export", "set_user_permissions", "share") # TODO: @@ -73,6 +73,7 @@ def has_permission(doctype, ptype="read", doc=None, verbose=False, user=None, ra role_permissions = get_role_permissions(meta, user=user) perm = role_permissions.get(ptype) + if not perm: push_perm_check_log(_('User {0} does not have doctype access via role permission for document {1}').format(frappe.bold(user), frappe.bold(doctype))) @@ -192,9 +193,9 @@ def get_role_permissions(doctype_meta, user=None): and ptype != 'create'): perms['if_owner'][ptype] = 1 # has no access if not owner - # only provide read access so that user is able to at-least access list + # only provide select or read access so that user is able to at-least access list # (and the documents will be filtered based on owner sin further checks) - perms[ptype] = 1 if ptype == 'read' else 0 + perms[ptype] = 1 if ptype in ['select', 'read'] else 0 frappe.local.role_permissions[cache_key] = perms @@ -397,7 +398,8 @@ def set_user_permission_if_allowed(doctype, name, user, with_message=False): if get_role_permissions(frappe.get_meta(doctype), user).set_user_permissions!=1: add_user_permission(doctype, name, user) -def add_user_permission(doctype, name, user, ignore_permissions=False, applicable_for=None, is_default=0): +def add_user_permission(doctype, name, user, ignore_permissions=False, applicable_for=None, + is_default=0, hide_descendants=0): '''Add user permission''' from frappe.core.doctype.user_permission.user_permission import user_permission_exists @@ -412,6 +414,7 @@ def add_user_permission(doctype, name, user, ignore_permissions=False, applicabl for_value=name, is_default=is_default, applicable_for=applicable_for, + hide_descendants=hide_descendants, )).insert(ignore_permissions=ignore_permissions) def remove_user_permission(doctype, name, user): diff --git a/frappe/printing/doctype/letter_head/letter_head.py b/frappe/printing/doctype/letter_head/letter_head.py index 491d959755..3a3b14faad 100644 --- a/frappe/printing/doctype/letter_head/letter_head.py +++ b/frappe/printing/doctype/letter_head/letter_head.py @@ -5,6 +5,7 @@ from __future__ import unicode_literals import frappe from frappe.utils import is_image from frappe.model.document import Document +from frappe import _ class LetterHead(Document): def before_insert(self): @@ -13,14 +14,20 @@ class LetterHead(Document): def validate(self): self.set_image() - if not self.is_default: - if not frappe.db.sql("""select count(*) from `tabLetter Head` where ifnull(is_default,0)=1"""): + self.validate_disabled_and_default() + + def validate_disabled_and_default(self): + if self.disabled and self.is_default: + frappe.throw(_("Letter Head cannot be both disabled and default")) + + if not self.is_default and not self.disabled: + if not frappe.db.exists('Letter Head', dict(is_default=1)): self.is_default = 1 def set_image(self): if self.source=='Image': if self.image and is_image(self.image): - self.content = ''.format(self.image) + self.content = ''.format(self.image) 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 e6599b2496..786f8f97ab 100644 --- a/frappe/printing/doctype/print_format/print_format.js +++ b/frappe/printing/doctype/print_format/print_format.js @@ -19,6 +19,7 @@ frappe.ui.form.on("Print Format", { } frm.trigger('render_buttons'); frm.toggle_display('standard', frappe.boot.developer_mode); + frm.trigger('hide_absolute_value_field'); }, render_buttons: function (frm) { frm.page.clear_inner_toolbar(); @@ -58,5 +59,20 @@ frappe.ui.form.on("Print Format", { frm.set_value('show_section_headings', value); frm.set_value('line_breaks', value); frm.trigger('render_buttons'); + }, + doc_type: function (frm) { + frm.trigger('hide_absolute_value_field'); + }, + hide_absolute_value_field: function (frm) { + // TODO: make it work with frm.doc.doc_type + // Problem: frm isn't updated in some random cases + const doctype = locals[frm.doc.doctype][frm.doc.name].doc_type; + if (doctype) { + frappe.model.with_doctype(doctype, () => { + const meta = frappe.get_meta(doctype); + const has_int_float_currency_field = meta.fields.filter(df => in_list(['Int', 'Float', 'Currency'], df.fieldtype)); + frm.toggle_display('absolute_value', has_int_float_currency_field.length); + }); + } } -}) +}); diff --git a/frappe/printing/doctype/print_format/print_format.json b/frappe/printing/doctype/print_format/print_format.json index 74ee56cb54..4032cef209 100644 --- a/frappe/printing/doctype/print_format/print_format.json +++ b/frappe/printing/doctype/print_format/print_format.json @@ -8,10 +8,11 @@ "field_order": [ "doc_type", "module", - "disabled", + "default_print_language", "column_break_3", "standard", "custom_format", + "disabled", "section_break_6", "print_format_type", "raw_printing", @@ -21,8 +22,8 @@ "align_labels_right", "show_section_headings", "line_breaks", + "absolute_value", "column_break_11", - "default_print_language", "font", "css_section", "css", @@ -153,7 +154,7 @@ "fieldname": "font", "fieldtype": "Select", "label": "Font", - "options": "Default\nArial\nHelvetica\nVerdana\nMonospace" + "options": "Default\nHelvetica Neue\nArial\nHelvetica\nVerdana\nMonospace" }, { "depends_on": "eval:!doc.raw_printing", @@ -170,7 +171,7 @@ "fieldname": "custom_html_help", "fieldtype": "HTML", "label": "Custom HTML Help", - "options": "

        Custom CSS Help

        \n\n

        Notes:

        \n\n
          \n
        1. All field groups (label + value) are set attributes data-fieldtype and data-fieldname
        2. \n
        3. All values are given class value
        4. \n
        5. All Section Breaks are given class section-break
        6. \n
        7. All Column Breaks are given class column-break
        8. \n
        \n\n

        Examples

        \n\n

        1. Left align integers

        \n\n
        [data-fieldtype=\"Int\"] .value { text-left: left; }
        \n\n

        1. Add border to sections except the last section

        \n\n
        .section-break { padding: 30px 0px; border-bottom: 1px solid #eee; }\n.section-break:last-child { padding-bottom: 0px; border-bottom: 0px;  }
        \n" + "options": "

        Custom CSS Help

        \n\n

        Notes:

        \n\n
          \n
        1. All field groups (label + value) are set attributes data-fieldtype and data-fieldname
        2. \n
        3. All values are given class value
        4. \n
        5. All Section Breaks are given class section-break
        6. \n
        7. All Column Breaks are given class column-break
        8. \n
        \n\n

        Examples

        \n\n

        1. Left align integers

        \n\n
        [data-fieldtype=\"Int\"] .value { text-align: left; }
        \n\n

        1. Add border to sections except the last section

        \n\n
        .section-break { padding: 30px 0px; border-bottom: 1px solid #eee; }\n.section-break:last-child { padding-bottom: 0px; border-bottom: 0px;  }
        \n" }, { "depends_on": "custom_format", @@ -196,13 +197,21 @@ "fieldtype": "Check", "hidden": 1, "label": "Print Format Builder" + }, + { + "default": "0", + "depends_on": "doc_type", + "description": "If checked, negative numeric values of Currency, Quantity or Count would be shown as positive", + "fieldname": "absolute_value", + "fieldtype": "Check", + "label": "Show Absolute Values" } ], "icon": "fa fa-print", "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2020-08-29 11:44:59.082797", + "modified": "2021-03-01 15:25:46.578863", "modified_by": "Administrator", "module": "Printing", "name": "Print Format", diff --git a/frappe/printing/doctype/print_format/test_print_format.py b/frappe/printing/doctype/print_format/test_print_format.py index e8375ae5e7..7e30bda23e 100644 --- a/frappe/printing/doctype/print_format/test_print_format.py +++ b/frappe/printing/doctype/print_format/test_print_format.py @@ -11,8 +11,8 @@ test_records = frappe.get_test_records('Print Format') class TestPrintFormat(unittest.TestCase): def test_print_user(self, style=None): print_html = frappe.get_print("User", "Administrator", style=style) - self.assertTrue("" in print_html) - self.assertTrue(re.findall('
        [\s]*administrator[\s]*
        ', print_html)) + self.assertTrue("" in print_html) + self.assertTrue(re.findall('
        [\s]*administrator[\s]*
        ', print_html)) return print_html def test_print_user_standard(self): diff --git a/frappe/printing/doctype/print_settings/print_settings.json b/frappe/printing/doctype/print_settings/print_settings.json index f93ad0ee5a..d64cb4c6d3 100644 --- a/frappe/printing/doctype/print_settings/print_settings.json +++ b/frappe/printing/doctype/print_settings/print_settings.json @@ -170,7 +170,7 @@ "fieldname": "font", "fieldtype": "Select", "label": "Font", - "options": "Default\nArial\nHelvetica\nVerdana\nMonospace" + "options": "Default\nHelvetica Neue\nArial\nHelvetica\nInter\nVerdana\nMonospace" }, { "description": "In points. Default is 9.", @@ -180,9 +180,10 @@ } ], "icon": "fa fa-cog", + "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2020-07-02 16:14:47.470668", + "modified": "2020-10-22 23:42:09.471022", "modified_by": "Administrator", "module": "Printing", "name": "Print Settings", diff --git a/frappe/printing/page/print/__init__.py b/frappe/printing/page/print/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/printing/page/print/print.js b/frappe/printing/page/print/print.js new file mode 100644 index 0000000000..dfd93c4efa --- /dev/null +++ b/frappe/printing/page/print/print.js @@ -0,0 +1,760 @@ +frappe.pages['print'].on_page_load = function(wrapper) { + frappe.ui.make_app_page({ + parent: wrapper, + }); + + let print_view = new frappe.ui.form.PrintView(wrapper); + + $(wrapper).bind('show', () => { + const route = frappe.get_route(); + const doctype = route[1]; + const docname = route[2]; + if (!frappe.route_options || !frappe.route_options.frm) { + frappe.model.with_doc(doctype, docname, () => { + let frm = { doctype: doctype, docname: docname }; + frm.doc = frappe.get_doc(doctype, docname); + frappe.model.with_doctype(doctype, () => { + frm.meta = frappe.get_meta(route[1]); + print_view.show(frm); + }); + }); + } else { + print_view.frm = frappe.route_options.frm; + frappe.route_options.frm = null; + print_view.show(print_view.frm); + } + }); +}; + +frappe.ui.form.PrintView = class { + constructor(wrapper) { + this.wrapper = $(wrapper); + this.page = wrapper.page; + this.make(); + } + + make() { + this.print_wrapper = this.page.main.empty().html( + `` + ); + + this.print_settings = frappe.model.get_doc( + ':Print Settings', + 'Print Settings' + ); + this.setup_toolbar(); + this.setup_menu(); + this.setup_sidebar(); + this.setup_keyboard_shortcuts(); + } + + set_title() { + this.page.set_title(this.frm.docname); + } + + setup_toolbar() { + this.page.set_primary_action( + __('Print'), + () => this.printit(), 'printer' + ); + + this.page.add_button( + __('Full Page'), + () => this.render_page('/printview?'), + { icon: 'full-page' } + ); + + this.page.add_button( + __('PDF'), + () => this.render_page('/api/method/frappe.utils.print_format.download_pdf?'), + { icon: 'small-file' } + ); + + this.page.add_button( + frappe.utils.icon('refresh'), + () => this.refresh_print_format() + ); + } + + setup_sidebar() { + this.sidebar = this.page.sidebar.addClass('print-preview-sidebar'); + + this.print_sel = this.add_sidebar_item( + { + fieldtype: 'Select', + fieldname: 'print_format', + label: 'Print Format', + options: [this.get_default_option_for_select(__('Select Print Format'))], + change: () => this.refresh_print_format(), + default: __('Select Print Format') + }, + ).$input; + + this.language_sel = this.add_sidebar_item( + { + fieldtype: 'Select', + fieldname: 'language', + placeholder: 'Language', + options: [ + this.get_default_option_for_select(__('Select Language')), + ...this.get_language_options() + ], + default: __('Select Language'), + change: () => { + this.set_user_lang(); + this.preview(); + }, + }, + ).$input; + + this.letterhead_selector = this.add_sidebar_item( + { + fieldtype: 'Select', + fieldname: 'letterhead', + label: __('Select Letterhead'), + options: [ + this.get_default_option_for_select(__('Select Letterhead')), + __('No Letterhead') + ], + change: () => this.preview(), + default: this.print_settings.with_letterhead + ? __('No Letterhead') + : __('Select Letterhead') + }, + ).$input; + + this.sidebar_dynamic_section = $( + `
        ` + ).appendTo(this.sidebar); + } + + add_sidebar_item(df, is_dynamic) { + if (df.fieldtype == 'Select') { + df.input_class = 'btn btn-default btn-sm'; + } + + let field = frappe.ui.form.make_control({ + df: df, + parent: is_dynamic ? this.sidebar_dynamic_section : this.sidebar, + render_input: 1, + }); + + if (df.default != null) { + field.set_input(df.default); + } + + return field; + } + + get_default_option_for_select(value) { + return { + label: value, + value: value, + disabled: true + }; + } + + setup_menu() { + this.page.clear_menu(); + + this.page.add_menu_item(__('Print Settings'), () => { + frappe.set_route('Form', 'Print Settings'); + }); + + if ( + frappe.model.get_doc(':Print Settings', 'Print Settings') + .enable_raw_printing == '1' + ) { + this.page.add_menu_item(__('Raw Printing Setting'), () => { + this.printer_setting_dialog(); + }); + } + + if (frappe.user.has_role('System Manager')) { + this.page.add_menu_item(__('Customize'), () => + this.edit_print_format() + ); + } + } + + show(frm) { + this.frm = frm; + this.set_title(); + this.set_breadcrumbs(); + this.setup_customize_dialog(); + + let tasks = [ + this.refresh_print_options, + this.set_default_print_language, + this.set_letterhead_options, + this.preview, + ].map((fn) => fn.bind(this)); + + this.setup_additional_settings(); + return frappe.run_serially(tasks); + } + + set_breadcrumbs() { + frappe.breadcrumbs.add(this.frm.meta.module, this.frm.doctype); + } + + setup_additional_settings() { + this.additional_settings = {}; + this.sidebar_dynamic_section.empty(); + frappe + .xcall('frappe.printing.page.print.print.get_print_settings_to_show', { + doctype: this.frm.doc.doctype, + docname: this.frm.doc.name + }) + .then((settings) => this.add_settings_to_sidebar(settings)); + } + + add_settings_to_sidebar(settings) { + for (let df of settings) { + let field = this.add_sidebar_item({ + ...df, + change: () => { + const val = field.get_value(); + this.additional_settings[field.df.fieldname] = val; + this.preview(); + }, + }, true); + } + } + + edit_print_format() { + let print_format = this.get_print_format(); + let is_custom_format = + print_format.name && + print_format.print_format_builder && + print_format.standard === 'No'; + let is_standard_but_editable = + print_format.name && print_format.custom_format; + + if (is_standard_but_editable) { + frappe.set_route('Form', 'Print Format', print_format.name); + return; + } + if (is_custom_format) { + frappe.set_route('print-format-builder', print_format.name); + return; + } + // start a new print format + frappe.prompt( + [ + { + label: __('New Print Format Name'), + fieldname: 'print_format_name', + fieldtype: 'Data', + reqd: 1, + }, + { + label: __('Based On'), + fieldname: 'based_on', + fieldtype: 'Read Only', + default: print_format.name || 'Standard', + }, + ], + (data) => { + frappe.route_options = { + make_new: true, + doctype: this.frm.doctype, + name: data.print_format_name, + based_on: data.based_on, + }; + frappe.set_route('print-format-builder'); + this.print_sel.val(data.print_format_name); + }, + __('New Custom Print Format'), + __('Start') + ); + } + + refresh_print_format() { + this.set_default_print_language(); + this.toggle_raw_printing(); + this.preview(); + } + + // bind_events () { + // // // hide print view on pressing escape, only if there is no focus on any input + // // $(document).on("keydown", function (e) { + // // if (e.which === 27 && me.frm && e.target === document.body) { + // // me.hide(); + // // } + // // }); + // } + + setup_customize_dialog() { + let print_format = this.get_print_format(); + $(document).on('new-print-format', (e) => { + this.refresh_print_options(); + if (e.print_format) { + this.print_sel.val(e.print_format); + } + // start a new print format + frappe.prompt( + [ + { + label: __('New Print Format Name'), + fieldname: 'print_format_name', + fieldtype: 'Data', + reqd: 1, + }, + { + label: __('Based On'), + fieldname: 'based_on', + fieldtype: 'Read Only', + default: print_format.name || 'Standard', + }, + ], + (data) => { + frappe.route_options = { + make_new: true, + doctype: this.frm.doctype, + name: data.print_format_name, + based_on: data.based_on, + }; + frappe.set_route('print-format-builder'); + }, + __('New Custom Print Format'), + __('Start') + ); + }); + } + + setup_keyboard_shortcuts() { + this.wrapper.find('.print-toolbar a.btn-default').each((i, el) => { + frappe.ui.keys.get_shortcut_group(this.frm.page).add($(el)); + }); + } + + set_letterhead_options() { + let letterhead_options = [ + this.get_default_option_for_select(__('Select Letterhead')), + __('No Letterhead') + ]; + let default_letterhead; + let doc_letterhead = this.frm.doc.letter_head; + + return frappe.db + .get_list('Letter Head', { fields: ['name', 'is_default'] }) + .then((letterheads) => { + this.letterhead_selector.empty(); + letterheads.map((letterhead) => { + if (letterhead.is_default) default_letterhead = letterhead.name; + return letterhead_options.push(letterhead.name); + }); + + this.letterhead_selector.add_options(letterhead_options); + let selected_letterhead = doc_letterhead || default_letterhead; + if (selected_letterhead) + this.letterhead_selector.val(selected_letterhead); + }); + } + + set_user_lang() { + this.lang_code = this.language_sel.val(); + } + + get_language_options() { + return frappe.get_languages(); + } + + set_default_print_language() { + let print_format = this.get_print_format(); + this.lang_code = + print_format.default_print_language || + this.frm.doc.language || + frappe.boot.lang; + this.language_sel.val(this.lang_code); + } + + toggle_raw_printing() { + const is_raw_printing = this.is_raw_printing(); + this.wrapper.find('.btn-print-preview').toggle(!is_raw_printing); + this.wrapper.find('.btn-download-pdf').toggle(!is_raw_printing); + } + + preview() { + const $print_format = this.print_wrapper.find('iframe'); + this.$print_format_body = $print_format.contents(); + this.get_print_html((out) => { + if (!out.html) { + out.html = this.get_no_preview_html(); + } + + this.setup_print_format_dom(out, $print_format); + + const print_height = $print_format.get(0).offsetHeight; + const $message = this.wrapper.find('.page-break-message'); + + const print_height_inches = frappe.dom.pixel_to_inches(print_height); + // if contents are large enough, indicate that it will get printed on multiple pages + // Maximum height for an A4 document is 11.69 inches + if (print_height_inches > 11.69) { + $message.text(__('This may get printed on multiple pages')); + } else { + $message.text(''); + } + }); + } + + setup_print_format_dom(out, $print_format) { + this.print_wrapper.find('.print-format-skeleton').remove(); + this.$print_format_body.find('head').html( + ` + ` + ); + + if (frappe.utils.is_rtl(this.lang_code)) { + this.$print_format_body.find('head').append( + `` + ); + } + + this.$print_format_body.find('body').html( + `` + ); + + this.show_footer(); + + this.$print_format_body.find('.print-format').css({ + display: 'flex', + flexDirection: 'column', + }); + + this.$print_format_body.find('.page-break').css({ + display: 'flex', + 'flex-direction': 'column', + flex: '1', + }); + + setTimeout(() => { + $print_format.height(this.$print_format_body.find('.print-format').outerHeight()); + }, 500); + } + + hide() { + if (this.frm.setup_done && this.frm.page.current_view_name === 'print') { + this.frm.page.set_view( + this.frm.page.previous_view_name === 'print' + ? 'main' + : this.frm.page.previous_view_name || 'main' + ); + } + } + + show_footer() { + // footer is hidden by default as reqd by pdf generation + // simple hack to show it in print preview + + this.$print_format_body.find('#footer-html').attr( + 'style', + ` + display: block !important; + order: 1; + margin-top: auto; + padding-top: var(--padding-xl) + ` + ); + } + + printit() { + let me = this; + frappe.call({ + method: + 'frappe.printing.doctype.print_settings.print_settings.is_print_server_enabled', + callback: function(data) { + if (data.message) { + frappe.call({ + method: 'frappe.utils.print_format.print_by_server', + args: { + doctype: me.frm.doc.doctype, + name: me.frm.doc.name, + print_format: me.selected_format(), + no_letterhead: me.with_letterhead(), + letterhead: this.get_letterhead(), + }, + callback: function() {}, + }); + } else if (me.get_mapped_printer().length === 1) { + // printer is already mapped in localstorage (applies for both raw and pdf ) + if (me.is_raw_printing()) { + me.get_raw_commands(function(out) { + frappe.ui.form + .qz_connect() + .then(function() { + let printer_map = me.get_mapped_printer()[0]; + let data = [out.raw_commands]; + let config = qz.configs.create(printer_map.printer); + return qz.print(config, data); + }) + .then(frappe.ui.form.qz_success) + .catch((err) => { + frappe.ui.form.qz_fail(err); + }); + }); + } else { + frappe.show_alert( + { + message: __('PDF printing via "Raw Print" is not supported.'), + subtitle: __( + 'Please remove the printer mapping in Printer Settings and try again.' + ), + indicator: 'info', + }, + 14 + ); + //Note: need to solve "Error: Cannot parse (FILE) as a PDF file" to enable qz pdf printing. + } + } else if (me.is_raw_printing()) { + // printer not mapped in localstorage and the current print format is raw printing + frappe.show_alert( + { + message: __('Printer mapping not set.'), + subtitle: __( + 'Please set a printer mapping for this print format in the Printer Settings' + ), + indicator: 'warning', + }, + 14 + ); + me.printer_setting_dialog(); + } else { + me.render_page('/printview?', true); + } + }, + }); + } + + render_page(method, printit = false) { + let w = window.open( + frappe.urllib.get_full_url( + method + + 'doctype=' + + encodeURIComponent(this.frm.doc.doctype) + + '&name=' + + encodeURIComponent(this.frm.doc.name) + + (printit ? '&trigger_print=1' : '') + + '&format=' + + encodeURIComponent(this.selected_format()) + + '&no_letterhead=' + + (this.with_letterhead() ? '0' : '1') + + '&letterhead=' + + encodeURIComponent(this.get_letterhead()) + + '&settings=' + + encodeURIComponent(JSON.stringify(this.additional_settings)) + + (this.lang_code ? '&_lang=' + this.lang_code : '') + ) + ); + if (!w) { + frappe.msgprint(__('Please enable pop-ups')); + return; + } + } + + get_print_html(callback) { + let print_format = this.get_print_format(); + if (print_format.raw_printing) { + callback({ + html: this.get_no_preview_html(), + }); + return; + } + if (this._req) { + this._req.abort(); + } + this._req = frappe.call({ + method: 'frappe.www.printview.get_html_and_style', + args: { + doc: this.frm.doc, + print_format: this.selected_format(), + no_letterhead: !this.with_letterhead() ? 1 : 0, + letterhead: this.get_letterhead(), + settings: this.additional_settings, + _lang: this.lang_code, + }, + callback: function(r) { + if (!r.exc) { + callback(r.message); + } + }, + }); + } + + get_letterhead() { + return this.letterhead_selector.val(); + } + + get_no_preview_html() { + return `
        + ${__('No Preview Available')} +
        `; + } + + get_raw_commands(callback) { + // fetches rendered raw commands from the server for the current print format. + frappe.call({ + method: 'frappe.www.printview.get_rendered_raw_commands', + args: { + doc: this.frm.doc, + print_format: this.selected_format(), + _lang: this.lang_code, + }, + callback: function(r) { + if (!r.exc) { + callback(r.message); + } + }, + }); + } + + get_mapped_printer() { + // returns a list of "print format: printer" mapping filtered by the current print format + let print_format_printer_map = this.get_print_format_printer_map(); + if (print_format_printer_map[this.frm.doctype]) { + return print_format_printer_map[this.frm.doctype].filter( + (printer_map) => printer_map.print_format == this.selected_format() + ); + } else { + return []; + } + } + + get_print_format_printer_map() { + // returns the whole object "print_format_printer_map" stored in the localStorage. + try { + let print_format_printer_map = JSON.parse( + localStorage.print_format_printer_map + ); + return print_format_printer_map; + } catch (e) { + return {}; + } + } + + refresh_print_options() { + this.print_formats = frappe.meta.get_print_formats(this.frm.doctype); + const print_format_select_val = this.print_sel.val(); + this.print_sel.empty().add_options([ + this.get_default_option_for_select(__('Select Print Format')), + ...this.print_formats + ]); + return this.print_formats.includes(print_format_select_val) + && this.print_sel.val(print_format_select_val); + } + + selected_format() { + return ( + this.print_sel.val() || this.frm.meta.default_print_format || 'Standard' + ); + } + + is_raw_printing(format) { + return this.get_print_format(format).raw_printing === 1; + } + + get_print_format(format) { + let print_format = {}; + if (!format) { + format = this.selected_format(); + } + + if (locals['Print Format'] && locals['Print Format'][format]) { + print_format = locals['Print Format'][format]; + } + + return print_format; + } + + with_letterhead() { + return cint(this.get_letterhead() !== __('No Letterhead')); + } + + set_style(style) { + frappe.dom.set_style(style || frappe.boot.print_css, 'print-style'); + } + + printer_setting_dialog() { + // dialog for the Printer Settings + this.print_format_printer_map = this.get_print_format_printer_map(); + this.data = this.print_format_printer_map[this.frm.doctype] || []; + this.printer_list = []; + frappe.ui.form.qz_get_printer_list().then((data) => { + this.printer_list = data; + const dialog = new frappe.ui.Dialog({ + title: __('Printer Settings'), + fields: [ + { + fieldtype: 'Section Break', + }, + { + fieldname: 'printer_mapping', + fieldtype: 'Table', + label: __('Printer Mapping'), + in_place_edit: true, + data: this.data, + get_data: () => { + return this.data; + }, + fields: [ + { + fieldtype: 'Select', + fieldname: 'print_format', + default: 0, + options: this.print_formats, + read_only: 0, + in_list_view: 1, + label: __('Print Format'), + }, + { + fieldtype: 'Select', + fieldname: 'printer', + default: 0, + options: this.printer_list, + read_only: 0, + in_list_view: 1, + label: __('Printer'), + }, + ], + }, + ], + primary_action: () => { + let printer_mapping = dialog.get_values()['printer_mapping']; + if (printer_mapping && printer_mapping.length) { + let print_format_list = printer_mapping.map((a) => a.print_format); + let has_duplicate = print_format_list.some( + (item, idx) => print_format_list.indexOf(item) != idx + ); + if (has_duplicate) + frappe.throw( + __( + 'Cannot have multiple printers mapped to a single print format.' + ) + ); + } else { + printer_mapping = []; + } + dialog.print_format_printer_map = this.get_print_format_printer_map(); + dialog.print_format_printer_map[this.frm.doctype] = printer_mapping; + localStorage.print_format_printer_map = JSON.stringify( + dialog.print_format_printer_map + ); + dialog.hide(); + }, + primary_action_label: __('Save'), + }); + dialog.show(); + if (!(this.printer_list && this.printer_list.length)) { + frappe.throw(__('No Printer is Available.')); + } + }); + } +}; diff --git a/frappe/printing/page/print/print.json b/frappe/printing/page/print/print.json new file mode 100644 index 0000000000..bea659c264 --- /dev/null +++ b/frappe/printing/page/print/print.json @@ -0,0 +1,18 @@ +{ + "content": null, + "creation": "2020-10-09 17:23:15.163030", + "docstatus": 0, + "doctype": "Page", + "idx": 0, + "modified": "2020-10-09 17:23:15.163030", + "modified_by": "Administrator", + "module": "Printing", + "name": "print", + "owner": "Administrator", + "page_name": "Print", + "roles": [], + "script": null, + "standard": "Yes", + "style": null, + "system_page": 0 +} \ No newline at end of file diff --git a/frappe/printing/page/print/print.py b/frappe/printing/page/print/print.py new file mode 100644 index 0000000000..7ddf68c30d --- /dev/null +++ b/frappe/printing/page/print/print.py @@ -0,0 +1,19 @@ +import frappe + +@frappe.whitelist() +def get_print_settings_to_show(doctype, docname): + doc = frappe.get_doc(doctype, docname) + print_settings = frappe.get_single('Print Settings') + + if hasattr(doc, 'get_print_settings'): + fields = doc.get_print_settings() or [] + else: + return [] + + print_settings_fields = [] + for fieldname in fields: + df = print_settings.meta.get_field(fieldname) + df.default = print_settings.get(fieldname) + print_settings_fields.append(df) + + return print_settings_fields diff --git a/frappe/printing/page/print/print_skeleton_loading.html b/frappe/printing/page/print/print_skeleton_loading.html new file mode 100644 index 0000000000..c1e6a0ddf4 --- /dev/null +++ b/frappe/printing/page/print/print_skeleton_loading.html @@ -0,0 +1,164 @@ + diff --git a/frappe/printing/page/print_format_builder/print_format_builder.css b/frappe/printing/page/print_format_builder/print_format_builder.css index 507cb520ed..0f7796536d 100644 --- a/frappe/printing/page/print_format_builder/print_format_builder.css +++ b/frappe/printing/page/print_format_builder/print_format_builder.css @@ -1,83 +1,160 @@ -.print-format-builder-section, .print-format-builder-add-section { - border: 1px solid #d1d8dd; +.print-format-builder-section { + border: 1px solid var(--dark-border-color); + border-radius: var(--border-radius); margin: 0px; - margin-bottom: 15px; + margin-bottom: var(--margin-md); } + .print-format-builder-add-section, .print-format-builder-header { - border: 1px dashed #d1d8dd; - padding: 15px; + border: 1px dashed var(--dark-border-color); + border-radius: var(--border-radius); + padding: var(--padding-sm); + width: 100%; + display: inline-block; + background: var(--bg-color); cursor: pointer; } +.print-format-builder-header-edit { + margin-bottom: var(--margin-sm); +} + .print-format-builder-header { - margin-bottom: 15px; + margin-bottom: var(--margin-md); +} + +.print-format-builder-add-section { + color: var(--text-light); + align-items: center; + padding: var(--padding-lg); + display: flex; + justify-content: center; +} + +.print-format-builder-add-section .icon { + margin-right: var(--margin-sm); } .print-format-builder-column { - padding: 15px; - margin: 0px -15px 0 -16px; - background-color: white; - min-height: 64px; - border-left: 1px solid #d1d8dd; - border-right: 1px solid #d1d8dd; + border-radius: var(--border-radius); } -.section-column:last-child .print-format-builder-column { - margin-right: -16px; +.print-format-builder-section .section-column { + padding: var(--padding-xs) 0 var(--padding-md) var(--padding-md); +} + +.print-format-builder-section .section-column:last-child { + padding-right: var(--padding-md); } .print-format-builder-field { - padding: 5px; - border: 1px solid #d1d8dd; - border-radius: 3px; - margin-bottom: 10px; - min-height: 34px; + padding: 8px; + width: 100%; + display: inline-block; + border-radius: var(--border-radius); + background: var(--bg-light-gray); + margin-bottom: var(--margin-sm); + font-size: var(--text-md); + color: var(--text-color); } .print-format-builder-field:last-child { - margin-bottom: 0px; + margin-bottom: 0; } -.print-format-builder-field-placeholder { - margin-bottom: 10px; -} - -.print-format-builder-field-placeholder:last-child { - margin-bottom: 0px; +.print-format-builder-field .field-label { + vertical-align: middle; } .print-format-builder-column .print-format-builder-field { cursor: move; } +.print-format-builder-section-head .section-label { + font-size: var(--text-lg); + color: var(--text-color); + font-weight: 500; + vertical-align: middle; + margin-left: var(--margin-sm); +} .print-format-builder-section-head { cursor: move; - padding: 10px 15px; - border-bottom: 1px solid #d1d8dd; + padding: var(--padding-md) calc(var(--padding-md) + 8px) + var(--padding-sm) calc(var(--padding-md) + 8px); +} + +.column-selector-row { + margin-bottom: var(--margin-xs); + padding: var(--padding-xs) 0; + cursor: grab; } .column-selector-row:hover { - background-color: #fafbfc; + background-color: var(--highlight-color); } -.column-selector-row .form-control { - margin-top: 5px; +.column-selector-row .drag-handle { + margin-right: var(--margin-sm); +} + +.print-format-builder-field .drag-handle { + margin-right: var(--margin-sm); +} + +.print-format-builder-sidebar .sidebar-field { + width: 100%; + padding: 4px 8px; + color: var(--text-on-light-gray); + /* color: var(--text-light); */ + text-align: left; + cursor: grab; +} + +.print-format-builder-sidebar .sidebar-custom-field { + background-color: var(--gray-300); } .print-format-builder-sidebar { - display: inline-block; - width: 160px; - vertical-align: top; - position: fixed; - top: 110px; - bottom: 0px; + top: calc(var(--navbar-height) + 70px); + position: sticky; +} + +.print-format-builder-sidebar-fields { + padding: var(--padding-xs); overflow-y: auto; + height: 75vh; +} + +.print-format-builder-field-placeholder { + margin-bottom: var(--margin-sm); +} + +.print-format-builder-field-placeholder:last-child { + margin-bottom: 0; +} + +.print-format-builder-field-placeholder .drag-handle { + margin-right: var(--margin-sm); +} + +.filter-searchbox { + padding: 0 var(--padding-xs); + margin-bottom: var(--margin-sm); +} + +.filter-searchbox input { + background-color: var(--control-bg-on-gray); } .print-format-builder-main { display: inline-block; vertical-align: top; border-top: 0px; - margin-left: 160px; + padding: var(--padding-lg); +} + +.print-format-help-message { + font-size: var(--text-md); + margin-bottom: var(--margin-md); } 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 4e049d120a..7e58e295b5 100644 --- a/frappe/printing/page/print_format_builder/print_format_builder.js +++ b/frappe/printing/page/print_format_builder/print_format_builder.js @@ -42,14 +42,12 @@ frappe.PrintFormatBuilder = Class.extend({ this.page = frappe.ui.make_app_page({ parent: this.parent, title: __("Print Format Builder"), - single_column: true }); this.page.main.css({"border-color": "transparent"}); - this.page.sidebar = $('').appendTo(this.page.main); - this.page.main = $('').appendTo(this.page.main); + this.page.sidebar = $('').appendTo(this.page.sidebar); + this.page.main = $('').appendTo(this.page.main); // future-bindings for buttons on sections / fields // bind only once @@ -61,7 +59,6 @@ frappe.PrintFormatBuilder = Class.extend({ }, show_start: function() { this.page.main.html(frappe.render_template("print_format_builder_start", {})); - this.page.sidebar.html(""); this.page.clear_actions(); this.page.set_title(__("Print Format Builder")); this.start_edit_print_format(); @@ -168,16 +165,11 @@ frappe.PrintFormatBuilder = Class.extend({ }); }, setup_sidebar: function() { - var me = this; - this.page.sidebar.empty(); - // prepend custom HTML field var fields = [this.get_custom_html_field()].concat(this.meta.fields); - - $(frappe.render_template("print_format_builder_sidebar", - {fields: fields})) - .appendTo(this.page.sidebar); - + this.page.sidebar.html( + $(frappe.render_template("print_format_builder_sidebar", {fields: fields})) + ); this.setup_field_filter(); }, get_custom_html_field: function() { @@ -216,8 +208,8 @@ frappe.PrintFormatBuilder = Class.extend({ if(!this.print_heading_template) { // default print heading template this.print_heading_template = ''; } @@ -319,7 +311,7 @@ frappe.PrintFormatBuilder = Class.extend({ var me = this; // drag from fields library - Sortable.create(this.page.sidebar.find(".print-format-builder-fields").get(0), + Sortable.create(this.page.sidebar.find(".print-format-builder-sidebar-fields").get(0), { group: { name:'field', put: true, pull:"clone" @@ -466,9 +458,6 @@ frappe.PrintFormatBuilder = Class.extend({ field.remove(); }, input_class: "btn-danger", - input_css: { - "margin-top": "10px" - } } ], }); @@ -795,6 +784,7 @@ frappe.PrintFormatBuilder = Class.extend({ btn: this.page.btn_primary, callback: function(r) { me.print_format = r.message; + locals['Print Format'][me.print_format.name] = r.message; frappe.show_alert({message: __("Saved"), indicator: 'green'}); } }); diff --git a/frappe/printing/page/print_format_builder/print_format_builder_column_selector.html b/frappe/printing/page/print_format_builder/print_format_builder_column_selector.html index 697f27866e..15d029704b 100644 --- a/frappe/printing/page/print_format_builder/print_format_builder_column_selector.html +++ b/frappe/printing/page/print_format_builder/print_format_builder_column_selector.html @@ -1,33 +1,36 @@ -

        {{ __("Check columns to select, drag to set order.") }} +

        {{ __("Check columns to select, drag to set order.") }} {{ __("Widths can be set in px or %.") }}

        {{ __("Some columns might get cut off when printing to PDF. Try to keep number of columns under 10.") }}

        -

        {{ __("Column") }}

        -

        {{ __("Width") }}

        +

        {{ __("Column") }}

        +

        {{ __("Width") }}

        - {% for (i=0; i < fields.length; i++) { var f = fields[i]; %} - {% var selected = in_list(column_names, f.fieldname) %} -
        -
        -
        - -
        -
        -
        - -
        -
        - {% } %} + {% for (i=0; i < fields.length; i++) { var f = fields[i]; %} + {% var selected = in_list(column_names, f.fieldname) %} +
        +
        +
        + +
        +
        + +
        +
        +
        + +
        +
        + {% } %}
        diff --git a/frappe/printing/page/print_format_builder/print_format_builder_field.html b/frappe/printing/page/print_format_builder/print_format_builder_field.html index eb29092f28..0e207222a5 100644 --- a/frappe/printing/page/print_format_builder/print_format_builder_field.html +++ b/frappe/printing/page/print_format_builder/print_format_builder_field.html @@ -1,35 +1,46 @@ diff --git a/frappe/printing/page/print_format_builder/print_format_builder_layout.html b/frappe/printing/page/print_format_builder/print_format_builder_layout.html index 04475d8fab..f45a760cbc 100644 --- a/frappe/printing/page/print_format_builder/print_format_builder_layout.html +++ b/frappe/printing/page/print_format_builder/print_format_builder_layout.html @@ -1,11 +1,15 @@ -
        -
        - {%= __("Drag elements from the sidebar to add. Drag them back to trash.") %}

        +
        +