diff --git a/.editorconfig b/.editorconfig index 24f122a8d4..d76f67cd7f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,6 +9,6 @@ trim_trailing_whitespace = true charset = utf-8 # python, js indentation settings -[{*.py,*.js}] +[{*.py,*.js,*.vue}] indent_style = tab indent_size = 4 diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 4faece896a..f02694846d 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -10,3 +10,6 @@ # Replace use of Class.extend with native JS class fe20515c23a3ac41f1092bf0eaf0a0a452ec2e85 + +# Updating license headers +34460265554242a8d05fb09f049033b1117e1a2b diff --git a/.github/helper/documentation.py b/.github/helper/documentation.py index 08d1d1aa9c..f8ee3fa10b 100644 --- a/.github/helper/documentation.py +++ b/.github/helper/documentation.py @@ -32,9 +32,9 @@ if __name__ == "__main__": if response.ok: payload = response.json() - title = payload.get("title", "").lower() - head_sha = payload.get("head", {}).get("sha") - body = payload.get("body", "").lower() + title = (payload.get("title") or "").lower() + head_sha = (payload.get("head") or {}).get("sha") + body = (payload.get("body") or "").lower() if title.startswith("feat") and head_sha and "no-docs" not in body: if docs_link_exists(body): diff --git a/.github/helper/install.sh b/.github/helper/install.sh index f6f0cad31a..454cc89694 100644 --- a/.github/helper/install.sh +++ b/.github/helper/install.sh @@ -17,6 +17,7 @@ if [ "$TYPE" == "server" ]; then fi if [ "$DB" == "mariadb" ];then + sudo apt install mariadb-client-10.3 mysql --host 127.0.0.1 --port 3306 -u root -e "SET GLOBAL character_set_server = 'utf8mb4'"; mysql --host 127.0.0.1 --port 3306 -u root -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"; @@ -49,6 +50,7 @@ if [ "$TYPE" == "server" ]; then sed -i 's/^socketio:/# socketio:/g' Procfile; f if [ "$TYPE" == "server" ]; then sed -i 's/^redis_socketio:/# redis_socketio:/g' Procfile; fi if [ "$TYPE" == "ui" ]; then bench setup requirements --node; fi +if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi # install node-sass which is required for website theme test cd ./apps/frappe || exit @@ -58,4 +60,4 @@ cd ../.. bench start & bench --site test_site reinstall --yes if [ "$TYPE" == "server" ]; then bench --site test_site_producer reinstall --yes; fi -bench build --app frappe \ No newline at end of file +CI=Yes bench build --app frappe diff --git a/.github/helper/install_dependencies.sh b/.github/helper/install_dependencies.sh index 9be8519d85..d16f5b62ad 100644 --- a/.github/helper/install_dependencies.sh +++ b/.github/helper/install_dependencies.sh @@ -2,11 +2,6 @@ set -e -# python "${GITHUB_WORKSPACE}/.github/helper/roulette.py" -# if [[ $? != 2 ]];then -# exit; -# fi - # install wkhtmltopdf wget -O /tmp/wkhtmltox.tar.xz https://github.com/frappe/wkhtmltopdf/raw/master/wkhtmltox-0.12.3_linux-generic-amd64.tar.xz tar -xf /tmp/wkhtmltox.tar.xz -C /tmp diff --git a/.github/helper/roulette.py b/.github/helper/roulette.py index ea4f07b9f7..9831df7f30 100644 --- a/.github/helper/roulette.py +++ b/.github/helper/roulette.py @@ -1,56 +1,72 @@ -# if the script ends with exit code 0, then no tests are run further, else all tests are run +import json import os import re import shlex import subprocess import sys +import urllib.request +def get_files_list(pr_number, repo="frappe/frappe"): + req = urllib.request.Request(f"https://api.github.com/repos/{repo}/pulls/{pr_number}/files") + res = urllib.request.urlopen(req) + dump = json.loads(res.read().decode('utf8')) + return [change["filename"] for change in dump] + def get_output(command, shell=True): - print(command) - command = shlex.split(command) - return subprocess.check_output(command, shell=shell, encoding="utf8").strip() + print(command) + command = shlex.split(command) + return subprocess.check_output(command, shell=shell, encoding="utf8").strip() def is_py(file): - return file.endswith("py") + return file.endswith("py") -def is_js(file): - return file.endswith("js") +def is_ci(file): + return ".github" in file + +def is_frontend_code(file): + return file.lower().endswith((".css", ".scss", ".less", ".sass", ".styl", ".js", ".ts", ".vue")) def is_docs(file): - regex = re.compile(r'\.(md|png|jpg|jpeg)$|^.github|LICENSE') - return bool(regex.search(file)) + regex = re.compile(r'\.(md|png|jpg|jpeg|csv)$|^.github|LICENSE') + return bool(regex.search(file)) if __name__ == "__main__": - build_type = os.environ.get("TYPE") - before = os.environ.get("BEFORE") - after = os.environ.get("AFTER") - commit_range = before + '...' + after - print("Build Type: {}".format(build_type)) - print("Commit Range: {}".format(commit_range)) + files_list = sys.argv[1:] + build_type = os.environ.get("TYPE") + pr_number = os.environ.get("PR_NUMBER") + repo = os.environ.get("REPO_NAME") - try: - files_changed = get_output("git diff --name-only {}".format(commit_range), shell=False) - except Exception: - sys.exit(2) + # this is a push build, run all builds + if not pr_number: + os.system('echo "::set-output name=build::strawberry"') + sys.exit(0) - if "fatal" not in files_changed: - files_list = files_changed.split() - only_docs_changed = len(list(filter(is_docs, files_list))) == len(files_list) - only_js_changed = len(list(filter(is_js, files_list))) == len(files_list) - only_py_changed = len(list(filter(is_py, files_list))) == len(files_list) + files_list = files_list or get_files_list(pr_number=pr_number, repo=repo) - if only_docs_changed: - print("Only docs were updated, stopping build process.") - sys.exit(0) + if not files_list: + print("No files' changes detected. Build is shutting") + sys.exit(0) - if only_js_changed and build_type == "server": - print("Only JavaScript code was updated; Stopping Python build process.") - sys.exit(0) + ci_files_changed = any(f for f in files_list if is_ci(f)) + only_docs_changed = len(list(filter(is_docs, files_list))) == len(files_list) + only_frontend_code_changed = len(list(filter(is_frontend_code, files_list))) == len(files_list) + only_py_changed = len(list(filter(is_py, files_list))) == len(files_list) - if only_py_changed and build_type == "ui": - print("Only Python code was updated, stopping Cypress build process.") - sys.exit(0) + if ci_files_changed: + print("CI related files were updated, running all build processes.") - sys.exit(2) + elif only_docs_changed: + print("Only docs were updated, stopping build process.") + sys.exit(0) + + elif only_frontend_code_changed and build_type == "server": + print("Only Frontend code was updated; Stopping Python build process.") + sys.exit(0) + + elif only_py_changed and build_type == "ui": + print("Only Python code was updated, stopping Cypress build process.") + sys.exit(0) + + os.system('echo "::set-output name=build::strawberry"') diff --git a/.github/helper/semgrep_rules/README.md b/.github/helper/semgrep_rules/README.md deleted file mode 100644 index 670d8d280f..0000000000 --- a/.github/helper/semgrep_rules/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# Semgrep linting - -## What is semgrep? -Semgrep or "semantic grep" is language agnostic static analysis tool. In simple terms semgrep is syntax-aware `grep`, so unlike regex it doesn't get confused by different ways of writing same thing or whitespaces or code split in multiple lines etc. - -Example: - -To check if a translate function is using f-string or not the regex would be `r"_\(\s*f[\"']"` while equivalent rule in semgrep would be `_(f"...")`. As semgrep knows grammer of language it takes care of unnecessary whitespace, type of quotation marks etc. - -You can read more such examples in `.github/helper/semgrep_rules` directory. - -# Why/when to use this? -We want to maintain quality of contributions, at the same time remembering all the good practices can be pain to deal with while evaluating contributions. Using semgrep if you can translate "best practice" into a rule then it can automate the task for us. - -## Running locally - -Install semgrep using homebrew `brew install semgrep` or pip `pip install semgrep`. - -To run locally use following command: - -`semgrep --config=.github/helper/semgrep_rules [file/folder names]` - -## Testing -semgrep allows testing the tests. Refer to this page: https://semgrep.dev/docs/writing-rules/testing-rules/ - -When writing new rules you should write few positive and few negative cases as shown in the guide and current tests. - -To run current tests: `semgrep --test --test-ignore-todo .github/helper/semgrep_rules` - - -## Reference - -If you are new to Semgrep read following pages to get started on writing/modifying rules: - -- https://semgrep.dev/docs/getting-started/ -- https://semgrep.dev/docs/writing-rules/rule-syntax -- https://semgrep.dev/docs/writing-rules/pattern-examples/ -- https://semgrep.dev/docs/writing-rules/rule-ideas/#common-use-cases diff --git a/.github/helper/semgrep_rules/frappe_correctness.py b/.github/helper/semgrep_rules/frappe_correctness.py deleted file mode 100644 index 745e6463b8..0000000000 --- a/.github/helper/semgrep_rules/frappe_correctness.py +++ /dev/null @@ -1,64 +0,0 @@ -import frappe -from frappe import _, flt - -from frappe.model.document import Document - - -# ruleid: frappe-modifying-but-not-comitting -def on_submit(self): - if self.value_of_goods == 0: - frappe.throw(_('Value of goods cannot be 0')) - self.status = 'Submitted' - - -# ok: frappe-modifying-but-not-comitting -def on_submit(self): - if self.value_of_goods == 0: - frappe.throw(_('Value of goods cannot be 0')) - self.status = 'Submitted' - self.db_set('status', 'Submitted') - -# ok: frappe-modifying-but-not-comitting -def on_submit(self): - if self.value_of_goods == 0: - frappe.throw(_('Value of goods cannot be 0')) - x = "y" - self.status = x - self.db_set('status', x) - - -# ok: frappe-modifying-but-not-comitting -def on_submit(self): - x = "y" - self.status = x - self.save() - -# ruleid: frappe-modifying-but-not-comitting-other-method -class DoctypeClass(Document): - def on_submit(self): - self.good_method() - self.tainted_method() - - def tainted_method(self): - self.status = "uptate" - - -# ok: frappe-modifying-but-not-comitting-other-method -class DoctypeClass(Document): - def on_submit(self): - self.good_method() - self.tainted_method() - - def tainted_method(self): - self.status = "update" - self.db_set("status", "update") - -# ok: frappe-modifying-but-not-comitting-other-method -class DoctypeClass(Document): - def on_submit(self): - self.good_method() - self.tainted_method() - self.save() - - def tainted_method(self): - self.status = "uptate" diff --git a/.github/helper/semgrep_rules/frappe_correctness.yml b/.github/helper/semgrep_rules/frappe_correctness.yml deleted file mode 100644 index faab3344a6..0000000000 --- a/.github/helper/semgrep_rules/frappe_correctness.yml +++ /dev/null @@ -1,135 +0,0 @@ -# This file specifies rules for correctness according to how frappe doctype data model works. - -rules: -- id: frappe-modifying-but-not-comitting - patterns: - - pattern: | - def $METHOD(self, ...): - ... - self.$ATTR = ... - - pattern-not: | - def $METHOD(self, ...): - ... - self.$ATTR = ... - ... - self.db_set(..., self.$ATTR, ...) - - pattern-not: | - def $METHOD(self, ...): - ... - self.$ATTR = $SOME_VAR - ... - self.db_set(..., $SOME_VAR, ...) - - pattern-not: | - def $METHOD(self, ...): - ... - self.$ATTR = $SOME_VAR - ... - self.save() - - metavariable-regex: - metavariable: '$ATTR' - # this is negative look-ahead, add more attrs to ignore like (ignore|ignore_this_too|ignore_me) - regex: '^(?!ignore_linked_doctypes|status_updater)(.*)$' - - metavariable-regex: - metavariable: "$METHOD" - regex: "(on_submit|on_cancel)" - message: | - DocType modified in self.$METHOD. Please check if modification of self.$ATTR is commited to database. - languages: [python] - severity: ERROR - -- id: frappe-modifying-but-not-comitting-other-method - patterns: - - pattern: | - class $DOCTYPE(...): - def $METHOD(self, ...): - ... - self.$ANOTHER_METHOD() - ... - - def $ANOTHER_METHOD(self, ...): - ... - self.$ATTR = ... - - pattern-not: | - class $DOCTYPE(...): - def $METHOD(self, ...): - ... - self.$ANOTHER_METHOD() - ... - - def $ANOTHER_METHOD(self, ...): - ... - self.$ATTR = ... - ... - self.db_set(..., self.$ATTR, ...) - - pattern-not: | - class $DOCTYPE(...): - def $METHOD(self, ...): - ... - self.$ANOTHER_METHOD() - ... - - def $ANOTHER_METHOD(self, ...): - ... - self.$ATTR = $SOME_VAR - ... - self.db_set(..., $SOME_VAR, ...) - - pattern-not: | - class $DOCTYPE(...): - def $METHOD(self, ...): - ... - self.$ANOTHER_METHOD() - ... - self.save() - def $ANOTHER_METHOD(self, ...): - ... - self.$ATTR = ... - - metavariable-regex: - metavariable: "$METHOD" - regex: "(on_submit|on_cancel)" - message: | - self.$ANOTHER_METHOD is called from self.$METHOD, check if changes to self.$ATTR are commited to database. - languages: [python] - severity: ERROR - -- id: frappe-print-function-in-doctypes - pattern: print(...) - message: | - Did you mean to leave this print statement in? Consider using msgprint or logger instead of print statement. - languages: [python] - severity: WARNING - paths: - exclude: - - test_*.py - include: - - "*/**/doctype/*" - -- id: frappe-modifying-child-tables-while-iterating - pattern-either: - - pattern: | - for $ROW in self.$TABLE: - ... - self.remove(...) - - pattern: | - for $ROW in self.$TABLE: - ... - self.append(...) - message: | - Child table being modified while iterating on it. - languages: [python] - severity: ERROR - paths: - include: - - "*/**/doctype/*" - -- id: frappe-same-key-assigned-twice - pattern-either: - - pattern: | - {..., $X: $A, ..., $X: $B, ...} - - pattern: | - dict(..., ($X, $A), ..., ($X, $B), ...) - - pattern: | - _dict(..., ($X, $A), ..., ($X, $B), ...) - message: | - key `$X` is uselessly assigned twice. This could be a potential bug. - languages: [python] - severity: ERROR diff --git a/.github/helper/semgrep_rules/security.py b/.github/helper/semgrep_rules/security.py deleted file mode 100644 index f477d7c176..0000000000 --- a/.github/helper/semgrep_rules/security.py +++ /dev/null @@ -1,6 +0,0 @@ -def function_name(input): - # ruleid: frappe-codeinjection-eval - eval(input) - -# ok: frappe-codeinjection-eval -eval("1 + 1") diff --git a/.github/helper/semgrep_rules/security.yml b/.github/helper/semgrep_rules/security.yml deleted file mode 100644 index b2cc4b16fc..0000000000 --- a/.github/helper/semgrep_rules/security.yml +++ /dev/null @@ -1,29 +0,0 @@ -rules: -- id: frappe-codeinjection-eval - patterns: - - pattern-not: eval("...") - - pattern: eval(...) - message: | - Detected the use of eval(). eval() can be dangerous if used to evaluate - dynamic content. Avoid it or use safe_eval(). - languages: [python] - severity: ERROR - paths: - exclude: - - frappe/__init__.py - - frappe/commands/utils.py - -- id: frappe-sqli-format-strings - patterns: - - pattern-inside: | - @frappe.whitelist() - def $FUNC(...): - ... - - pattern-either: - - pattern: frappe.db.sql("..." % ...) - - pattern: frappe.db.sql(f"...", ...) - - pattern: frappe.db.sql("...".format(...), ...) - message: | - Detected use of raw string formatting for SQL queries. This can lead to sql injection vulnerabilities. Refer security guidelines - https://github.com/frappe/erpnext/wiki/Code-Security-Guidelines - languages: [python] - severity: WARNING diff --git a/.github/helper/semgrep_rules/translate.js b/.github/helper/semgrep_rules/translate.js deleted file mode 100644 index 9cdfb75d0b..0000000000 --- a/.github/helper/semgrep_rules/translate.js +++ /dev/null @@ -1,44 +0,0 @@ -// ruleid: frappe-translation-empty-string -__("") -// ruleid: frappe-translation-empty-string -__('') - -// ok: frappe-translation-js-formatting -__('Welcome {0}, get started with ERPNext in just a few clicks.', [full_name]); - -// ruleid: frappe-translation-js-formatting -__(`Welcome ${full_name}, get started with ERPNext in just a few clicks.`); - -// ok: frappe-translation-js-formatting -__('This is fine'); - - -// ok: frappe-translation-trailing-spaces -__('This is fine'); - -// ruleid: frappe-translation-trailing-spaces -__(' this is not ok '); -// ruleid: frappe-translation-trailing-spaces -__('this is not ok '); -// ruleid: frappe-translation-trailing-spaces -__(' this is not ok'); - -// ok: frappe-translation-js-splitting -__('You have {0} subscribers in your mailing list.', [subscribers.length]) - -// todoruleid: frappe-translation-js-splitting -__('You have') + subscribers.length + __('subscribers in your mailing list.') - -// ruleid: frappe-translation-js-splitting -__('You have' + 'subscribers in your mailing list.') - -// ruleid: frappe-translation-js-splitting -__('You have {0} subscribers' + - 'in your mailing list', [subscribers.length]) - -// ok: frappe-translation-js-splitting -__("Ctrl+Enter to add comment") - -// ruleid: frappe-translation-js-splitting -__('You have {0} subscribers \ - in your mailing list', [subscribers.length]) diff --git a/.github/helper/semgrep_rules/translate.py b/.github/helper/semgrep_rules/translate.py deleted file mode 100644 index 9de6aa94f0..0000000000 --- a/.github/helper/semgrep_rules/translate.py +++ /dev/null @@ -1,61 +0,0 @@ -# Examples taken from https://frappeframework.com/docs/user/en/translations -# This file is used for testing the tests. - -from frappe import _ - -full_name = "Jon Doe" -# ok: frappe-translation-python-formatting -_('Welcome {0}, get started with ERPNext in just a few clicks.').format(full_name) - -# ruleid: frappe-translation-python-formatting -_('Welcome %s, get started with ERPNext in just a few clicks.' % full_name) -# ruleid: frappe-translation-python-formatting -_('Welcome %(name)s, get started with ERPNext in just a few clicks.' % {'name': full_name}) - -# ruleid: frappe-translation-python-formatting -_('Welcome {0}, get started with ERPNext in just a few clicks.'.format(full_name)) - - -subscribers = ["Jon", "Doe"] -# ok: frappe-translation-python-formatting -_('You have {0} subscribers in your mailing list.').format(len(subscribers)) - -# ruleid: frappe-translation-python-splitting -_('You have') + len(subscribers) + _('subscribers in your mailing list.') - -# ruleid: frappe-translation-python-splitting -_('You have {0} subscribers \ - in your mailing list').format(len(subscribers)) - -# ok: frappe-translation-python-splitting -_('You have {0} subscribers') \ - + 'in your mailing list' - -# ruleid: frappe-translation-trailing-spaces -msg = _(" You have {0} pending invoice ") -# ruleid: frappe-translation-trailing-spaces -msg = _("You have {0} pending invoice ") -# ruleid: frappe-translation-trailing-spaces -msg = _(" You have {0} pending invoice") - -# ok: frappe-translation-trailing-spaces -msg = ' ' + _("You have {0} pending invoices") + ' ' - -# ruleid: frappe-translation-python-formatting -_(f"can not format like this - {subscribers}") -# ruleid: frappe-translation-python-splitting -_(f"what" + f"this is also not cool") - - -# ruleid: frappe-translation-empty-string -_("") -# ruleid: frappe-translation-empty-string -_('') - - -class Test: - # ok: frappe-translation-python-splitting - def __init__( - args - ): - pass diff --git a/.github/helper/semgrep_rules/translate.yml b/.github/helper/semgrep_rules/translate.yml deleted file mode 100644 index 5f03fb9fd0..0000000000 --- a/.github/helper/semgrep_rules/translate.yml +++ /dev/null @@ -1,64 +0,0 @@ -rules: -- id: frappe-translation-empty-string - pattern-either: - - pattern: _("") - - pattern: __("") - message: | - Empty string is useless for translation. - Please refer: https://frappeframework.com/docs/user/en/translations - languages: [python, javascript, json] - severity: ERROR - -- id: frappe-translation-trailing-spaces - pattern-either: - - pattern: _("=~/(^[ \t]+|[ \t]+$)/") - - pattern: __("=~/(^[ \t]+|[ \t]+$)/") - message: | - Trailing or leading whitespace not allowed in translate strings. - Please refer: https://frappeframework.com/docs/user/en/translations - languages: [python, javascript, json] - severity: ERROR - -- id: frappe-translation-python-formatting - pattern-either: - - pattern: _("..." % ...) - - pattern: _("...".format(...)) - - pattern: _(f"...") - message: | - Only positional formatters are allowed and formatting should not be done before translating. - Please refer: https://frappeframework.com/docs/user/en/translations - languages: [python] - severity: ERROR - -- id: frappe-translation-js-formatting - patterns: - - pattern: __(`...`) - - pattern-not: __("...") - message: | - Template strings are not allowed for text formatting. - Please refer: https://frappeframework.com/docs/user/en/translations - languages: [javascript, json] - severity: ERROR - -- id: frappe-translation-python-splitting - pattern-either: - - pattern: _(...) + _(...) - - pattern: _("..." + "...") - - pattern-regex: '[\s\.]_\([^\)]*\\\s*' # lines broken by `\` - - pattern-regex: '[\s\.]_\(\s*\n' # line breaks allowed by python for using ( ) - message: | - Do not split strings inside translate function. Do not concatenate using translate functions. - Please refer: https://frappeframework.com/docs/user/en/translations - languages: [python] - severity: ERROR - -- id: frappe-translation-js-splitting - pattern-either: - - pattern-regex: '__\([^\)]*[\\]\s+' - - pattern: __('...' + '...', ...) - - pattern: __('...') + __('...') - message: | - Do not split strings inside translate function. Do not concatenate using translate functions. - Please refer: https://frappeframework.com/docs/user/en/translations - languages: [javascript, json] - severity: ERROR diff --git a/.github/helper/semgrep_rules/ux.js b/.github/helper/semgrep_rules/ux.js deleted file mode 100644 index ae73f9cc60..0000000000 --- a/.github/helper/semgrep_rules/ux.js +++ /dev/null @@ -1,9 +0,0 @@ - -// ok: frappe-missing-translate-function-js -frappe.msgprint('{{ _("Both login and password required") }}'); - -// ruleid: frappe-missing-translate-function-js -frappe.msgprint('What'); - -// ok: frappe-missing-translate-function-js -frappe.throw(' {{ _("Both login and password required") }}. '); diff --git a/.github/helper/semgrep_rules/ux.py b/.github/helper/semgrep_rules/ux.py deleted file mode 100644 index a00d3cd8ae..0000000000 --- a/.github/helper/semgrep_rules/ux.py +++ /dev/null @@ -1,31 +0,0 @@ -import frappe -from frappe import msgprint, throw, _ - - -# ruleid: frappe-missing-translate-function-python -throw("Error Occured") - -# ruleid: frappe-missing-translate-function-python -frappe.throw("Error Occured") - -# ruleid: frappe-missing-translate-function-python -frappe.msgprint("Useful message") - -# ruleid: frappe-missing-translate-function-python -msgprint("Useful message") - - -# ok: frappe-missing-translate-function-python -translatedmessage = _("Hello") - -# ok: frappe-missing-translate-function-python -throw(translatedmessage) - -# ok: frappe-missing-translate-function-python -msgprint(translatedmessage) - -# ok: frappe-missing-translate-function-python -msgprint(_("Helpful message")) - -# ok: frappe-missing-translate-function-python -frappe.throw(_("Error occured")) diff --git a/.github/helper/semgrep_rules/ux.yml b/.github/helper/semgrep_rules/ux.yml deleted file mode 100644 index dd667f36c0..0000000000 --- a/.github/helper/semgrep_rules/ux.yml +++ /dev/null @@ -1,30 +0,0 @@ -rules: -- id: frappe-missing-translate-function-python - pattern-either: - - patterns: - - pattern: frappe.msgprint("...", ...) - - pattern-not: frappe.msgprint(_("..."), ...) - - patterns: - - pattern: frappe.throw("...", ...) - - pattern-not: frappe.throw(_("..."), ...) - message: | - All user facing text must be wrapped in translate function. Please refer to translation documentation. https://frappeframework.com/docs/user/en/guides/basics/translations - languages: [python] - severity: ERROR - -- id: frappe-missing-translate-function-js - pattern-either: - - patterns: - - pattern: frappe.msgprint("...", ...) - - pattern-not: frappe.msgprint(__("..."), ...) - # ignore microtemplating e.g. msgprint("{{ _("server side translation") }}") - - pattern-not: frappe.msgprint("=~/\{\{.*\_.*\}\}/i", ...) - - patterns: - - pattern: frappe.throw("...", ...) - - pattern-not: frappe.throw(__("..."), ...) - # ignore microtemplating - - pattern-not: frappe.throw("=~/\{\{.*\_.*\}\}/i", ...) - message: | - All user facing text must be wrapped in translate function. Please refer to translation documentation. https://frappeframework.com/docs/user/en/guides/basics/translations - languages: [javascript] - severity: ERROR diff --git a/.github/semantic.yml b/.github/semantic.yml index e1e53bc1a4..fa15046b4a 100644 --- a/.github/semantic.yml +++ b/.github/semantic.yml @@ -11,3 +11,20 @@ allowRevertCommits: true # For allowed PR types: https://github.com/commitizen/conventional-commit-types/blob/v3.0.0/index.json # Tool Reference: https://github.com/zeke/semantic-pull-requests + +# By default types specified in commitizen/conventional-commit-types is used. +# See: https://github.com/commitizen/conventional-commit-types/blob/v3.0.0/index.json +# You can override the valid types +types: + - BREAKING CHANGE + - feat + - fix + - docs + - style + - refactor + - perf + - test + - build + - ci + - chore + - revert diff --git a/.github/try-on-f-cloud-button.svg b/.github/try-on-f-cloud-button.svg new file mode 100644 index 0000000000..fe0bb2c52d --- /dev/null +++ b/.github/try-on-f-cloud-button.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 510e7c7678..dba13f9358 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -12,4 +12,4 @@ jobs: - name: curl run: | apk add curl bash - curl -s -X POST -H "Content-Type: application/json" -H "Accept: application/json" -H "Travis-API-Version: 3" -H "Authorization: token ${{ secrets.TRAVIS_CI_TOKEN }}" -d '{"request":{"branch":"master"}}' https://api.travis-ci.com/repo/frappe%2Ffrappe_docker/requests + curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: Bearer ${{ secrets.CI_PAT }}" https://api.github.com/repos/frappe/frappe_docker/actions/workflows/build_stable.yml/dispatches -d '{"ref":"main"}' diff --git a/.github/workflows/docs-checker.yml b/.github/workflows/docs-checker.yml index 90453cd1b4..02a01bf4e4 100644 --- a/.github/workflows/docs-checker.yml +++ b/.github/workflows/docs-checker.yml @@ -12,7 +12,7 @@ jobs: - name: 'Setup Environment' uses: actions/setup-python@v2 with: - python-version: 3.6 + python-version: 3.7 - name: 'Clone repo' uses: actions/checkout@v2 diff --git a/.github/workflows/patch-mariadb-tests.yml b/.github/workflows/patch-mariadb-tests.yml index e8627a01fb..d9a6ca6f59 100644 --- a/.github/workflows/patch-mariadb-tests.yml +++ b/.github/workflows/patch-mariadb-tests.yml @@ -2,9 +2,14 @@ name: Patch on: [pull_request, workflow_dispatch] + +concurrency: + group: patch-mariadb-develop-${{ github.event.number }} + cancel-in-progress: true + jobs: test: - runs-on: ubuntu-18.04 + runs-on: ubuntu-latest name: Patch Test @@ -24,12 +29,23 @@ jobs: - name: Setup Python uses: actions/setup-python@v2 with: - python-version: 3.7 + python-version: '3.9' + + - name: Check if build should be run + id: check-build + run: | + python "${GITHUB_WORKSPACE}/.github/helper/roulette.py" + env: + TYPE: "server" + PR_NUMBER: ${{ github.event.number }} + REPO_NAME: ${{ github.repository }} - name: Add to Hosts + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts - name: Cache pip + if: ${{ steps.check-build.outputs.build == 'strawberry' }} uses: actions/cache@v2 with: path: ~/.cache/pip @@ -39,6 +55,7 @@ jobs: ${{ runner.os }}- - name: Cache node modules + if: ${{ steps.check-build.outputs.build == 'strawberry' }} uses: actions/cache@v2 env: cache-name: cache-node-modules @@ -51,10 +68,12 @@ jobs: ${{ runner.os }}- - name: Get yarn cache directory path + if: ${{ steps.check-build.outputs.build == 'strawberry' }} id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn cache dir)" - uses: actions/cache@v2 + if: ${{ steps.check-build.outputs.build == 'strawberry' }} id: yarn-cache with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} @@ -63,6 +82,7 @@ jobs: ${{ runner.os }}-yarn- - name: Install Dependencies + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: bash ${GITHUB_WORKSPACE}/.github/helper/install_dependencies.sh env: BEFORE: ${{ env.GITHUB_EVENT_PATH.before }} @@ -70,14 +90,35 @@ jobs: TYPE: server - name: Install + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh env: DB: mariadb TYPE: server - name: Run Patch Tests + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: | cd ~/frappe-bench/ wget https://frappeframework.com/files/v10-frappe.sql.gz bench --site test_site --force restore ~/frappe-bench/v10-frappe.sql.gz + + source env/bin/activate + cd apps/frappe/ + git remote set-url upstream https://github.com/frappe/frappe.git + + for version in $(seq 12 13) + do + echo "Updating to v$version" + branch_name="version-$version-hotfix" + git fetch --depth 1 upstream $branch_name:$branch_name + + git checkout -q -f $branch_name + pip install -q -r requirements.txt + bench --site test_site migrate + done + + echo "Updating to last commit" + git checkout -q -f "$GITHUB_SHA" + bench setup requirements --python bench --site test_site migrate diff --git a/.github/workflows/publish-assets-develop.yml b/.github/workflows/publish-assets-develop.yml index a23885b508..f56d1460b5 100644 --- a/.github/workflows/publish-assets-develop.yml +++ b/.github/workflows/publish-assets-develop.yml @@ -18,7 +18,7 @@ jobs: node-version: 14 - uses: actions/setup-python@v2 with: - python-version: '3.6' + python-version: '3.9' - name: Set up bench and build assets run: | npm install -g yarn diff --git a/.github/workflows/publish-assets-releases.yml b/.github/workflows/publish-assets-releases.yml index a697517c23..2582632fa0 100644 --- a/.github/workflows/publish-assets-releases.yml +++ b/.github/workflows/publish-assets-releases.yml @@ -21,7 +21,7 @@ jobs: python-version: '12.x' - uses: actions/setup-python@v2 with: - python-version: '3.6' + python-version: '3.9' - name: Set up bench and build assets run: | npm install -g yarn diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml index 389524e968..325411cf5c 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/semgrep.yml @@ -1,34 +1,22 @@ name: Semgrep on: - pull_request: - branches: - - develop - - version-13-hotfix - - version-13-pre-release + pull_request: { } + jobs: semgrep: name: Frappe Linter runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Setup python3 - uses: actions/setup-python@v2 - with: - python-version: 3.8 + - uses: actions/checkout@v2 - - name: Setup semgrep - run: | - python -m pip install -q semgrep - git fetch origin $GITHUB_BASE_REF:$GITHUB_BASE_REF -q + - name: Download Semgrep rules + run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules - - name: Semgrep errors - run: | - files=$(git diff --name-only --diff-filter=d $GITHUB_BASE_REF) - [[ -d .github/helper/semgrep_rules ]] && semgrep --severity ERROR --config=.github/helper/semgrep_rules --quiet --error $files - semgrep --config="r/python.lang.correctness" --quiet --error $files - - - name: Semgrep warnings - run: | - files=$(git diff --name-only --diff-filter=d $GITHUB_BASE_REF) - [[ -d .github/helper/semgrep_rules ]] && semgrep --severity WARNING --severity INFO --config=.github/helper/semgrep_rules --quiet $files + - uses: returntocorp/semgrep-action@v1 + env: + SEMGREP_TIMEOUT: 120 + with: + config: >- + r/python.lang.correctness + ./frappe-semgrep-rules/rules diff --git a/.github/workflows/server-mariadb-tests.yml b/.github/workflows/server-mariadb-tests.yml index 2476102e3d..588f357f26 100644 --- a/.github/workflows/server-mariadb-tests.yml +++ b/.github/workflows/server-mariadb-tests.yml @@ -6,9 +6,14 @@ on: push: branches: [ develop ] +concurrency: + group: server-mariadb-develop-${{ github.event.number }} + cancel-in-progress: true + + jobs: test: - runs-on: ubuntu-18.04 + runs-on: ubuntu-latest strategy: fail-fast: false @@ -33,19 +38,31 @@ jobs: - name: Setup Python uses: actions/setup-python@v2 with: - python-version: 3.7 + python-version: '3.9' + + - name: Check if build should be run + id: check-build + run: | + python "${GITHUB_WORKSPACE}/.github/helper/roulette.py" + env: + TYPE: "server" + PR_NUMBER: ${{ github.event.number }} + REPO_NAME: ${{ github.repository }} - uses: actions/setup-node@v2 + if: ${{ steps.check-build.outputs.build == 'strawberry' }} with: node-version: 14 check-latest: true - name: Add to Hosts + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: | echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts echo "127.0.0.1 test_site_producer" | sudo tee -a /etc/hosts - name: Cache pip + if: ${{ steps.check-build.outputs.build == 'strawberry' }} uses: actions/cache@v2 with: path: ~/.cache/pip @@ -55,6 +72,7 @@ jobs: ${{ runner.os }}- - name: Cache node modules + if: ${{ steps.check-build.outputs.build == 'strawberry' }} uses: actions/cache@v2 env: cache-name: cache-node-modules @@ -67,10 +85,12 @@ jobs: ${{ runner.os }}- - name: Get yarn cache directory path + if: ${{ steps.check-build.outputs.build == 'strawberry' }} id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn cache dir)" - uses: actions/cache@v2 + if: ${{ steps.check-build.outputs.build == 'strawberry' }} id: yarn-cache with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} @@ -79,6 +99,7 @@ jobs: ${{ runner.os }}-yarn- - name: Install Dependencies + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: bash ${GITHUB_WORKSPACE}/.github/helper/install_dependencies.sh env: BEFORE: ${{ env.GITHUB_EVENT_PATH.before }} @@ -86,45 +107,25 @@ jobs: TYPE: server - name: Install + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh env: DB: mariadb TYPE: server - name: Run Tests + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --use-orchestrator --with-coverage env: CI_BUILD_ID: ${{ github.run_id }} ORCHESTRATOR_URL: http://test-orchestrator.frappe.io - - name: Upload Coverage Data - run: | - cp ~/frappe-bench/sites/.coverage ${GITHUB_WORKSPACE} - cd ${GITHUB_WORKSPACE} - pip3 install coverage==5.5 - pip3 install coveralls==3.0.1 - coveralls - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_TOKEN }} - COVERALLS_FLAG_NAME: run-${{ matrix.container }} - COVERALLS_SERVICE_NAME: ${{ github.event_name == 'pull_request' && 'github' || 'github-actions' }} - COVERALLS_PARALLEL: true - - coveralls: - name: Coverage Wrap Up - needs: test - container: python:3-slim - runs-on: ubuntu-18.04 - steps: - - name: Clone - uses: actions/checkout@v2 - - - name: Coveralls Finished - run: | - cd ${GITHUB_WORKSPACE} - pip3 install coverage==5.5 - pip3 install coveralls==3.0.1 - coveralls --finish - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Upload coverage data + if: ${{ steps.check-build.outputs.build == 'strawberry' }} + uses: codecov/codecov-action@v2 + with: + name: MariaDB + fail_ci_if_error: true + files: /home/runner/frappe-bench/sites/coverage.xml + verbose: true + flags: server \ No newline at end of file diff --git a/.github/workflows/server-postgres-tests.yml b/.github/workflows/server-postgres-tests.yml index 4325eebaad..78f379837b 100644 --- a/.github/workflows/server-postgres-tests.yml +++ b/.github/workflows/server-postgres-tests.yml @@ -3,10 +3,16 @@ name: Server on: pull_request: workflow_dispatch: + push: + branches: [ develop ] + +concurrency: + group: server-postgres-develop-${{ github.event.number }} + cancel-in-progress: true jobs: test: - runs-on: ubuntu-18.04 + runs-on: ubuntu-latest strategy: fail-fast: false @@ -35,19 +41,31 @@ jobs: - name: Setup Python uses: actions/setup-python@v2 with: - python-version: 3.7 + python-version: '3.9' + + - name: Check if build should be run + id: check-build + run: | + python "${GITHUB_WORKSPACE}/.github/helper/roulette.py" + env: + TYPE: "server" + PR_NUMBER: ${{ github.event.number }} + REPO_NAME: ${{ github.repository }} - uses: actions/setup-node@v2 + if: ${{ steps.check-build.outputs.build == 'strawberry' }} with: node-version: '14' check-latest: true - name: Add to Hosts + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: | echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts echo "127.0.0.1 test_site_producer" | sudo tee -a /etc/hosts - name: Cache pip + if: ${{ steps.check-build.outputs.build == 'strawberry' }} uses: actions/cache@v2 with: path: ~/.cache/pip @@ -57,6 +75,7 @@ jobs: ${{ runner.os }}- - name: Cache node modules + if: ${{ steps.check-build.outputs.build == 'strawberry' }} uses: actions/cache@v2 env: cache-name: cache-node-modules @@ -69,10 +88,12 @@ jobs: ${{ runner.os }}- - name: Get yarn cache directory path + if: ${{ steps.check-build.outputs.build == 'strawberry' }} id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn cache dir)" - uses: actions/cache@v2 + if: ${{ steps.check-build.outputs.build == 'strawberry' }} id: yarn-cache with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} @@ -81,6 +102,7 @@ jobs: ${{ runner.os }}-yarn- - name: Install Dependencies + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: bash ${GITHUB_WORKSPACE}/.github/helper/install_dependencies.sh env: BEFORE: ${{ env.GITHUB_EVENT_PATH.before }} @@ -88,13 +110,25 @@ jobs: TYPE: server - name: Install + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh env: DB: postgres TYPE: server - name: Run Tests - run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --use-orchestrator + if: ${{ steps.check-build.outputs.build == 'strawberry' }} + run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --use-orchestrator --with-coverage env: CI_BUILD_ID: ${{ github.run_id }} ORCHESTRATOR_URL: http://test-orchestrator.frappe.io + + - name: Upload coverage data + if: ${{ steps.check-build.outputs.build == 'strawberry' }} + uses: codecov/codecov-action@v2 + with: + name: Postgres + fail_ci_if_error: true + files: /home/runner/frappe-bench/sites/coverage.xml + verbose: true + flags: server diff --git a/.github/workflows/translation_linter.yml b/.github/workflows/translation_linter.yml deleted file mode 100644 index 4becaebd6b..0000000000 --- a/.github/workflows/translation_linter.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Frappe Linter -on: - pull_request: - branches: - - develop - - version-12-hotfix - - version-11-hotfix -jobs: - check_translation: - name: Translation Syntax Check - runs-on: ubuntu-18.04 - steps: - - uses: actions/checkout@v2 - - name: Setup python3 - uses: actions/setup-python@v1 - with: - python-version: 3.6 - - name: Validating Translation Syntax - run: | - git fetch origin $GITHUB_BASE_REF:$GITHUB_BASE_REF -q - files=$(git diff --name-only --diff-filter=d $GITHUB_BASE_REF) - python $GITHUB_WORKSPACE/.github/helper/translation.py $files diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index f2f43f10f8..fcc53ba59c 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -6,9 +6,13 @@ on: push: branches: [ develop ] +concurrency: + group: ui-develop-${{ github.event.number }} + cancel-in-progress: true + jobs: test: - runs-on: ubuntu-18.04 + runs-on: ubuntu-latest strategy: fail-fast: false @@ -33,19 +37,31 @@ jobs: - name: Setup Python uses: actions/setup-python@v2 with: - python-version: 3.7 + python-version: '3.9' + + - name: Check if build should be run + id: check-build + run: | + python "${GITHUB_WORKSPACE}/.github/helper/roulette.py" + env: + TYPE: "ui" + PR_NUMBER: ${{ github.event.number }} + REPO_NAME: ${{ github.repository }} - uses: actions/setup-node@v2 + if: ${{ steps.check-build.outputs.build == 'strawberry' }} with: node-version: 14 check-latest: true - name: Add to Hosts + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: | echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts echo "127.0.0.1 test_site_producer" | sudo tee -a /etc/hosts - name: Cache pip + if: ${{ steps.check-build.outputs.build == 'strawberry' }} uses: actions/cache@v2 with: path: ~/.cache/pip @@ -55,6 +71,7 @@ jobs: ${{ runner.os }}- - name: Cache node modules + if: ${{ steps.check-build.outputs.build == 'strawberry' }} uses: actions/cache@v2 env: cache-name: cache-node-modules @@ -67,10 +84,12 @@ jobs: ${{ runner.os }}- - name: Get yarn cache directory path + if: ${{ steps.check-build.outputs.build == 'strawberry' }} id: yarn-cache-dir-path run: echo "::set-output name=dir::$(yarn cache dir)" - uses: actions/cache@v2 + if: ${{ steps.check-build.outputs.build == 'strawberry' }} id: yarn-cache with: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} @@ -79,6 +98,7 @@ jobs: ${{ runner.os }}-yarn- - name: Cache cypress binary + if: ${{ steps.check-build.outputs.build == 'strawberry' }} uses: actions/cache@v2 with: path: ~/.cache @@ -88,6 +108,7 @@ jobs: ${{ runner.os }}- - name: Install Dependencies + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: bash ${GITHUB_WORKSPACE}/.github/helper/install_dependencies.sh env: BEFORE: ${{ env.GITHUB_EVENT_PATH.before }} @@ -95,13 +116,42 @@ jobs: TYPE: ui - name: Install + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh env: DB: mariadb TYPE: ui + - name: Instrument Source Code + if: ${{ steps.check-build.outputs.build == 'strawberry' }} + run: cd ~/frappe-bench/apps/frappe/ && npx nyc instrument -x 'frappe/public/dist/**' -x 'frappe/public/js/lib/**' -x '**/*.bundle.js' --compact=false --in-place frappe + + - name: Build + if: ${{ steps.check-build.outputs.build == 'strawberry' }} + run: cd ~/frappe-bench/ && bench build --apps frappe + - name: Site Setup + if: ${{ steps.check-build.outputs.build == 'strawberry' }} run: cd ~/frappe-bench/ && bench --site test_site execute frappe.utils.install.complete_setup_wizard - name: UI Tests - run: cd ~/frappe-bench/ && bench --site test_site run-ui-tests frappe --headless --parallel --ci-build-id $GITHUB_RUN_ID + if: ${{ steps.check-build.outputs.build == 'strawberry' }} + run: cd ~/frappe-bench/ && bench --site test_site run-ui-tests frappe --with-coverage --headless --parallel --ci-build-id $GITHUB_RUN_ID + env: + CYPRESS_RECORD_KEY: 4a48f41c-11b3-425b-aa88-c58048fa69eb + + - name: Check If Coverage Report Exists + id: check_coverage + uses: andstor/file-existence-action@v1 + with: + files: "/home/runner/frappe-bench/apps/frappe/.cypress-coverage/clover.xml" + + - name: Upload Coverage Data + if: ${{ steps.check-build.outputs.build == 'strawberry' && steps.check_coverage.outputs.files_exists == 'true' }} + uses: codecov/codecov-action@v2 + with: + name: Cypress + fail_ci_if_error: true + directory: /home/runner/frappe-bench/apps/frappe/.cypress-coverage/ + verbose: true + flags: ui-tests diff --git a/.gitignore b/.gitignore index 1ff3122d70..c9dd8f38f3 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,7 @@ coverage.xml *.cover .hypothesis/ .pytest_cache/ +.cypress-coverage # Translations *.mo diff --git a/.mergify.yml b/.mergify.yml index c759c1e3ec..0bd9641d5b 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -1,4 +1,20 @@ pull_request_rules: + - name: Auto-close PRs on stable branch + conditions: + - and: + - and: + - author!=surajshetty3416 + - author!=gavindsouza + - or: + - base=version-13 + - base=version-12 + actions: + close: + comment: + message: | + @{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on an appropriate hotfix branch. + https://github.com/frappe/erpnext/wiki/Pull-Request-Checklist#which-branch + - name: Automatic merge on CI success and review conditions: - status-success=Sider diff --git a/CODEOWNERS b/CODEOWNERS index 92723ab035..69ca578b6c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -4,16 +4,17 @@ # the repo. Unless a later match takes precedence, * @frappe/frappe-review-team -website/ @prssanna -web_form/ @prssanna templates/ @surajshetty3416 www/ @surajshetty3416 integrations/ @leela -patches/ @surajshetty3416 -dashboard/ @prssanna +patches/ @surajshetty3416 @gavindsouza email/ @leela event_streaming/ @ruchamahabal data_import* @netchampfaris core/ @surajshetty3416 +database @gavindsouza +model @gavindsouza requirements.txt @gavindsouza +query_builder/ @gavindsouza commands/ @gavindsouza +workspace @shariquerik diff --git a/LICENSE b/LICENSE index 5dfc0fd5bd..6919960f8b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License -Copyright (c) 2016-2018 Frappe Technologies Pvt. Ltd. +Copyright (c) 2016-2021 Frappe Technologies Pvt. Ltd. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 11343a632a..ef471aa05a 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,8 @@ - - + + @@ -35,25 +35,36 @@ Full-stack web application framework that uses Python and MariaDB on the server side and a tightly integrated client side library. Built for [ERPNext](https://erpnext.com) -### Table of Contents -* [Installation](https://frappeframework.com/docs/user/en/installation) -* [Documentation](https://frappeframework.com/docs) +
+ + + +
+ +## Table of Contents +* [Installation](#installation) +* [Contributing](#contributing) +* [Resources](#resources) * [License](#license) -### Installation +## Installation * [Install via Docker](https://github.com/frappe/frappe_docker) * [Install via Frappe Bench](https://github.com/frappe/bench) +* [Offical Documentation](https://frappeframework.com/docs/user/en/installation) +* [Managed Hosting on Frappe Cloud](https://frappecloud.com/deploy?apps=frappe&source=frappe_readme) ## Contributing +1. [Code of Conduct](CODE_OF_CONDUCT.md) 1. [Contribution Guidelines](https://github.com/frappe/erpnext/wiki/Contribution-Guidelines) +1. [Security Policy](SECURITY.md) 1. [Translations](https://translate.erpnext.com) -### Website +## Resources -For details and documentation, see the website -[https://frappeframework.com](https://frappeframework.com) +1. [frappeframework.com](https://frappeframework.com) - Official documentation of the Frappe Framework. +1. [frappe.school](https://frappe.school) - Pick from the various courses by the maintainers or from the community. -### License +## License This repository has been released under the [MIT License](LICENSE). diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000000..a9f6df0296 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,27 @@ +codecov: + require_ci_to_pass: yes + +coverage: + status: + patch: off + project: + default: false + server: + target: auto + threshold: 0.5% + flags: + - server + +comment: + layout: "diff, flags" + require_changes: true + +flags: + server: + paths: + - ".*\\.py" + carryforward: true + ui-tests: + paths: + - ".*\\.js" + carryforward: true diff --git a/cypress.json b/cypress.json index f2508ca66e..ae4495cfa8 100644 --- a/cypress.json +++ b/cypress.json @@ -4,6 +4,8 @@ "adminPassword": "admin", "defaultCommandTimeout": 20000, "pageLoadTimeout": 15000, + "video": true, + "videoUploadOnPasses": false, "retries": { "runMode": 2, "openMode": 2 diff --git a/cypress/fixtures/doctype_with_tab_break.js b/cypress/fixtures/doctype_with_tab_break.js new file mode 100644 index 0000000000..74e5e6abba --- /dev/null +++ b/cypress/fixtures/doctype_with_tab_break.js @@ -0,0 +1,54 @@ +export default { + name: 'Form With Tab Break', + custom: 1, + actions: [], + doctype: 'DocType', + engine: 'InnoDB', + fields: [ + { + fieldname: 'username', + fieldtype: 'Data', + label: 'Name', + options: 'Name' + }, + { + fieldname: 'tab', + fieldtype: 'Tab Break', + label: 'Tab 2', + }, + { + fieldname: 'Phone', + fieldtype: 'Data', + label: 'Phone', + options: 'Phone', + reqd: 1 + }, + ], + links: [ + { + "group": "Profile", + "link_doctype": "Contact", + "link_fieldname": "user" + }, + ], + modified_by: 'Administrator', + module: 'Custom', + owner: 'Administrator', + permissions: [ + { + create: 1, + delete: 1, + email: 1, + print: 1, + read: 1, + role: 'System Manager', + share: 1, + write: 1 + } + ], + quick_entry: 1, + autoname: "format: Test-{####}", + sort_field: 'modified', + sort_order: 'ASC', + track_changes: 1 +}; diff --git a/cypress/fixtures/sample_image.jpg b/cypress/fixtures/sample_image.jpg new file mode 100644 index 0000000000..6322b65e33 Binary files /dev/null and b/cypress/fixtures/sample_image.jpg differ diff --git a/cypress/integration/api.js b/cypress/integration/api.js index 7a5b1611b0..e8c39e6e25 100644 --- a/cypress/integration/api.js +++ b/cypress/integration/api.js @@ -31,8 +31,13 @@ context('API Resources', () => { }); it('Removes the Comments', () => { - cy.get_list('Comment').then(body => body.data.forEach(comment => { - cy.remove_doc('Comment', comment.name); - })); + cy.get_list('Comment').then(body => { + let comment_names = []; + body.data.map(comment => comment_names.push(comment.name)); + comment_names = [...new Set(comment_names)]; // remove duplicates + comment_names.forEach((comment_name) => { + cy.remove_doc('Comment', comment_name); + }); + }); }); }); diff --git a/cypress/integration/awesome_bar.js b/cypress/integration/awesome_bar.js index 3e12101532..8e503cce46 100644 --- a/cypress/integration/awesome_bar.js +++ b/cypress/integration/awesome_bar.js @@ -7,12 +7,13 @@ context('Awesome Bar', () => { beforeEach(() => { cy.get('.navbar .navbar-home').click(); + cy.findByPlaceholderText('Search or type a command (Ctrl + G)').clear(); }); it('navigates to doctype list', () => { - cy.get('#navbar-search').type('todo', { delay: 200 }); - cy.get('#navbar-search + ul').should('be.visible'); - cy.get('#navbar-search').type('{downarrow}{enter}', { delay: 100 }); + cy.findByPlaceholderText('Search or type a command (Ctrl + G)').type('todo', { delay: 700 }); + cy.get('.awesomplete').findByRole('listbox').should('be.visible'); + cy.findByPlaceholderText('Search or type a command (Ctrl + G)').type('{downarrow}{enter}', { delay: 700 }); cy.get('.title-text').should('contain', 'To Do'); @@ -20,25 +21,25 @@ context('Awesome Bar', () => { }); it('find text in doctype list', () => { - cy.get('#navbar-search') - .type('test in todo{downarrow}{enter}', { delay: 200 }); + cy.findByPlaceholderText('Search or type a command (Ctrl + G)') + .type('test in todo{downarrow}{enter}', { delay: 700 }); cy.get('.title-text').should('contain', 'To Do'); - cy.get('[data-original-title="Name"] > .input-with-feedback') + cy.findByPlaceholderText('Name') .should('have.value', '%test%'); }); it('navigates to new form', () => { - cy.get('#navbar-search') - .type('new blog post{downarrow}{enter}', { delay: 200 }); + cy.findByPlaceholderText('Search or type a command (Ctrl + G)') + .type('new blog post{downarrow}{enter}', { delay: 700 }); cy.get('.title-text:visible').should('have.text', 'New Blog Post'); }); it('calculates math expressions', () => { - cy.get('#navbar-search') - .type('55 + 32{downarrow}{enter}', { delay: 200 }); + cy.findByPlaceholderText('Search or type a command (Ctrl + G)') + .type('55 + 32{downarrow}{enter}', { delay: 700 }); cy.get('.modal-title').should('contain', 'Result'); cy.get('.msgprint').should('contain', '55 + 32 = 87'); diff --git a/cypress/integration/control_barcode.js b/cypress/integration/control_barcode.js index 1df5e64f0e..5f1ab86d41 100644 --- a/cypress/integration/control_barcode.js +++ b/cypress/integration/control_barcode.js @@ -20,7 +20,7 @@ context('Control Barcode', () => { it('should generate barcode on setting a value', () => { get_dialog_with_barcode().as('dialog'); - cy.get('.frappe-control[data-fieldname=barcode] input') + cy.get('.frappe-control[data-fieldname=barcode]').findByRole('textbox') .focus() .type('123456789') .blur(); @@ -37,11 +37,11 @@ context('Control Barcode', () => { it('should reset when input is cleared', () => { get_dialog_with_barcode().as('dialog'); - cy.get('.frappe-control[data-fieldname=barcode] input') + cy.get('.frappe-control[data-fieldname=barcode]').findByRole('textbox') .focus() .type('123456789') .blur(); - cy.get('.frappe-control[data-fieldname=barcode] input') + cy.get('.frappe-control[data-fieldname=barcode]').findByRole('textbox') .clear() .blur(); cy.get('.frappe-control[data-fieldname=barcode] svg[data-barcode-value="123456789"]') diff --git a/cypress/integration/control_duration.js b/cypress/integration/control_duration.js index 266d421e70..09629a344f 100644 --- a/cypress/integration/control_duration.js +++ b/cypress/integration/control_duration.js @@ -33,12 +33,13 @@ context('Control Duration', () => { cy.get('@dialog').then(dialog => { let value = dialog.get_value('duration'); expect(value).to.equal(3889800); + cy.hide_dialog(); }); }); it('should hide days or seconds according to duration options', () => { get_dialog_with_duration(1, 1).as('dialog'); - cy.get('.frappe-control[data-fieldname=duration] input').first().click(); + cy.get('.frappe-control[data-fieldname=duration] input').first(); cy.get('.duration-input[data-duration=days]').should('not.be.visible'); cy.get('.duration-input[data-duration=seconds]').should('not.be.visible'); }); diff --git a/cypress/integration/control_float.js b/cypress/integration/control_float.js new file mode 100644 index 0000000000..670d1fe73e --- /dev/null +++ b/cypress/integration/control_float.js @@ -0,0 +1,93 @@ +context("Control Float", () => { + before(() => { + cy.login(); + cy.visit("/app/website"); + }); + + function get_dialog_with_float() { + return cy.dialog({ + title: "Float Check", + fields: [ + { + fieldname: "float_number", + fieldtype: "Float", + Label: "Float" + } + ] + }); + } + + it("check value changes", () => { + get_dialog_with_float().as("dialog"); + + let data = get_data(); + data.forEach(x => { + cy.window() + .its("frappe") + .then(frappe => { + frappe.boot.sysdefaults.number_format = x.number_format; + }); + x.values.forEach(d => { + cy.get_field("float_number", "Float").clear(); + cy.fill_field("float_number", d.input, "Float").blur(); + cy.get_field("float_number", "Float").should( + "have.value", + d.blur_expected + ); + + cy.get_field("float_number", "Float").focus(); + cy.get_field("float_number", "Float").blur(); + cy.get_field("float_number", "Float").focus(); + cy.get_field("float_number", "Float").should( + "have.value", + d.focus_expected + ); + }); + }); + }); + + function get_data() { + return [ + { + number_format: "#.###,##", + values: [ + { + input: "364.87,334", + blur_expected: "36.487,334", + focus_expected: "36487.334" + }, + { + input: "36487,334", + blur_expected: "36.487,334", + focus_expected: "36487.334" + }, + { + input: "100", + blur_expected: "100,000", + focus_expected: "100" + } + ] + }, + { + number_format: "#,###.##", + values: [ + { + input: "364,87.334", + blur_expected: "36,487.334", + focus_expected: "36487.334" + }, + { + input: "36487.334", + blur_expected: "36,487.334", + focus_expected: "36487.334" + }, + { + input: "100", + blur_expected: "100.000", + focus_expected: "100" + } + ] + } + ]; + } +}); diff --git a/cypress/integration/control_icon.js b/cypress/integration/control_icon.js new file mode 100644 index 0000000000..5c531a0823 --- /dev/null +++ b/cypress/integration/control_icon.js @@ -0,0 +1,50 @@ +context('Control Icon', () => { + before(() => { + cy.login(); + cy.visit('/app/website'); + }); + + function get_dialog_with_icon() { + return cy.dialog({ + title: 'Icon', + fields: [{ + label: 'Icon', + fieldname: 'icon', + fieldtype: 'Icon' + }] + }); + } + + it('should set icon', () => { + get_dialog_with_icon().as('dialog'); + cy.get('.frappe-control[data-fieldname=icon]').findByRole('textbox').click(); + + cy.get('.icon-picker .icon-wrapper[id=active]').first().click(); + cy.get('.frappe-control[data-fieldname=icon]').findByRole('textbox').should('have.value', 'active'); + cy.get('@dialog').then(dialog => { + let value = dialog.get_value('icon'); + expect(value).to.equal('active'); + }); + + cy.get('.icon-picker .icon-wrapper[id=resting]').first().click(); + cy.get('.frappe-control[data-fieldname=icon]').findByRole('textbox').should('have.value', 'resting'); + cy.get('@dialog').then(dialog => { + let value = dialog.get_value('icon'); + expect(value).to.equal('resting'); + }); + }); + + it('search for icon and clear search input', () => { + let search_text = 'ed'; + cy.get('.icon-picker').findByRole('searchbox').click().type(search_text); + cy.get('.icon-section .icon-wrapper:not(.hidden)').then(i => { + cy.get(`.icon-section .icon-wrapper[id*='${search_text}']`).then(icons => { + expect(i.length).to.equal(icons.length); + }); + }); + + cy.get('.icon-picker').findByRole('searchbox').clear().blur(); + cy.get('.icon-section .icon-wrapper').should('not.have.class', 'hidden'); + }); + +}); \ No newline at end of file diff --git a/cypress/integration/control_link.js b/cypress/integration/control_link.js index 8f9257e9c4..6d16769b37 100644 --- a/cypress/integration/control_link.js +++ b/cypress/integration/control_link.js @@ -35,7 +35,7 @@ context('Control Link', () => { cy.wait('@search_link'); cy.get('@input').type('todo for link', { delay: 200 }); cy.wait('@search_link'); - cy.get('.frappe-control[data-fieldname=link] ul').should('be.visible'); + cy.get('.frappe-control[data-fieldname=link]').findByRole('listbox').should('be.visible'); cy.get('.frappe-control[data-fieldname=link] input').type('{enter}', { delay: 100 }); cy.get('.frappe-control[data-fieldname=link] input').blur(); cy.get('@dialog').then(dialog => { @@ -49,7 +49,7 @@ context('Control Link', () => { it('should unset invalid value', () => { get_dialog_with_link().as('dialog'); - cy.intercept('GET', '/api/method/frappe.desk.form.utils.validate_link*').as('validate_link'); + cy.intercept('POST', '/api/method/frappe.client.validate_link').as('validate_link'); cy.get('.frappe-control[data-fieldname=link] input') .type('invalid value', { delay: 100 }) @@ -61,7 +61,7 @@ context('Control Link', () => { it('should route to form on arrow click', () => { get_dialog_with_link().as('dialog'); - cy.intercept('GET', '/api/method/frappe.desk.form.utils.validate_link*').as('validate_link'); + cy.intercept('POST', '/api/method/frappe.client.validate_link').as('validate_link'); cy.intercept('POST', '/api/method/frappe.desk.search.search_link').as('search_link'); cy.get('@todos').then(todos => { @@ -71,10 +71,25 @@ context('Control Link', () => { cy.get('@input').type(todos[0]).blur(); cy.wait('@validate_link'); cy.get('@input').focus(); - cy.get('.frappe-control[data-fieldname=link] .link-btn') + cy.findByTitle('Open Link') .should('be.visible') .click(); cy.location('pathname').should('eq', `/app/todo/${todos[0]}`); }); }); + + it('should fetch valid value', () => { + cy.get('@todos').then(todos => { + cy.visit(`/app/todo/${todos[0]}`); + cy.intercept('POST', '/api/method/frappe.client.validate_link').as('validate_link'); + + cy.get('.frappe-control[data-fieldname=assigned_by] input').focus().as('input'); + cy.get('@input').type('Administrator', {delay: 100}).blur(); + cy.wait('@validate_link'); + cy.get('.frappe-control[data-fieldname=assigned_by_full_name] .control-value').should( + 'contain', 'Administrator' + ); + }); + }); + }); diff --git a/cypress/integration/control_select.js b/cypress/integration/control_select.js index 0bc719b4a7..8e18d21260 100644 --- a/cypress/integration/control_select.js +++ b/cypress/integration/control_select.js @@ -24,8 +24,10 @@ context('Control Select', () => { cy.get('@control').get('.select-icon').should('exist'); cy.get('@control').get('.placeholder').should('have.css', 'display', 'block'); cy.get('@select').select('Option 1'); + cy.findByDisplayValue('Option 1').should('exist'); cy.get('@control').get('.placeholder').should('have.css', 'display', 'none'); cy.get('@select').invoke('val', ''); + cy.findByDisplayValue('Option 1').should('not.exist'); cy.get('@control').get('.placeholder').should('have.css', 'display', 'block'); diff --git a/cypress/integration/dashboard_chart.js b/cypress/integration/dashboard_chart.js new file mode 100644 index 0000000000..ae71fcda3a --- /dev/null +++ b/cypress/integration/dashboard_chart.js @@ -0,0 +1,22 @@ +context('Dashboard Chart', () => { + before(() => { + cy.login(); + cy.visit('/app/website'); + }); + + it('Check filter populate for child table doctype', () => { + cy.visit('/app/dashboard-chart/new-dashboard-chart-1'); + cy.get('[data-fieldname="parent_document_type"]').should('have.css', 'display', 'none'); + + cy.get_field('document_type', 'Link'); + cy.fill_field('document_type', 'Workspace Link', 'Link').focus().blur(); + cy.get_field('document_type', 'Link').should('have.value', 'Workspace Link'); + + cy.fill_field('chart_name', 'Test Chart', 'Data'); + + cy.get('[data-fieldname="filters_json"]').click().wait(200); + cy.get('.modal-body .filter-action-buttons .add-filter').click(); + cy.get('.modal-body .fieldname-select-area').click(); + cy.get('.modal-actions .btn-modal-close').click(); + }); +}); \ No newline at end of file diff --git a/cypress/integration/dashboard_links.js b/cypress/integration/dashboard_links.js new file mode 100644 index 0000000000..16ffd41cf4 --- /dev/null +++ b/cypress/integration/dashboard_links.js @@ -0,0 +1,65 @@ +context('Dashboard links', () => { + before(() => { + cy.visit('/login'); + cy.login(); + }); + + it('Adding a new contact, checking for the counter on the dashboard and deleting the created contact', () => { + cy.visit('/app/contact'); + cy.clear_filters(); + + cy.visit('/app/user'); + cy.get('.list-row-col > .level-item > .ellipsis').eq(0).click({ force: true }); + + //To check if initially the dashboard contains only the "Contact" link and there is no counter + cy.get('[data-doctype="Contact"]').should('contain', 'Contact'); + + //Adding a new contact + cy.get('.document-link-badge[data-doctype="Contact"]').click(); + cy.wait(300); + cy.findByRole('button', {name: 'Add Contact'}).should('be.visible'); + cy.findByRole('button', {name: 'Add Contact'}).click(); + cy.get('[data-doctype="Contact"][data-fieldname="first_name"]').type('Admin'); + cy.findByRole('button', {name: 'Save'}).click(); + cy.visit('/app/user'); + cy.get('.list-row-col > .level-item > .ellipsis').eq(0).click({ force: true }); + + //To check if the counter for contact doc is "1" after adding the contact + cy.get('[data-doctype="Contact"] > .count').should('contain', '1'); + cy.get('[data-doctype="Contact"]').contains('Contact').click(); + + //Deleting the newly created contact + cy.visit('/app/contact'); + cy.get('.list-subject > .select-like > .list-row-checkbox').eq(0).click({ force: true }); + cy.findByRole('button', {name: 'Actions'}).click(); + cy.get('.actions-btn-group [data-label="Delete"]').click(); + cy.findByRole('button', {name: 'Yes'}).click({delay: 700}); + + + //To check if the counter from the "Contact" doc link is removed + cy.wait(700); + cy.visit('/app/user'); + cy.get('.list-row-col > .level-item > .ellipsis').eq(0).click({ force: true }); + cy.get('[data-doctype="Contact"]').should('contain', 'Contact'); + }); + + it('Report link in dashboard', () => { + cy.visit('/app/user'); + cy.visit('/app/user/Administrator'); + cy.get('[data-doctype="Contact"]').should('contain', 'Contact'); + cy.findByText('Connections'); + cy.window() + .its('cur_frm') + .then(cur_frm => { + cur_frm.dashboard.data.reports = [ + { + 'label': 'Reports', + 'items': ['Website Analytics'] + } + ]; + cur_frm.dashboard.render_report_links(); + cy.get('[data-report="Website Analytics"]').contains('Website Analytics').click(); + cy.findByText('Website Analytics'); + }); + }); +}); diff --git a/cypress/integration/datetime_field_form_validation.js b/cypress/integration/datetime_field_form_validation.js new file mode 100644 index 0000000000..ef47a0fbf7 --- /dev/null +++ b/cypress/integration/datetime_field_form_validation.js @@ -0,0 +1,19 @@ +// TODO: Enable this again +// currently this is flaky possibly because of different timezone in CI + +// context('Datetime Field Validation', () => { +// before(() => { +// cy.login(); +// cy.visit('/app/communication'); +// }); + +// it('datetime field form validation', () => { +// // validating datetime field value when value is set from backend and get validated on form load. +// cy.window().its('frappe').then(frappe => { +// return frappe.xcall("frappe.tests.ui_test_helpers.create_communication_record"); +// }).then(doc => { +// cy.visit(`/app/communication/${doc.name}`); +// cy.get('.indicator-pill').should('contain', 'Open').should('have.class', 'red'); +// }); +// }); +// }); \ No newline at end of file diff --git a/cypress/integration/depends_on.js b/cypress/integration/depends_on.js index d33babb134..9aa6b5d89d 100644 --- a/cypress/integration/depends_on.js +++ b/cypress/integration/depends_on.js @@ -62,11 +62,11 @@ context('Depends On', () => { it('should set the field as mandatory depending on other fields value', () => { cy.new_form('Test Depends On'); cy.fill_field('test_field', 'Some Value'); - cy.get('button.primary-action').contains('Save').click(); + cy.findByRole('button', {name: 'Save'}).click(); cy.get('.msgprint-dialog .modal-title').contains('Missing Fields').should('be.visible'); cy.hide_dialog(); cy.fill_field('test_field', 'Random value'); - cy.get('button.primary-action').contains('Save').click(); + cy.findByRole('button', {name: 'Save'}).click(); cy.get('.msgprint-dialog .modal-title').contains('Missing Fields').should('not.be.visible'); }); it('should set the field as read only depending on other fields value', () => { @@ -84,7 +84,7 @@ context('Depends On', () => { cy.fill_field('dependant_field', 'Some Value'); //cy.fill_field('test_field', 'Some Other Value'); cy.get('.frappe-control[data-fieldname="child_test_depends_on_field"]').as('table'); - cy.get('@table').find('button.grid-add-row').click(); + cy.get('@table').findByRole('button', {name: 'Add Row'}).click(); cy.get('@table').find('[data-idx="1"]').as('row1'); cy.get('@row1').find('.btn-open-row').click(); cy.get('@row1').find('.form-in-grid').as('row1-form_in_grid'); diff --git a/cypress/integration/discussions.js b/cypress/integration/discussions.js new file mode 100644 index 0000000000..a6e0ff9b56 --- /dev/null +++ b/cypress/integration/discussions.js @@ -0,0 +1,79 @@ +context('Discussions', () => { + before(() => { + cy.login(); + cy.visit('/app'); + return cy.window().its('frappe').then(frappe => { + return frappe.call('frappe.tests.ui_test_helpers.create_data_for_discussions'); + }); + }); + + const reply_through_modal = () => { + cy.visit('/test-page-discussions'); + + // Open the modal + cy.get('.reply').click(); + cy.wait(500); + cy.get('.discussion-modal').should('be.visible'); + + // Enter title + cy.get('.modal .topic-title').type('Discussion from tests') + .should('have.value', 'Discussion from tests'); + + // Enter comment + cy.get('.modal .comment-field') + .type('This is a discussion from the cypress ui tests.') + .should('have.value', 'This is a discussion from the cypress ui tests.'); + + // Submit + cy.get('.modal .submit-discussion').click(); + cy.wait(2000); + + // Check if discussion is added to page and content is visible + cy.get('.sidebar-parent:first .discussion-topic-title').should('have.text', 'Discussion from tests'); + cy.get('.discussion-on-page:visible').should('have.class', 'show'); + cy.get('.discussion-on-page:visible .reply-card .reply-text') + .should('have.text', 'This is a discussion from the cypress ui tests.\n'); + + }; + + const reply_through_comment_box = () => { + cy.get('.discussion-on-page:visible .comment-field') + .type('This is a discussion from the cypress ui tests. \n\nThis comment was entered through the commentbox on the page.') + .should('have.value', 'This is a discussion from the cypress ui tests. \n\nThis comment was entered through the commentbox on the page.'); + + cy.get('.discussion-on-page:visible .submit-discussion').click(); + cy.wait(3000); + cy.get('.discussion-on-page:visible').should('have.class', 'show'); + cy.get('.discussion-on-page:visible').children(".reply-card").eq(1).children(".reply-text") + .should('have.text', 'This is a discussion from the cypress ui tests. \n\nThis comment was entered through the commentbox on the page.\n'); + }; + + const cancel_and_clear_comment_box = () => { + cy.get('.discussion-on-page:visible .comment-field') + .type('This is a discussion from the cypress ui tests.') + .should('have.value', 'This is a discussion from the cypress ui tests.'); + + cy.get('.discussion-on-page:visible .cancel-comment').click(); + cy.get('.discussion-on-page:visible .comment-field').should('have.value', ''); + }; + + const single_thread_discussion = () => { + cy.visit('/test-single-thread'); + cy.get('.discussions-sidebar').should('have.length', 0); + cy.get('.reply').should('have.length', 0); + + cy.get('.discussion-on-page .comment-field') + .type('This comment is being made on a single thread discussion.') + .should('have.value', 'This comment is being made on a single thread discussion.'); + + cy.get('.discussion-on-page .submit-discussion').click(); + cy.wait(3000); + cy.get('.discussion-on-page').children(".reply-card").eq(-1).children(".reply-text") + .should('have.text', 'This comment is being made on a single thread discussion.\n'); + }; + + it('reply through modal', reply_through_modal); + it('reply through comment box', reply_through_comment_box); + it('cancel and clear comment box', cancel_and_clear_comment_box); + it('single thread discussion', single_thread_discussion); +}); diff --git a/cypress/integration/file_uploader.js b/cypress/integration/file_uploader.js index 2f457983de..3d4f92df3c 100644 --- a/cypress/integration/file_uploader.js +++ b/cypress/integration/file_uploader.js @@ -25,7 +25,7 @@ context('FileUploader', () => { cy.get_open_dialog().find('.file-name').should('contain', 'example.json'); cy.intercept('POST', '/api/method/upload_file').as('upload_file'); - cy.get_open_dialog().find('.btn-modal-primary').click(); + cy.get_open_dialog().findByRole('button', {name: 'Upload'}).click(); cy.wait('@upload_file').its('response.statusCode').should('eq', 200); cy.get('.modal:visible').should('not.exist'); }); @@ -33,11 +33,11 @@ context('FileUploader', () => { it('should accept uploaded files', () => { open_upload_dialog(); - cy.get_open_dialog().find('.btn-file-upload div:contains("Library")').click(); - cy.get('.file-filter').type('example.json'); - cy.get_open_dialog().find('.tree-label:contains("example.json")').first().click(); + cy.get_open_dialog().findByRole('button', {name: 'Library'}).click(); + cy.findByPlaceholderText('Search by filename or extension').type('example.json'); + cy.get_open_dialog().findAllByText('example.json').first().click(); cy.intercept('POST', '/api/method/upload_file').as('upload_file'); - cy.get_open_dialog().find('.btn-primary').click(); + cy.get_open_dialog().findByRole('button', {name: 'Upload'}).click(); cy.wait('@upload_file').its('response.body.message') .should('have.property', 'file_name', 'example.json'); cy.get('.modal:visible').should('not.exist'); @@ -46,12 +46,33 @@ context('FileUploader', () => { it('should accept web links', () => { open_upload_dialog(); - cy.get_open_dialog().find('.btn-file-upload div:contains("Link")').click(); - cy.get_open_dialog().find('.file-web-link input').type('https://github.com', { delay: 100, force: true }); + cy.get_open_dialog().findByRole('button', {name: 'Link'}).click(); + cy.get_open_dialog() + .findByPlaceholderText('Attach a web link') + .type('https://github.com', { delay: 100, force: true }); cy.intercept('POST', '/api/method/upload_file').as('upload_file'); - cy.get_open_dialog().find('.btn-primary').click(); + cy.get_open_dialog().findByRole('button', {name: 'Upload'}).click(); cy.wait('@upload_file').its('response.body.message') .should('have.property', 'file_url', 'https://github.com'); cy.get('.modal:visible').should('not.exist'); }); + + it('should allow cropping and optimization for valid images', () => { + open_upload_dialog(); + + cy.get_open_dialog().find('.file-upload-area').attachFile('sample_image.jpg', { + subjectType: 'drag-n-drop', + }); + + cy.get_open_dialog().findAllByText('sample_image.jpg').should('exist'); + cy.get_open_dialog().find('.btn-crop').first().click(); + cy.get_open_dialog().findByRole('button', {name: 'Crop'}).click(); + cy.get_open_dialog().findAllByRole('checkbox', {name: 'Optimize'}).should('exist'); + cy.get_open_dialog().findAllByLabelText('Optimize').first().click(); + + cy.intercept('POST', '/api/method/upload_file').as('upload_file'); + cy.get_open_dialog().findByRole('button', {name: 'Upload'}).click(); + cy.wait('@upload_file').its('response.statusCode').should('eq', 200); + cy.get('.modal:visible').should('not.exist'); + }); }); diff --git a/cypress/integration/folder_navigation.js b/cypress/integration/folder_navigation.js new file mode 100644 index 0000000000..cec7edb59f --- /dev/null +++ b/cypress/integration/folder_navigation.js @@ -0,0 +1,79 @@ +context('Folder Navigation', () => { + before(() => { + cy.visit('/login'); + cy.login(); + cy.visit('/app/file'); + }); + + it('Adding Folders', () => { + //Adding filter to go into the home folder + cy.get('.filter-selector > .btn').findByText('1 filter').click(); + cy.findByRole('button', {name: 'Clear Filters'}).click(); + cy.get('.filter-action-buttons > .text-muted').findByText('+ Add a Filter').click(); + cy.get('.fieldname-select-area > .awesomplete > .form-control').type('Fol{enter}'); + cy.get('.filter-field > .form-group > .link-field > .awesomplete > .input-with-feedback').type('Home{enter}'); + cy.get('.filter-action-buttons > div > .btn-primary').findByText('Apply Filters').click(); + + //Adding folder (Test Folder) + cy.get('.menu-btn-group > .btn').click(); + cy.get('.menu-btn-group [data-label="New Folder"]').click(); + cy.get('form > [data-fieldname="value"]').type('Test Folder'); + cy.findByRole('button', {name: 'Create'}).click(); + }); + + it('Navigating the nested folders, checking if the URL formed is correct, checking if the added content in the child folder is correct', () => { + //Navigating inside the Attachments folder + cy.get('[title="Attachments"] > span').click(); + + //To check if the URL formed after visiting the attachments folder is correct + cy.location('pathname').should('eq', '/app/file/view/home/Attachments'); + cy.visit('/app/file/view/home/Attachments'); + + //Adding folder inside the attachments folder + cy.get('.menu-btn-group > .btn').click(); + cy.get('.menu-btn-group [data-label="New Folder"]').click(); + cy.get('form > [data-fieldname="value"]').type('Test Folder'); + cy.findByRole('button', {name: 'Create'}).click(); + + //Navigating inside the added folder in the Attachments folder + cy.get('[title="Test Folder"] > span').click(); + + //To check if the URL is correct after visiting the Test Folder + cy.location('pathname').should('eq', '/app/file/view/home/Attachments/Test%20Folder'); + cy.visit('/app/file/view/home/Attachments/Test%20Folder'); + + //Adding a file inside the Test Folder + cy.findByRole('button', {name: 'Add File'}).eq(0).click({force: true}); + cy.get('.file-uploader').findByText('Link').click(); + cy.get('.input-group > .form-control').type('https://wallpaperplay.com/walls/full/8/2/b/72402.jpg'); + cy.findByRole('button', {name: 'Upload'}).click(); + + //To check if the added file is present in the Test Folder + cy.get('span.level-item > span').should('contain', 'Test Folder'); + cy.get('.list-row-container').eq(0).should('contain.text', '72402.jpg'); + cy.get('.list-row-checkbox').eq(0).click(); + + //Deleting the added file from the Test folder + cy.findByRole('button', {name: 'Actions'}).click(); + cy.get('.actions-btn-group [data-label="Delete"]').click(); + cy.wait(700); + cy.findByRole('button', {name: 'Yes'}).click(); + cy.wait(700); + + //Deleting the Test Folder + cy.visit('/app/file/view/home/Attachments'); + cy.get('.list-row-checkbox').eq(0).click(); + cy.findByRole('button', {name: 'Actions'}).click(); + cy.get('.actions-btn-group [data-label="Delete"]').click(); + cy.findByRole('button', {name: 'Yes'}).click(); + }); + + it('Deleting Test Folder from the home', () => { + //Deleting the Test Folder added in the home directory + cy.visit('/app/file/view/home'); + cy.get('.level-left > .list-subject > .file-select >.list-row-checkbox').eq(0).click({force: true, delay: 500}); + cy.findByRole('button', {name: 'Actions'}).click(); + cy.get('.actions-btn-group [data-label="Delete"]').click(); + cy.findByRole('button', {name: 'Yes'}).click(); + }); +}); diff --git a/cypress/integration/form.js b/cypress/integration/form.js index 909955c1df..71cc6f4f0d 100644 --- a/cypress/integration/form.js +++ b/cypress/integration/form.js @@ -8,8 +8,7 @@ context('Form', () => { }); it('create a new form', () => { cy.visit('/app/todo/new'); - cy.fill_field('description', 'this is a test todo', 'Text Editor'); - cy.wait(300); + cy.get_field('description', 'Text Editor').type('this is a test todo', {force: true}).wait(200); cy.get('.page-title').should('contain', 'Not Saved'); cy.intercept({ method: 'POST', @@ -17,29 +16,34 @@ context('Form', () => { }).as('form_save'); cy.get('.primary-action').click(); cy.wait('@form_save').its('response.statusCode').should('eq', 200); + cy.visit('/app/todo'); - cy.wait(300); - cy.get('.title-text').should('be.visible').and('contain', 'To Do'); + cy.get('.page-head').findByTitle('To Do').should('exist'); cy.get('.list-row').should('contain', 'this is a test todo'); }); + it('navigates between documents with child table list filters applied', () => { cy.visit('/app/contact'); - cy.add_filter(); - cy.get('.filter-field .input-with-feedback.form-control').type('123', { force: true }); - cy.get('.filter-popover .apply-filters').click({ force: true }); - cy.visit('/app/contact/Test Form Contact 3'); + + cy.clear_filters(); + cy.get('.standard-filter-section [data-fieldname="name"] input').type('Test Form Contact 3').blur(); + cy.click_listview_row_item(0); + cy.get('.prev-doc').should('be.visible').click(); cy.get('.msgprint-dialog .modal-body').contains('No further records').should('be.visible'); cy.hide_dialog(); - cy.get('.next-doc').click(); - cy.wait(200); + + cy.get('.next-doc').should('be.visible').click(); + cy.get('.msgprint-dialog .modal-body').contains('No further records').should('be.visible'); cy.hide_dialog(); - cy.contains('Test Form Contact 2').should('not.exist'); - cy.get('.title-text').should('contain', 'Test Form Contact 3'); + + cy.get('#page-Contact .page-head').findByTitle('Test Form Contact 3').should('exist'); + // 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'; diff --git a/cypress/integration/form_tab_break.js b/cypress/integration/form_tab_break.js new file mode 100644 index 0000000000..45c3c92084 --- /dev/null +++ b/cypress/integration/form_tab_break.js @@ -0,0 +1,31 @@ +import doctype_with_tab_break from '../fixtures/doctype_with_tab_break'; +const doctype_name = doctype_with_tab_break.name; +context("Form Tab Break", () => { + before(() => { + cy.login(); + cy.visit('/app/website'); + return cy.insert_doc('DocType', doctype_with_tab_break, true); + }); + it("Should switch tab and open correct tabs on validation error", () => { + cy.new_form(doctype_name); + // test tab switch + cy.findByRole("tab", {name: "Tab 2"}).click(); + cy.findByText("Phone"); + cy.findByRole("tab", {name: "Details"}).click(); + cy.findByText("Name"); + + // form should switch to the tab with un-filled mandatory field + cy.fill_field("username", "Test"); + cy.findByRole("button", {name: "Save"}).click(); + cy.findByText("Missing Fields"); + cy.hide_dialog(); + cy.findByText("Phone"); + cy.fill_field("phone", "12345678"); + cy.findByRole("button", {name: "Save"}).click(); + + // After save, first tab should have dashboard + cy.get(".form-tabs > .nav-item").eq(0).click(); + cy.findByText("Connections"); + + }); +}); \ No newline at end of file diff --git a/cypress/integration/form_tour.js b/cypress/integration/form_tour.js new file mode 100644 index 0000000000..ab7ada9034 --- /dev/null +++ b/cypress/integration/form_tour.js @@ -0,0 +1,88 @@ +context('Form Tour', () => { + before(() => { + cy.login(); + cy.visit('/app/form-tour'); + return cy.window().its('frappe').then(frappe => { + return frappe.call("frappe.tests.ui_test_helpers.create_form_tour"); + }); + }); + + const open_test_form_tour = () => { + cy.visit('/app/form-tour/Test Form Tour'); + cy.findByRole('button', {name: 'Show Tour'}).should('be.visible').as('show_tour'); + cy.get('@show_tour').click(); + cy.wait(500); + cy.url().should('include', '/app/contact'); + }; + + it('jump to a form tour', open_test_form_tour); + + it('navigates a form tour', () => { + open_test_form_tour(); + + cy.get('.frappe-driver').should('be.visible'); + cy.get('.frappe-control[data-fieldname="first_name"]').as('first_name'); + cy.get('@first_name').should('have.class', 'driver-highlighted-element'); + cy.get('.frappe-driver').findByRole('button', {name: 'Next'}).as('next_btn'); + + // next btn shouldn't move to next step, if first name is not entered + cy.get('@next_btn').click(); + cy.wait(500); + cy.get('@first_name').should('have.class', 'driver-highlighted-element'); + + // after filling the field, next step should be highlighted + cy.fill_field('first_name', 'Test Name', 'Data'); + cy.wait(500); + cy.get('@next_btn').click(); + cy.wait(500); + + // assert field is highlighted + cy.get('.frappe-control[data-fieldname="last_name"]').as('last_name'); + cy.get('@last_name').should('have.class', 'driver-highlighted-element'); + + // after filling the field, next step should be highlighted + cy.fill_field('last_name', 'Test Last Name', 'Data'); + cy.wait(500); + cy.get('@next_btn').click(); + cy.wait(500); + + // assert field is highlighted + cy.get('.frappe-control[data-fieldname="phone_nos"]').as('phone_nos'); + cy.get('@phone_nos').should('have.class', 'driver-highlighted-element'); + + // move to next step + cy.wait(500); + cy.get('@next_btn').click(); + cy.wait(500); + + // assert add row btn is highlighted + cy.get('@phone_nos').find('.grid-add-row').as('add_row'); + cy.get('@add_row').should('have.class', 'driver-highlighted-element'); + + // add a row & move to next step + cy.wait(500); + cy.get('@add_row').click(); + cy.wait(500); + + // assert table field is highlighted + cy.get('.grid-row-open .frappe-control[data-fieldname="phone"]').as('phone'); + cy.get('@phone').should('have.class', 'driver-highlighted-element'); + // enter value in a table field + let field = cy.fill_table_field('phone_nos', '1', 'phone', '1234567890'); + field.blur(); + + // move to collapse row step + cy.wait(500); + cy.get('.driver-popover-title').contains('Test Title 4').siblings().get('@next_btn').click(); + cy.wait(500); + // collapse row + cy.get('.grid-row-open .grid-collapse-row').click(); + cy.wait(500); + + // assert save btn is highlighted + cy.get('.primary-action').should('have.class', 'driver-highlighted-element'); + cy.wait(500); + cy.get('.frappe-driver').findByRole('button', {name: 'Save'}).should('be.visible'); + + }); +}); diff --git a/cypress/integration/grid_configuration.js b/cypress/integration/grid_configuration.js new file mode 100644 index 0000000000..7193d804c2 --- /dev/null +++ b/cypress/integration/grid_configuration.js @@ -0,0 +1,23 @@ +context('Grid Configuration', () => { + beforeEach(() => { + cy.login(); + cy.visit('/app/doctype/User'); + }); + it('Set user wise grid settings', () => { + cy.wait(100); + cy.get('.frappe-control[data-fieldname="fields"]').as('table'); + cy.get('@table').find('.icon-sm').click(); + cy.wait(100); + cy.get('.frappe-control[data-fieldname="fields_html"]').as('modal'); + cy.get('@modal').find('.add-new-fields').click(); + cy.wait(100); + cy.get('[type="checkbox"][data-unit="read_only"]').check(); + cy.findByRole('button', {name: 'Add'}).click(); + cy.wait(100); + cy.get('[data-fieldname="options"]').invoke('attr', 'value', '1'); + cy.get('.form-control.column-width[data-fieldname="options"]').trigger('change'); + cy.findByRole('button', {name: 'Update'}).click(); + cy.wait(200); + cy.get('[title="Read Only"').should('be.visible'); + }); +}); \ No newline at end of file diff --git a/cypress/integration/grid_pagination.js b/cypress/integration/grid_pagination.js index 8f6b79c1f4..c07230d2b8 100644 --- a/cypress/integration/grid_pagination.js +++ b/cypress/integration/grid_pagination.js @@ -30,12 +30,12 @@ context('Grid Pagination', () => { it('adds and deletes rows and changes page', () => { cy.visit('/app/contact/Test Contact'); cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); - cy.get('@table').find('button.grid-add-row').click(); + cy.get('@table').findByRole('button', {name: 'Add Row'}).click(); cy.get('@table').find('.grid-body .row-index').should('contain', 1001); cy.get('@table').find('.current-page-number').should('contain', '21'); cy.get('@table').find('.total-page-number').should('contain', '21'); cy.get('@table').find('.grid-body .grid-row .grid-row-check').click({ force: true }); - cy.get('@table').find('button.grid-remove-rows').click(); + cy.get('@table').findByRole('button', {name: 'Delete'}).click(); cy.get('@table').find('.grid-body .row-index').last().should('contain', 1000); cy.get('@table').find('.current-page-number').should('contain', '20'); cy.get('@table').find('.total-page-number').should('contain', '20'); diff --git a/cypress/integration/list_view.js b/cypress/integration/list_view.js index 633d1335ab..7791bef8f5 100644 --- a/cypress/integration/list_view.js +++ b/cypress/integration/list_view.js @@ -6,12 +6,28 @@ context('List View', () => { return frappe.xcall("frappe.tests.ui_test_helpers.setup_workflow"); }); }); + + it('Keep checkbox checked after Bulk Update', () => { + cy.go_to_list('ToDo'); + cy.get('.list-row-container .list-row-checkbox').click({ multiple: true, force: true }); + cy.get('.actions-btn-group button').contains('Actions').should('be.visible').click(); + cy.get('.dropdown-menu li:visible .dropdown-item .menu-item-label[data-label="Edit"]').click(); + + cy.get('.modal-body .form-control[data-fieldname="field"]').first().select('Priority').wait(200); + + cy.get('.modal-footer .standard-actions .btn-primary').click(); + cy.wait(500); + + cy.get('.actions-btn-group button').contains('Actions').should('be.visible').click(); + cy.get('.list-row-container .list-row-checkbox:checked').should('be.visible'); + }); + it('enables "Actions" button', () => { - const actions = ['Approve', 'Reject', 'Edit', 'Assign To', 'Apply Assignment Rule', 'Add Tags', 'Print', 'Delete']; + const actions = ['Approve', 'Reject', 'Edit', 'Export', '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('.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.get('.dropdown-menu li:visible .dropdown-item').should('have.length', 9).each((el, index) => { cy.wrap(el).contains(actions[index]); }).then((elements) => { cy.intercept({ @@ -24,10 +40,11 @@ context('List View', () => { }).as('real-time-update'); cy.wrap(elements).contains('Approve').click(); cy.wait(['@bulk-approval', '@real-time-update']); - cy.hide_dialog(); + cy.wait(300); + cy.get_open_dialog().find('.btn-modal-close').click(); + cy.reload(); 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 52512b911e..61d4b8aae5 100644 --- a/cypress/integration/list_view_settings.js +++ b/cypress/integration/list_view_settings.js @@ -17,9 +17,9 @@ context('List View Settings', () => { cy.get('.dropdown-menu li').filter(':visible').contains('List Settings').click(); cy.get('.modal-dialog').should('contain', 'DocType Settings'); - cy.get('input[data-fieldname="disable_count"]').check({ force: true }); - cy.get('input[data-fieldname="disable_sidebar_stats"]').check({ force: true }); - cy.get('button').filter(':visible').contains('Save').click(); + cy.findByLabelText('Disable Count').check({ force: true }); + cy.findByLabelText('Disable Sidebar Stats').check({ force: true }); + cy.findByRole('button', {name: 'Save'}).click(); cy.reload({ force: true }); @@ -29,8 +29,8 @@ context('List View Settings', () => { cy.get('.menu-btn-group button').click({ force: true }); cy.get('.dropdown-menu li').filter(':visible').contains('List Settings').click(); cy.get('.modal-dialog').should('contain', 'DocType Settings'); - cy.get('input[data-fieldname="disable_count"]').uncheck({ force: true }); - cy.get('input[data-fieldname="disable_sidebar_stats"]').uncheck({ force: true }); - cy.get('button').filter(':visible').contains('Save').click(); + cy.findByLabelText('Disable Count').uncheck({ force: true }); + cy.findByLabelText('Disable Sidebar Stats').uncheck({ force: true }); + cy.findByRole('button', {name: 'Save'}).click(); }); }); diff --git a/cypress/integration/login.js b/cypress/integration/login.js index 6b109dd18d..98739bb4c9 100644 --- a/cypress/integration/login.js +++ b/cypress/integration/login.js @@ -11,13 +11,13 @@ context('Login', () => { it('validates password', () => { cy.get('#login_email').type('Administrator'); - cy.get('.btn-login:visible').click(); + cy.findByRole('button', {name: 'Login'}).click(); cy.location('pathname').should('eq', '/login'); }); it('validates email', () => { cy.get('#login_password').type('qwe'); - cy.get('.btn-login:visible').click(); + cy.findByRole('button', {name: 'Login'}).click(); cy.location('pathname').should('eq', '/login'); }); @@ -25,8 +25,8 @@ context('Login', () => { cy.get('#login_email').type('Administrator'); cy.get('#login_password').type('qwer'); - cy.get('.btn-login:visible').click(); - cy.get('.btn-login:visible').contains('Invalid Login. Try again.'); + cy.findByRole('button', {name: 'Login'}).click(); + cy.findByRole('button', {name: 'Invalid Login. Try again.'}).should('exist'); cy.location('pathname').should('eq', '/login'); }); @@ -34,7 +34,7 @@ context('Login', () => { cy.get('#login_email').type('Administrator'); cy.get('#login_password').type(Cypress.config('adminPassword')); - cy.get('.btn-login:visible').click(); + cy.findByRole('button', {name: 'Login'}).click(); cy.location('pathname').should('eq', '/app'); cy.window().its('frappe.session.user').should('eq', 'Administrator'); }); @@ -60,7 +60,7 @@ context('Login', () => { cy.get('#login_email').type('Administrator'); cy.get('#login_password').type(Cypress.config('adminPassword')); - cy.get('.btn-login:visible').click(); + cy.findByRole('button', {name: 'Login'}).click(); // verify redirected location and url params after login cy.url().should('include', '/me?' + payload.toString().replace('+', '%20')); diff --git a/cypress/integration/multi_select_dialog.js b/cypress/integration/multi_select_dialog.js new file mode 100644 index 0000000000..a45fba8d32 --- /dev/null +++ b/cypress/integration/multi_select_dialog.js @@ -0,0 +1,58 @@ +context('MultiSelectDialog', () => { + before(() => { + cy.login(); + cy.visit('/app'); + }); + + function open_multi_select_dialog() { + cy.window().its('frappe').then(frappe => { + new frappe.ui.form.MultiSelectDialog({ + doctype: "Assignment Rule", + target: {}, + setters: { + document_type: null, + priority: null + }, + add_filters_group: 1, + allow_child_item_selection: 1, + child_fieldname: "assignment_days", + child_columns: ["day"] + }); + }); + } + + it('multi select dialog api works', () => { + open_multi_select_dialog(); + cy.get_open_dialog().should('contain', 'Select Assignment Rules'); + }); + + it('checks for filters', () => { + ['search_term', 'document_type', 'priority'].forEach(fieldname => { + cy.get_open_dialog().get(`.frappe-control[data-fieldname="${fieldname}"]`).should('exist'); + }); + + // add_filters_group: 1 should add a filter group + cy.get_open_dialog().get(`.frappe-control[data-fieldname="filter_area"]`).should('exist'); + + }); + + it('checks for child item selection', () => { + cy.get_open_dialog() + .get(`.dt-row-header`).should('not.exist'); + + cy.get_open_dialog() + .get(`.frappe-control[data-fieldname="allow_child_item_selection"]`) + .should('exist') + .click(); + + cy.get_open_dialog() + .get(`.frappe-control[data-fieldname="child_selection_area"]`) + .should('exist'); + + cy.get_open_dialog() + .get(`.dt-row-header`).should('contain', 'Assignment Rule'); + + cy.get_open_dialog() + .get(`.dt-row-header`).should('contain', 'Day'); + }); +}); \ No newline at end of file diff --git a/cypress/integration/navigation.js b/cypress/integration/navigation.js new file mode 100644 index 0000000000..b4e023c53e --- /dev/null +++ b/cypress/integration/navigation.js @@ -0,0 +1,25 @@ +context('Navigation', () => { + before(() => { + cy.login(); + }); + it('Navigate to route with hash in document name', () => { + cy.insert_doc('ToDo', {'__newname': 'ABC#123', 'description': 'Test this', 'ignore_duplicate': true}); + cy.visit('/app/todo/ABC#123'); + cy.title().should('eq', 'Test this - ABC#123'); + cy.get_field('description', 'Text Editor').contains('Test this'); + cy.go('back'); + cy.title().should('eq', 'Website'); + }); + + it.only('Navigate to previous page after login', () => { + cy.visit('/app/todo'); + cy.get('.page-head').findByTitle('To Do').should('be.visible'); + cy.request('/api/method/logout'); + cy.reload().as('reload'); + cy.get('@reload').get('.page-card .btn-primary').contains('Login').click(); + cy.location('pathname').should('eq', '/login'); + cy.login(); + cy.visit('/app'); + cy.location('pathname').should('eq', '/app/todo'); + }); +}); diff --git a/cypress/integration/query_report.js b/cypress/integration/query_report.js index e2a1c3fc79..43f26f8b50 100644 --- a/cypress/integration/query_report.js +++ b/cypress/integration/query_report.js @@ -2,32 +2,62 @@ context('Query Report', () => { before(() => { cy.login(); cy.visit('/app/website'); + cy.insert_doc('Report', { + 'report_name': 'Test ToDo Report', + 'ref_doctype': 'ToDo', + 'report_type': 'Query Report', + 'query': 'select * from tabToDo' + }, true).as('doc'); + cy.create_records({ + doctype: 'ToDo', + description: 'this is a test todo for query report' + }).as('todos'); }); it('add custom column in report', () => { cy.visit('/app/query-report/Permitted Documents For User'); 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 }).blur(); + cy.get('#page-query-report input[data-fieldname="user"]').as('input-user'); + cy.get('@input-user').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('#page-query-report input[data-fieldname="doctype"]').as('input-role'); + cy.get('@input-role').focus().type('Role', { delay: 100 }).blur(); cy.get('.datatable').should('exist'); - 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('#page-query-report .page-actions .menu-btn-group button').click({ force: true }); + cy.get('#page-query-report .menu-btn-group .dropdown-menu').contains('Add Column').click({ force: true }); + cy.get_open_dialog().get('.modal-title').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('.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_open_dialog().findByRole('button', {name: 'Submit'}).click({ force: true }); + cy.get('#page-query-report .page-actions .menu-btn-group button').click({ force: true }); + cy.get('#page-query-report .menu-btn-group .dropdown-menu').contains('Save').click({ timeout: 100, force: true }); + cy.get_open_dialog().get('.modal-title').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_open_dialog().findByRole('button', {name: 'Submit'}).click({ timeout: 1000, force: true }); }); }); + + let save_report_and_open = (report, update_name) => { + cy.get('#page-query-report .page-actions .menu-btn-group button').click({ force: true }); + cy.get('#page-query-report .menu-btn-group .dropdown-menu').contains('Save').click({ timeout: 100, force: true }); + cy.get_open_dialog().get('.modal-title').should('contain', 'Save Report'); + + cy.get('input[data-fieldname="report_name"]').type(update_name, { delay: 100, force: true }); + cy.get_open_dialog().findByRole('button', {name: 'Submit'}).click({ timeout: 1000, force: true }); + + cy.visit('/app/query-report/'+report); + cy.get('.datatable').should('exist'); + }; + + it('test multi level query report', () => { + cy.visit('/app/query-report/Test ToDo Report'); + cy.get('.datatable').should('exist'); + + save_report_and_open('Test ToDo Report 1', ' 1'); + save_report_and_open('Test ToDo Report 11', '1'); + }); }); \ No newline at end of file diff --git a/cypress/integration/recorder.js b/cypress/integration/recorder.js index 5b7692d8ff..7d4c83abf5 100644 --- a/cypress/integration/recorder.js +++ b/cypress/integration/recorder.js @@ -13,56 +13,52 @@ context('Recorder', () => { }); }); - it('Navigate to Recorder', () => { - cy.visit('/app'); - cy.awesomebar('recorder'); - cy.get('h3').should('contain', 'Recorder'); - cy.url().should('include', '/recorder/detail'); - }); - it('Recorder Empty State', () => { - cy.get('.title-text').should('contain', 'Recorder'); + cy.get('.page-head').findByTitle('Recorder').should('exist'); cy.get('.indicator-pill').should('contain', 'Inactive').should('have.class', 'red'); - cy.get('.primary-action').should('contain', 'Start'); - cy.get('.btn-secondary').should('contain', 'Clear'); + cy.get('.page-actions').findByRole('button', {name: 'Start'}).should('exist'); + cy.get('.page-actions').findByRole('button', {name: 'Clear'}).should('exist'); - cy.get('.msg-box').should('contain', 'Inactive'); - cy.get('.msg-box .btn-primary').should('contain', 'Start Recording'); + cy.get('.msg-box').should('contain', 'Recorder is Inactive'); + cy.get('.msg-box').findByRole('button', {name: 'Start Recording'}).should('exist'); }); it('Recorder Start', () => { - cy.get('.primary-action').should('contain', 'Start').click(); + cy.get('.page-actions').findByRole('button', {name: 'Start'}).click(); cy.get('.indicator-pill').should('contain', 'Active').should('have.class', 'green'); - cy.get('.msg-box').should('contain', 'No Requests'); + cy.get('.msg-box').should('contain', 'No Requests found'); 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('.page-head').findByTitle('DocType').should('exist'); cy.get('.list-count').should('contain', '20 of '); 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-head').findByTitle('Recorder').should('exist'); + cy.get('.frappe-list .result-list').should('contain', '/api/method/frappe.desk.reportview.get'); }); it('Recorder View Request', () => { - cy.get('.primary-action').should('contain', 'Start').click(); + cy.get('.page-actions').findByRole('button', {name: 'Start'}).click(); 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('.page-head').findByTitle('DocType').should('exist'); cy.get('.list-count').should('contain', '20 of '); cy.visit('/app/recorder'); - cy.get('.list-row-container span').contains('/api/method/frappe').click(); + cy.get('.frappe-list .list-row-container span') + .contains('/api/method/frappe') + .should('be.visible') + .click({force: true}); cy.url().should('include', '/recorder/request'); cy.get('form').should('contain', '/api/method/frappe'); diff --git a/cypress/integration/relative_time_filters.js b/cypress/integration/relative_time_filters.js index cbb0524c24..362d3a219b 100644 --- a/cypress/integration/relative_time_filters.js +++ b/cypress/integration/relative_time_filters.js @@ -1,44 +1,47 @@ -context('Relative Timeframe', () => { - before(() => { - cy.login(); - 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('/app/List/ToDo/List'); - cy.clear_filters(); - cy.get('.list-row:contains("this is fourth todo")').should('exist'); - 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.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.intercept('POST', '/api/method/frappe.model.utils.user_settings.save') - .as('save_user_settings'); - cy.clear_filters(); - cy.wait('@save_user_settings'); - }); - it('sets relative timespan filter for next week and filters list', () => { - cy.visit('/app/List/ToDo/List'); - cy.clear_filters(); - cy.get('.list-row:contains("this is fourth todo")').should('exist'); - 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.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.intercept('POST', '/api/method/frappe.model.utils.user_settings.save') - .as('save_user_settings'); - cy.clear_filters(); - cy.wait('@save_user_settings'); - }); -}); +// TODO: Enable this again +// currently this is flaky possibly because of different timezone in CI + +// context('Relative Timeframe', () => { +// before(() => { +// cy.login(); +// 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('/app/List/ToDo/List'); +// cy.clear_filters(); +// cy.get('.list-row:contains("this is fourth todo")').should('exist'); +// 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.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.intercept('POST', '/api/method/frappe.model.utils.user_settings.save') +// .as('save_user_settings'); +// cy.clear_filters(); +// cy.wait('@save_user_settings'); +// }); +// it('sets relative timespan filter for next week and filters list', () => { +// cy.visit('/app/List/ToDo/List'); +// cy.clear_filters(); +// cy.get('.list-row:contains("this is fourth todo")').should('exist'); +// 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.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.intercept('POST', '/api/method/frappe.model.utils.user_settings.save') +// .as('save_user_settings'); +// cy.clear_filters(); +// cy.wait('@save_user_settings'); +// }); +// }); diff --git a/cypress/integration/report_view.js b/cypress/integration/report_view.js index ea76246ae2..0253e8fd43 100644 --- a/cypress/integration/report_view.js +++ b/cypress/integration/report_view.js @@ -23,8 +23,7 @@ context('Report View', () => { let cell = cy.get('.dt-row-0 > .dt-cell--col-4'); // select the cell cell.dblclick(); - cell.find('input[data-fieldname="enabled"]').check({ force: true }); - cy.get('.dt-row-0 > .dt-cell--col-5').click(); + cell.get('.dt-cell__edit--col-4').findByRole('checkbox').check({ force: true }); cy.wait('@value-update'); cy.get('@doc').then(doc => { cy.call('frappe.client.get_value', { diff --git a/cypress/integration/sidebar.js b/cypress/integration/sidebar.js new file mode 100644 index 0000000000..2831c9bad5 --- /dev/null +++ b/cypress/integration/sidebar.js @@ -0,0 +1,55 @@ +context('Sidebar', () => { + before(() => { + cy.visit('/login'); + cy.login(); + cy.visit('/app/doctype'); + }); + + it('Test for checking "Assigned To" counter value, adding filter and adding & removing an assignment', () => { + cy.click_sidebar_button("Assigned To"); + + //To check if no filter is available in "Assigned To" dropdown + cy.get('.empty-state').should('contain', 'No filters found'); + + cy.click_sidebar_button("Created By"); + + //To check if "Created By" dropdown contains filter + cy.get('.group-by-item > .dropdown-item').should('contain', 'Me'); + + //Assigning a doctype to a user + cy.visit('/app/doctype/ToDo'); + cy.get('.form-assignments > .flex > .text-muted').click(); + cy.get_field('assign_to_me', 'Check').click(); + cy.get('.modal-footer > .standard-actions > .btn-primary').click(); + cy.visit('/app/doctype'); + cy.click_sidebar_button("Assigned To"); + + //To check if filter is added in "Assigned To" dropdown after assignment + cy.get('.group-by-field.show > .dropdown-menu > .group-by-item > .dropdown-item').should('contain', '1'); + + //To check if there is no filter added to the listview + cy.get('.filter-selector > .btn').should('contain', 'Filter'); + + //To add a filter to display data into the listview + cy.get('.group-by-field.show > .dropdown-menu > .group-by-item > .dropdown-item').click(); + + //To check if filter is applied + cy.click_filter_button().should('contain', '1 filter'); + cy.get('.fieldname-select-area > .awesomplete > .form-control').should('have.value', 'Assigned To'); + cy.get('.condition').should('have.value', 'like'); + cy.get('.filter-field > .form-group > .input-with-feedback').should('have.value', '%Administrator%'); + cy.click_filter_button(); + + //To remove the applied filter + cy.clear_filters(); + + //To remove the assignment + cy.visit('/app/doctype/ToDo'); + cy.get('.assignments > .avatar-group > .avatar > .avatar-frame').click(); + cy.get('.remove-btn').click({force: true}); + cy.hide_dialog(); + cy.visit('/app/doctype'); + cy.click_sidebar_button("Assigned To"); + cy.get('.empty-state').should('contain', 'No filters found'); + }); +}); diff --git a/cypress/integration/table_multiselect.js b/cypress/integration/table_multiselect.js index 25cab78ba2..f873461efb 100644 --- a/cypress/integration/table_multiselect.js +++ b/cypress/integration/table_multiselect.js @@ -9,6 +9,7 @@ context('Table MultiSelect', () => { cy.new_form('Assignment Rule'); cy.fill_field('__newname', name); cy.fill_field('document_type', 'Blog Post'); + cy.get('.section-head').contains('Assignment Rules').scrollIntoView(); 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'); diff --git a/cypress/integration/timeline.js b/cypress/integration/timeline.js new file mode 100644 index 0000000000..6c4733400d --- /dev/null +++ b/cypress/integration/timeline.js @@ -0,0 +1,89 @@ +import custom_submittable_doctype from '../fixtures/custom_submittable_doctype'; + +context('Timeline', () => { + before(() => { + cy.visit('/login'); + cy.login(); + }); + + it('Adding new ToDo, adding new comment, verifying comment addition & deletion and deleting ToDo', () => { + //Adding new ToDo + cy.visit('/app/todo/new-todo-1'); + cy.get('[data-fieldname="description"] .ql-editor.ql-blank').type('Test ToDo', {force: true}).wait(200); + cy.get('.page-head .page-actions').findByRole('button', {name: 'Save'}).click(); + + cy.visit('/app/todo'); + cy.click_listview_row_item(0); + + //To check if the comment box is initially empty and tying some text into it + cy.get('[data-fieldname="comment"] .ql-editor').should('contain', '').type('Testing Timeline'); + + //Adding new comment + cy.get('.comment-box').findByRole('button', {name: 'Comment'}).click(); + + //To check if the commented text is visible in the timeline content + cy.get('.timeline-content').should('contain', 'Testing Timeline'); + + //Editing comment + cy.click_timeline_action_btn("Edit"); + cy.get('.timeline-content [data-fieldname="comment"] .ql-editor').first().type(' 123'); + cy.click_timeline_action_btn("Save"); + + //To check if the edited comment text is visible in timeline content + cy.get('.timeline-content').should('contain', 'Testing Timeline 123'); + + //Discarding comment + cy.click_timeline_action_btn("Edit"); + cy.click_timeline_action_btn("Dismiss"); + + //To check if after discarding the timeline content is same as previous + cy.get('.timeline-content').should('contain', 'Testing Timeline 123'); + + //Deleting the added comment + cy.get('.timeline-message-box .more-actions > .action-btn').click(); //Menu button in timeline item + cy.get('.timeline-message-box .more-actions .dropdown-item').contains('Delete').click({ force: true }); + cy.get_open_dialog().findByRole('button', {name: 'Yes'}).click({ force: true }); + + cy.get('.timeline-content').should('not.contain', 'Testing Timeline 123'); + }); + + it('Timeline should have submit and cancel activity information', () => { + cy.visit('/app/doctype'); + + //Creating custom doctype + cy.insert_doc('DocType', custom_submittable_doctype, true); + + cy.visit('/app/custom-submittable-doctype'); + cy.click_listview_primary_button('Add Custom Submittable DocType'); + + //Adding a new entry for the created custom doctype + cy.fill_field('title', 'Test'); + cy.click_modal_primary_button('Save'); + cy.click_modal_primary_button('Submit'); + + cy.visit('/app/custom-submittable-doctype'); + cy.click_listview_row_item(0); + + //To check if the submission of the documemt is visible in the timeline content + cy.get('.timeline-content').should('contain', 'Administrator submitted this document'); + cy.get('[id="page-Custom Submittable DocType"] .page-actions').findByRole('button', {name: 'Cancel'}).click(); + cy.get_open_dialog().findByRole('button', {name: 'Yes'}).click(); + + //To check if the cancellation of the documemt is visible in the timeline content + cy.get('.timeline-content').should('contain', 'Administrator cancelled this document'); + + //Deleting the document + cy.visit('/app/custom-submittable-doctype'); + cy.select_listview_row_checkbox(0); + cy.get('.page-actions').findByRole('button', {name: 'Actions'}).click(); + cy.get('.page-actions .actions-btn-group [data-label="Delete"]').click(); + cy.click_modal_primary_button('Yes'); + + //Deleting the custom doctype + cy.visit('/app/doctype'); + cy.select_listview_row_checkbox(0); + cy.get('.page-actions').findByRole('button', {name: 'Actions'}).click(); + cy.get('.page-actions .actions-btn-group [data-label="Delete"]').click(); + cy.click_modal_primary_button('Yes'); + }); +}); \ No newline at end of file diff --git a/cypress/integration/timeline_email.js b/cypress/integration/timeline_email.js new file mode 100644 index 0000000000..dfe80e0019 --- /dev/null +++ b/cypress/integration/timeline_email.js @@ -0,0 +1,74 @@ +context('Timeline Email', () => { + before(() => { + cy.visit('/login'); + cy.login(); + cy.visit('/app/todo'); + }); + + it('Adding new ToDo', () => { + cy.click_listview_primary_button('Add ToDo'); + cy.get('.custom-actions:visible > .btn').contains("Edit in full page").click({delay: 500}); + cy.fill_field("description", "Test ToDo", "Text Editor"); + cy.wait(500); + cy.get('.primary-action').contains('Save').click({force: true}); + cy.wait(700); + }); + + it('Adding email and verifying timeline content for email attachment, deleting attachment and ToDo', () => { + cy.visit('/app/todo'); + cy.get('.list-row > .level-left > .list-subject').eq(0).click(); + + //Creating a new email + cy.get('.timeline-actions > .btn').click(); + cy.fill_field('recipients', 'test@example.com', 'MultiSelect'); + cy.get('.modal.show > .modal-dialog > .modal-content > .modal-body > :nth-child(1) > .form-layout > .form-page > :nth-child(3) > .section-body > .form-column > form > [data-fieldtype="Text Editor"] > .form-group > .control-input-wrapper > .control-input > .ql-container > .ql-editor').type('Test Mail'); + + //Adding attachment to the email + cy.get('.add-more-attachments > .btn').click(); + cy.get('.mt-2 > .btn > .mt-1').eq(2).click(); + cy.get('.input-group > .form-control').type('https://wallpaperplay.com/walls/full/8/2/b/72402.jpg'); + cy.get('.btn-primary').contains('Upload').click(); + + //Sending the email + cy.click_modal_primary_button('Send', {delay: 500}); + + //To check if the sent mail content is shown in the timeline content + cy.get('[data-doctype="Communication"] > .timeline-content').should('contain', 'Test Mail'); + + //To check if the attachment of email is shown in the timeline content + cy.get('.timeline-content').should('contain', 'Added 72402.jpg'); + + //Deleting the sent email + cy.get('[title="Open Communication"] > .icon').first().click({force: true}); + cy.get('#page-Communication > .page-head > .container > .row > .col > .standard-actions > .menu-btn-group > .btn').click(); + cy.get('#page-Communication > .page-head > .container > .row > .col > .standard-actions > .menu-btn-group > .dropdown-menu > li > .grey-link').eq(9).click(); + cy.get('.modal.show > .modal-dialog > .modal-content > .modal-footer > .standard-actions > .btn-primary').click(); + + cy.visit('/app/todo'); + cy.get('.list-row > .level-left > .list-subject > .level-item.ellipsis > .ellipsis').eq(0).click(); + + //Removing the added attachment + cy.get('.attachment-row > .data-pill > .remove-btn > .icon').click(); + cy.wait(500); + cy.get('.modal-footer:visible > .standard-actions > .btn-primary').contains('Yes').click(); + + //To check if the removed attachment is shown in the timeline content + cy.get('.timeline-content').should('contain', 'Removed 72402.jpg'); + cy.wait(500); + + //To check if the discard button functionality in email is working correctly + cy.get('.timeline-actions > .btn').click(); + cy.fill_field('recipients', 'test@example.com', 'MultiSelect'); + cy.get('.modal-footer > .standard-actions > .btn-secondary').contains('Discard').click(); + cy.wait(500); + cy.get('.timeline-actions > .btn').click(); + cy.wait(500); + cy.get_field('recipients', 'MultiSelect').should('have.text', ''); + cy.get('.modal-header:visible > .modal-actions > .btn-modal-close > .icon').click(); + + //Deleting the added ToDo + cy.get('.menu-btn-group:visible > .btn').click(); + cy.get('.menu-btn-group:visible > .dropdown-menu > li > .dropdown-item').contains('Delete').click(); + cy.get('.modal-footer:visible > .standard-actions > .btn-primary').click(); + }); +}); diff --git a/cypress/integration/workspace.js b/cypress/integration/workspace.js new file mode 100644 index 0000000000..65586366e6 --- /dev/null +++ b/cypress/integration/workspace.js @@ -0,0 +1,90 @@ +context('Workspace 2.0', () => { + before(() => { + cy.visit('/login'); + cy.login(); + cy.visit('/app/website'); + }); + + it('Navigate to page from sidebar', () => { + cy.visit('/app/build'); + cy.get('.codex-editor__redactor .ce-block'); + cy.get('.sidebar-item-container[item-name="Settings"]').first().click(); + cy.location('pathname').should('eq', '/app/settings'); + }); + + it('Create Private Page', () => { + cy.get('.codex-editor__redactor .ce-block'); + cy.get('.custom-actions button[data-label="Create%20Workspace"]').click(); + cy.fill_field('title', 'Test Private Page', 'Data'); + cy.fill_field('icon', 'edit', 'Icon'); + cy.get_open_dialog().find('.modal-header').click(); + cy.get_open_dialog().find('.btn-primary').click(); + + // check if sidebar item is added in pubic section + cy.get('.sidebar-item-container[item-name="Test Private Page"]').should('have.attr', 'item-public', '0'); + + cy.get('.standard-actions .btn-primary[data-label="Save Customizations"]').click(); + cy.wait(300); + cy.get('.sidebar-item-container[item-name="Test Private Page"]').should('have.attr', 'item-public', '0'); + + cy.wait(500); + cy.get('.codex-editor__redactor .ce-block'); + cy.get('.standard-actions .btn-secondary[data-label=Edit]').click(); + }); + + it('Add New Block', () => { + cy.get('.codex-editor__redactor .ce-block'); + cy.get('.custom-actions .inner-group-button[data-label="Add%20Block"]').click(); + cy.get('.custom-actions .inner-group-button .dropdown-menu .block-menu-item-label').contains('Heading').click(); + cy.get(":focus").type('Header'); + cy.get(".ce-block:last").find('.ce-header').should('exist'); + + cy.get('.custom-actions .inner-group-button[data-label="Add%20Block"]').click(); + cy.get('.custom-actions .inner-group-button .dropdown-menu .block-menu-item-label').contains('Text').click(); + cy.get(":focus").type('Paragraph text'); + cy.get(".ce-block:last").find('.ce-paragraph').should('exist'); + }); + + it('Delete A Block', () => { + cy.get(".ce-block:last").find('.delete-paragraph').click(); + cy.get(".ce-block:last").find('.ce-paragraph').should('not.exist'); + }); + + it('Shrink and Expand A Block', () => { + cy.get(".ce-block:last").find('.tune-btn').click(); + cy.get('.ce-settings--opened .ce-shrink-button').click(); + cy.get(".ce-block:last").should('have.class', 'col-11'); + cy.get('.ce-settings--opened .ce-shrink-button').click(); + cy.get(".ce-block:last").should('have.class', 'col-10'); + cy.get('.ce-settings--opened .ce-shrink-button').click(); + cy.get(".ce-block:last").should('have.class', 'col-9'); + cy.get('.ce-settings--opened .ce-expand-button').click(); + cy.get(".ce-block:last").should('have.class', 'col-10'); + cy.get('.ce-settings--opened .ce-expand-button').click(); + cy.get(".ce-block:last").should('have.class', 'col-11'); + cy.get('.ce-settings--opened .ce-expand-button').click(); + cy.get(".ce-block:last").should('have.class', 'col-12'); + }); + + it('Change Header Text Size', () => { + cy.get('.ce-settings--opened .cdx-settings-button[data-level="3"]').click(); + cy.get(".ce-block:last").find('.widget-head h3').should('exist'); + cy.get('.ce-settings--opened .cdx-settings-button[data-level="4"]').click(); + cy.get(".ce-block:last").find('.widget-head h4').should('exist'); + + cy.get('.standard-actions .btn-primary[data-label="Save Customizations"]').click(); + }); + + it('Delete Private Page', () => { + cy.get('.codex-editor__redactor .ce-block'); + cy.get('.standard-actions .btn-secondary[data-label=Edit]').click(); + + cy.get('.sidebar-item-container[item-name="Test Private Page"]').find('.sidebar-item-control .delete-page').click(); + cy.wait(300); + cy.get('.modal-footer > .standard-actions > .btn-modal-primary:visible').first().click(); + cy.get('.standard-actions .btn-primary[data-label="Save Customizations"]').click(); + cy.get('.codex-editor__redactor .ce-block'); + cy.get('.sidebar-item-container[item-name="Test Private Page"]').should('not.exist'); + }); + +}); \ No newline at end of file diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js index 07d9804a73..9720faa666 100644 --- a/cypress/plugins/index.js +++ b/cypress/plugins/index.js @@ -11,7 +11,7 @@ // This function is called when a project is opened or re-opened (e.g. due to // the project's config changing) -module.exports = () => { - // `on` is used to hook into various events Cypress emits - // `config` is the resolved Cypress config -}; +module.exports = (on, config) => { + require('@cypress/code-coverage/task')(on, config); + return config; +}; \ No newline at end of file diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 1964b96d70..933f6a1758 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -1,4 +1,5 @@ import 'cypress-file-upload'; +import '@testing-library/cypress/add-commands'; // *********************************************** // This example commands.js shows you how to // create various custom commands and overwrite @@ -186,22 +187,22 @@ 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, delay: 100}); } return cy.get('@input'); }); Cypress.Commands.add('get_field', (fieldname, fieldtype = 'Data') => { - let selector = `.form-control[data-fieldname="${fieldname}"]`; + let selector = `[data-fieldname="${fieldname}"] input:visible`; if (fieldtype === 'Text Editor') { - selector = `[data-fieldname="${fieldname}"] .ql-editor[contenteditable=true]`; + selector = `[data-fieldname="${fieldname}"] .ql-editor[contenteditable=true]:visible`; } if (fieldtype === 'Code') { selector = `[data-fieldname="${fieldname}"] .ace_text-input`; } - return cy.get(selector); + return cy.get(selector).first(); }); Cypress.Commands.add('fill_table_field', (tablefieldname, row_idx, fieldname, value, fieldtype = 'Data') => { @@ -240,7 +241,7 @@ Cypress.Commands.add('get_table_field', (tablefieldname, row_idx, fieldname, fie }); Cypress.Commands.add('awesomebar', text => { - cy.get('#navbar-search').type(`${text}{downarrow}{enter}`, {delay: 100}); + cy.get('#navbar-search').type(`${text}{downarrow}{enter}`, {delay: 700}); }); Cypress.Commands.add('new_form', doctype => { @@ -251,7 +252,8 @@ Cypress.Commands.add('new_form', doctype => { }); Cypress.Commands.add('go_to_list', doctype => { - cy.visit(`/app/list/${doctype}/list`); + let dt_in_route = doctype.toLowerCase().replace(/ /g, '-'); + cy.visit(`/app/${dt_in_route}`); }); Cypress.Commands.add('clear_cache', () => { @@ -315,7 +317,11 @@ Cypress.Commands.add('add_filter', () => { }); Cypress.Commands.add('clear_filters', () => { - cy.get('.filter-section .filter-button').click(); + cy.intercept({ + method: 'POST', + url: 'api/method/frappe.model.utils.user_settings.save' + }).as('filter-saved'); + cy.get('.filter-section .filter-button').click({force: true}); cy.wait(300); cy.get('.filter-popover').should('exist'); cy.get('.filter-popover').find('.clear-filters').click(); @@ -323,4 +329,33 @@ Cypress.Commands.add('clear_filters', () => { cy.window().its('cur_list').then(cur_list => { cur_list && cur_list.filter_area && cur_list.filter_area.clear(); }); + cy.wait('@filter-saved'); +}); + +Cypress.Commands.add('click_modal_primary_button', (btn_name) => { + cy.get('.modal-footer > .standard-actions > .btn-primary').contains(btn_name).trigger('click', {force: true}); +}); + +Cypress.Commands.add('click_sidebar_button', (btn_name) => { + cy.get('.list-group-by-fields .list-link > a').contains(btn_name).click({force: true}); +}); + +Cypress.Commands.add('click_listview_row_item', (row_no) => { + cy.get('.list-row > .level-left > .list-subject > .level-item > .ellipsis').eq(row_no).click({force: true}); +}); + +Cypress.Commands.add('click_filter_button', () => { + cy.get('.filter-selector > .btn').click(); +}); + +Cypress.Commands.add('click_listview_primary_button', (btn_name) => { + cy.get('.primary-action').contains(btn_name).click({force: true}); +}); + +Cypress.Commands.add('click_timeline_action_btn', (btn_name) => { + cy.get('.timeline-message-box .actions .action-btn').contains(btn_name).click(); +}); + +Cypress.Commands.add('select_listview_row_checkbox', (row_no) => { + cy.get('.frappe-list .select-like > .list-row-checkbox').eq(row_no).click(); }); diff --git a/cypress/support/index.js b/cypress/support/index.js index 1bee72d2ca..9cd770a31e 100644 --- a/cypress/support/index.js +++ b/cypress/support/index.js @@ -15,6 +15,7 @@ // Import commands.js using ES2015 syntax: import './commands'; +import '@cypress/code-coverage/support'; // Alternatively you can use CommonJS syntax: diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000000..f4045c6bed --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,4 @@ +coverage==5.5 +Faker~=8.1.0 +pyngrok~=5.0.5 +unittest-xml-reporting~=3.0.4 diff --git a/esbuild/build-cleanup.js b/esbuild/build-cleanup.js new file mode 100644 index 0000000000..cf03606a34 --- /dev/null +++ b/esbuild/build-cleanup.js @@ -0,0 +1,38 @@ +/* eslint-disable no-console */ +const path = require("path"); +const fs = require("fs"); +const glob = require("fast-glob"); + +module.exports = { + name: 'build_cleanup', + setup(build) { + build.onEnd(result => { + if (result.errors.length) return; + clean_dist_files(Object.keys(result.metafile.outputs)); + }); + }, +}; + +function clean_dist_files(new_files) { + new_files.forEach( + file => { + if (file.endsWith(".map")) return; + + const pattern = file.split(".").slice(0, -2).join(".") + "*"; + glob.sync(pattern).forEach( + file_to_delete => { + if (file_to_delete.startsWith(file)) return; + + fs.unlink(path.resolve(file_to_delete), err => { + if (!err) return; + + console.error( + `Error deleting ${file.split(path.sep).pop()}` + ); + }); + } + + ); + } + ); +} \ No newline at end of file diff --git a/esbuild/esbuild.js b/esbuild/esbuild.js index 5154adb634..792cb56198 100644 --- a/esbuild/esbuild.js +++ b/esbuild/esbuild.js @@ -1,17 +1,20 @@ /* eslint-disable no-console */ -let path = require("path"); -let fs = require("fs"); -let glob = require("fast-glob"); -let esbuild = require("esbuild"); -let vue = require("esbuild-vue"); -let yargs = require("yargs"); -let cliui = require("cliui")(); -let chalk = require("chalk"); -let html_plugin = require("./frappe-html"); -let postCssPlugin = require("esbuild-plugin-postcss2").default; -let ignore_assets = require("./ignore-assets"); -let sass_options = require("./sass_options"); -let { +const path = require("path"); +const fs = require("fs"); +const glob = require("fast-glob"); +const esbuild = require("esbuild"); +const vue = require("esbuild-vue"); +const yargs = require("yargs"); +const cliui = require("cliui")(); +const chalk = require("chalk"); +const html_plugin = require("./frappe-html"); +const rtlcss = require('rtlcss'); +const postCssPlugin = require("esbuild-plugin-postcss2").default; +const ignore_assets = require("./ignore-assets"); +const sass_options = require("./sass_options"); +const build_cleanup_plugin = require("./build-cleanup"); + +const { app_list, assets_path, apps_path, @@ -25,7 +28,7 @@ let { get_redis_subscriber } = require("./utils"); -let argv = yargs +const argv = yargs .usage("Usage: node esbuild [options]") .option("apps", { type: "string", @@ -43,6 +46,11 @@ let argv = yargs type: "boolean", description: "Run in watch mode and rebuild on file changes" }) + .option("live-reload", { + type: "boolean", + description: `Automatically reload Desk when assets are rebuilt. + Can only be used with the --watch flag.` + }) .option("production", { type: "boolean", description: "Run build in production mode" @@ -92,28 +100,30 @@ if (WATCH_MODE) { async function execute() { console.time(TOTAL_BUILD_TIME); - if (!FILES_TO_BUILD.length) { - await clean_dist_folders(APPS); - } - let result; + let results; try { - result = await build_assets_for_apps(APPS, FILES_TO_BUILD); + results = await build_assets_for_apps(APPS, FILES_TO_BUILD); } catch (e) { log_error("There were some problems during build"); log(); log(chalk.dim(e.stack)); + if (process.env.CI) { + process.kill(process.pid); + } return; } if (!WATCH_MODE) { - log_built_assets(result.metafile); + log_built_assets(results); console.timeEnd(TOTAL_BUILD_TIME); log(); } else { log("Watching for changes..."); } - return await write_assets_json(result.metafile); + for (const result of results) { + await write_assets_json(result.metafile); + } } function build_assets_for_apps(apps, files) { @@ -125,6 +135,8 @@ function build_assets_for_apps(apps, files) { let output_path = assets_path; let file_map = {}; + let style_file_map = {}; + let rtl_style_file_map = {}; for (let file of files) { let relative_app_path = path.relative(apps_path, file); let app = relative_app_path.split(path.sep)[0]; @@ -140,19 +152,32 @@ function build_assets_for_apps(apps, files) { } output_name = path.join(app, "dist", output_name); - if (Object.keys(file_map).includes(output_name)) { + if (Object.keys(file_map).includes(output_name) || Object.keys(style_file_map).includes(output_name)) { log_warn( `Duplicate output file ${output_name} generated from ${file}` ); } - - file_map[output_name] = file; + if ([".css", ".scss", ".less", ".sass", ".styl"].includes(extension)) { + style_file_map[output_name] = file; + rtl_style_file_map[output_name.replace('/css/', '/css-rtl/')] = file; + } else { + file_map[output_name] = file; + } } - - return build_files({ + let build = build_files({ files: file_map, outdir: output_path }); + let style_build = build_style_files({ + files: style_file_map, + outdir: output_path + }); + let rtl_style_build = build_style_files({ + files: rtl_style_file_map, + outdir: output_path, + rtl_style: true + }); + return Promise.all([build, style_build, rtl_style_build]); }); } @@ -203,7 +228,35 @@ function get_files_to_build(files) { } function build_files({ files, outdir }) { - return esbuild.build({ + let build_plugins = [ + html_plugin, + build_cleanup_plugin, + vue(), + ]; + return esbuild.build(get_build_options(files, outdir, build_plugins)); +} + +function build_style_files({ files, outdir, rtl_style = false }) { + let plugins = []; + if (rtl_style) { + plugins.push(rtlcss); + } + + let build_plugins = [ + ignore_assets, + build_cleanup_plugin, + postCssPlugin({ + plugins: plugins, + sassOptions: sass_options + }) + ]; + + plugins.push(require("autoprefixer")); + return esbuild.build(get_build_options(files, outdir, build_plugins)); +} + +function get_build_options(files, outdir, plugins) { + return { entryPoints: files, entryNames: "[dir]/[name].[hash]", outdir, @@ -217,17 +270,9 @@ function build_files({ files, outdir }) { PRODUCTION ? "production" : "development" ) }, - plugins: [ - html_plugin, - ignore_assets, - vue(), - postCssPlugin({ - plugins: [require("autoprefixer")], - sassOptions: sass_options - }) - ], + plugins: plugins, watch: get_watch_config() - }); + }; } function get_watch_config() { @@ -244,10 +289,24 @@ function get_watch_config() { assets_json, prev_assets_json } = await write_assets_json(result.metafile); + + let changed_files; if (prev_assets_json) { - log_rebuilt_assets(prev_assets_json, assets_json); + changed_files = get_rebuilt_assets( + prev_assets_json, + assets_json + ); + + let timestamp = new Date().toLocaleTimeString(); + let message = `${timestamp}: Compiled ${changed_files.length} files...`; + log(chalk.yellow(message)); + for (let filepath of changed_files) { + let filename = path.basename(filepath); + log(" " + filename); + } + log(); } - notify_redis({ success: true }); + notify_redis({ success: true, changed_files }); } } }; @@ -255,19 +314,11 @@ function get_watch_config() { return null; } -async function clean_dist_folders(apps) { - for (let app of apps) { - let public_path = get_public_path(app); - await fs.promises.rmdir(path.resolve(public_path, "dist", "js"), { - recursive: true - }); - await fs.promises.rmdir(path.resolve(public_path, "dist", "css"), { - recursive: true - }); +function log_built_assets(results) { + let outputs = {}; + for (const result of results) { + outputs = Object.assign(outputs, result.metafile.outputs); } -} - -function log_built_assets(metafile) { let column_widths = [60, 20]; cliui.div( { @@ -282,9 +333,9 @@ function log_built_assets(metafile) { cliui.div(""); let output_by_dist_path = {}; - for (let outfile in metafile.outputs) { + for (let outfile in outputs) { if (outfile.endsWith(".map")) continue; - let data = metafile.outputs[outfile]; + let data = outputs[outfile]; outfile = path.resolve(outfile); outfile = path.relative(assets_path, outfile); let filename = path.basename(outfile); @@ -339,7 +390,11 @@ async function write_assets_json(metafile) { let info = metafile.outputs[output]; let asset_path = "/" + path.relative(sites_path, output); if (info.entryPoint) { - out[path.basename(info.entryPoint)] = asset_path; + let key = path.basename(info.entryPoint); + if (key.endsWith('.css') && asset_path.includes('/css-rtl/')) { + key = `rtl_${key}`; + } + out[key] = asset_path; } } @@ -403,7 +458,7 @@ function run_build_command_for_apps(apps) { process.chdir(cwd); } -async function notify_redis({ error, success }) { +async function notify_redis({ error, success, changed_files }) { // notify redis which in turns tells socketio to publish this to browser let subscriber = get_redis_subscriber("redis_socketio"); subscriber.on("error", _ => { @@ -425,7 +480,9 @@ async function notify_redis({ error, success }) { } if (success) { payload = { - success: true + success: true, + changed_files, + live_reload: argv["live-reload"] }; } @@ -455,7 +512,7 @@ function open_in_editor() { subscriber.subscribe("open_in_editor"); } -function log_rebuilt_assets(prev_assets, new_assets) { +function get_rebuilt_assets(prev_assets, new_assets) { let added_files = []; let old_files = Object.values(prev_assets); let new_files = Object.values(new_assets); @@ -465,17 +522,5 @@ function log_rebuilt_assets(prev_assets, new_assets) { added_files.push(filepath); } } - - log( - chalk.yellow( - `${new Date().toLocaleTimeString()}: Compiled ${ - added_files.length - } files...` - ) - ); - for (let filepath of added_files) { - let filename = path.basename(filepath); - log(" " + filename); - } - log(); + return added_files; } diff --git a/frappe/__init__.py b/frappe/__init__.py index 9d8c5d3607..4218aa113b 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE """ Frappe - Low Code Open Source Framework in Python and JS @@ -28,8 +28,7 @@ from .exceptions import * from .utils.jinja import (get_jenv, get_template, render_template, get_email_from_template, get_jloader) from .utils.lazy_loader import lazy_import -# Lazy imports -faker = lazy_import('faker') +from frappe.query_builder import get_query_builder, patch_query_execute __version__ = '14.0.0-dev' @@ -42,7 +41,8 @@ class _dict(dict): """dict like object that exposes keys as attributes""" def __getattr__(self, key): ret = self.get(key) - if not ret and key.startswith("__"): + # "__deepcopy__" exception added to fix frappe#14833 via DFP + if not ret and key.startswith("__") and key != "__deepcopy__": raise AttributeError() return ret def __setattr__(self, key, value): @@ -118,6 +118,7 @@ def set_user_lang(user, user_language=None): # local-globals db = local("db") +qb = local("qb") conf = local("conf") form = form_dict = local("form_dict") request = local("request") @@ -137,7 +138,11 @@ lang = local("lang") if typing.TYPE_CHECKING: from frappe.database.mariadb.database import MariaDBDatabase from frappe.database.postgres.database import PostgresDatabase + from frappe.query_builder.builder import MariaDB, Postgres + db: typing.Union[MariaDBDatabase, PostgresDatabase] + qb: typing.Union[MariaDB, Postgres] + # end: static analysis hack def init(site, sites_path=None, new_site=False): @@ -202,8 +207,10 @@ def init(site, sites_path=None, new_site=False): local.form_dict = _dict() local.session = _dict() local.dev_server = _dev_server + local.qb = get_query_builder(local.conf.db_type or "mariadb") setup_module_map() + patch_query_execute() local.initialised = True @@ -226,12 +233,13 @@ def connect_replica(): from frappe.database import get_db user = local.conf.db_name password = local.conf.db_password + port = local.conf.replica_db_port if local.conf.different_credentials_for_replica: user = local.conf.replica_db_name password = local.conf.replica_db_password - local.replica_db = get_db(host=local.conf.replica_host, user=user, password=password) + local.replica_db = get_db(host=local.conf.replica_host, user=user, password=password, port=port) # swap db connections local.primary_db = local.db @@ -480,11 +488,11 @@ def get_request_header(key, default=None): :param default: Default value.""" return request.headers.get(key, default) -def sendmail(recipients=[], sender="", subject="No Subject", message="No Message", +def sendmail(recipients=None, 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, 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, + cc=None, bcc=None, 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, with_container=False): """Send email using user's default **Email Account** or global default **Email Account**. @@ -514,6 +522,14 @@ def sendmail(recipients=[], sender="", subject="No Subject", message="No Message :param header: Append header in email :param with_container: Wraps email inside a styled container """ + + if recipients is None: + recipients = [] + if cc is None: + cc = [] + if bcc is None: + bcc = [] + text_content = None if template: message, text_content = get_email_from_template(template, args) @@ -609,8 +625,6 @@ def read_only(): try: retval = fn(*args, **get_newargs(fn, kwargs)) - except: - raise finally: if local and hasattr(local, 'primary_db'): local.db.close() @@ -620,6 +634,29 @@ def read_only(): return wrapper_fn return innfn +def write_only(): + # if replica connection exists, we have to replace it momentarily with the primary connection + def innfn(fn): + def wrapper_fn(*args, **kwargs): + primary_db = getattr(local, "primary_db", None) + replica_db = getattr(local, "replica_db", None) + in_read_only = getattr(local, "db", None) != primary_db + + # switch to primary connection + if in_read_only and primary_db: + local.db = local.primary_db + + try: + retval = fn(*args, **get_newargs(fn, kwargs)) + finally: + # switch back to replica connection + if in_read_only and replica_db: + local.db = replica_db + + return retval + return wrapper_fn + return innfn + def only_for(roles, message=False): """Raise `frappe.PermissionError` if the user does not have any of the given **Roles**. @@ -690,18 +727,20 @@ def only_has_select_perm(doctype, user=None, ignore_permissions=False): else: return False -def has_permission(doctype=None, ptype="read", doc=None, user=None, verbose=False, throw=False): +def has_permission(doctype=None, ptype="read", doc=None, user=None, verbose=False, throw=False, parent_doctype=None): """Raises `frappe.PermissionError` if not permitted. :param doctype: DocType for which permission is to be check. :param ptype: Permission type (`read`, `write`, `create`, `submit`, `cancel`, `amend`). Default: `read`. :param doc: [optional] Checks User permissions for given doc. - :param user: [optional] Check for given user. Default: current user.""" + :param user: [optional] Check for given user. Default: current user. + :param parent_doctype: Required when checking permission for a child DocType (unless doc is specified).""" if not doctype and doc: doctype = doc.doctype import frappe.permissions - out = frappe.permissions.has_permission(doctype, ptype, doc=doc, verbose=verbose, user=user, raise_exception=throw) + out = frappe.permissions.has_permission(doctype, ptype, doc=doc, verbose=verbose, user=user, + raise_exception=throw, parent_doctype=parent_doctype) if throw and not out: if doc: frappe.throw(_("No permission for {0}").format(doc.doctype + " " + doc.name)) @@ -1449,7 +1488,10 @@ def get_value(*args, **kwargs): def as_json(obj, indent=1): from frappe.utils.response import json_handler - return json.dumps(obj, indent=indent, sort_keys=True, default=json_handler, separators=(',', ': ')) + try: + return json.dumps(obj, indent=indent, sort_keys=True, default=json_handler, separators=(',', ': ')) + except TypeError: + return json.dumps(obj, indent=indent, default=json_handler, separators=(',', ': ')) def are_emails_muted(): from frappe.utils import cint @@ -1491,7 +1533,7 @@ def get_print(doctype=None, name=None, print_format=None, style=None, :param style: Print Format style. :param as_pdf: Return as PDF. Default False. :param password: Password to encrypt the pdf with. Default None""" - from frappe.website.render import build_page + from frappe.website.serve import get_response_content from frappe.utils.pdf import get_pdf local.form_dict.doctype = doctype @@ -1506,7 +1548,7 @@ def get_print(doctype=None, name=None, print_format=None, style=None, options = {'password': password} if not html: - html = build_page("printview") + html = get_response_content("printview") if as_pdf: return get_pdf(html, output = output, options = options) @@ -1683,7 +1725,7 @@ def get_desk_link(doctype, name): ) def bold(text): - return '{0}'.format(text) + return '{0}'.format(text) def safe_eval(code, eval_globals=None, eval_locals=None): '''A safer `eval`''' @@ -1756,7 +1798,7 @@ def get_version(doctype, name, limit=None, head=False, raise_err=True): 'limit': limit }, as_list=1) - from frappe.chat.util import squashify, dictify, safe_json_loads + from frappe.utils import squashify, dictify, safe_json_loads versions = [] @@ -1804,6 +1846,7 @@ def parse_json(val): return parse_json(val) def mock(type, size=1, locale='en'): + import faker results = [] fake = faker.Faker(locale) if type not in dir(fake): @@ -1813,7 +1856,7 @@ def mock(type, size=1, locale='en'): data = getattr(fake, type)() results.append(data) - from frappe.chat.util import squashify + from frappe.utils import squashify return squashify(results) def validate_and_sanitize_search_inputs(fn): diff --git a/frappe/api.py b/frappe/api.py index 36d51e894c..b061761d10 100644 --- a/frappe/api.py +++ b/frappe/api.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import base64 import binascii import json @@ -82,7 +82,7 @@ def handle(): if frappe.local.request.method=="PUT": data = get_request_form_data() - doc = frappe.get_doc(doctype, name) + doc = frappe.get_doc(doctype, name, for_update=True) if "flags" in data: del data["flags"] diff --git a/frappe/app.py b/frappe/app.py index 6f5023be93..8e1534e7ef 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import os import logging @@ -16,9 +16,9 @@ import frappe.handler import frappe.auth import frappe.api import frappe.utils.response -import frappe.website.render from frappe.utils import get_site_name, sanitize_html from frappe.middlewares import StaticDataMiddleware +from frappe.website.serve import get_response from frappe.utils.error import make_error_snapshot from frappe.core.doctype.comment.comment import update_comments_in_parent_after_request from frappe import _ @@ -72,7 +72,7 @@ def application(request): response = frappe.utils.response.download_private_file(request.path) elif request.method in ('GET', 'HEAD', 'POST'): - response = frappe.website.render.render() + response = get_response() else: raise NotFound @@ -266,8 +266,7 @@ def handle_exception(e): make_error_snapshot(e) if return_as_message: - response = frappe.website.render.render("message", - http_status_code=http_status_code) + response = get_response("message", http_status_code=http_status_code) return response diff --git a/frappe/auth.py b/frappe/auth.py index ef79d96ddb..078a6bb165 100644 --- a/frappe/auth.py +++ b/frappe/auth.py @@ -1,31 +1,58 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt -import datetime -from frappe import _ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See LICENSE +from urllib.parse import quote + import frappe import frappe.database import frappe.utils -from frappe.utils import cint, flt, get_datetime, datetime, date_diff, today import frappe.utils.user -from frappe import conf -from frappe.sessions import Session, clear_sessions, delete_session -from frappe.modules.patch_handler import check_session_stopped -from frappe.translate import get_lang_code -from frappe.utils.password import check_password, delete_login_failed_cache +from frappe import _, conf from frappe.core.doctype.activity_log.activity_log import add_authentication_log -from frappe.twofactor import (should_run_2fa, authenticate_for_2factor, - confirm_otp_token, get_cached_user_pass) +from frappe.modules.patch_handler import check_session_stopped +from frappe.sessions import Session, clear_sessions, delete_session +from frappe.translate import get_language +from frappe.twofactor import authenticate_for_2factor, confirm_otp_token, get_cached_user_pass, should_run_2fa +from frappe.utils import cint, date_diff, datetime, get_datetime, today +from frappe.utils.password import check_password from frappe.website.utils import get_home_page -from urllib.parse import quote class HTTPRequest: def __init__(self): - # Get Environment variables - self.domain = frappe.request.host - if self.domain and self.domain.startswith('www.'): - self.domain = self.domain[4:] + # set frappe.local.request_ip + self.set_request_ip() + # load cookies + self.set_cookies() + + # set frappe.local.db + self.connect() + + # login and start/resume user session + self.set_session() + + # set request language + self.set_lang() + + # match csrf token from current session + self.validate_csrf_token() + + # write out latest cookies + frappe.local.cookie_manager.init_cookies() + + # check session status + check_session_stopped() + + @property + def domain(self): + if not getattr(self, "_domain", None): + self._domain = frappe.request.host + if self._domain and self._domain.startswith('www.'): + self._domain = self._domain[4:] + + return self._domain + + def set_request_ip(self): if frappe.get_request_header('X-Forwarded-For'): frappe.local.request_ip = (frappe.get_request_header('X-Forwarded-For').split(",")[0]).strip() @@ -35,37 +62,21 @@ class HTTPRequest: else: frappe.local.request_ip = '127.0.0.1' - # language - self.set_lang() - - # load cookies + def set_cookies(self): frappe.local.cookie_manager = CookieManager() - # set db - self.connect() - - # login + def set_session(self): frappe.local.login_manager = LoginManager() - if frappe.form_dict._lang: - lang = get_lang_code(frappe.form_dict._lang) - if lang: - frappe.local.lang = lang - - self.validate_csrf_token() - - # write out latest cookies - frappe.local.cookie_manager.init_cookies() - - # check status - check_session_stopped() - def validate_csrf_token(self): if frappe.local.request and frappe.local.request.method in ("POST", "PUT", "DELETE"): - if not frappe.local.session: return - if not frappe.local.session.data.csrf_token \ - or frappe.local.session.data.device=="mobile" \ - or frappe.conf.get('ignore_csrf', None): + if not frappe.local.session: + return + if ( + not frappe.local.session.data.csrf_token + or frappe.local.session.data.device == "mobile" + or frappe.conf.get('ignore_csrf', None) + ): # not via boot return @@ -79,17 +90,18 @@ class HTTPRequest: frappe.throw(_("Invalid Request"), frappe.CSRFTokenError) def set_lang(self): - from frappe.translate import guess_language - frappe.local.lang = guess_language() + frappe.local.lang = get_language() def get_db_name(self): """get database name from conf""" return conf.db_name - def connect(self, ac_name = None): + def connect(self): """connect to db, from ac_name or db_name""" - frappe.local.db = frappe.database.get_db(user = self.get_db_name(), \ - password = getattr(conf, 'db_password', '')) + frappe.local.db = frappe.database.get_db( + user=self.get_db_name(), + password=getattr(conf, 'db_password', '') + ) class LoginManager: def __init__(self): @@ -116,7 +128,6 @@ class LoginManager: self.make_session() self.set_user_info() - @frappe.whitelist() def login(self): # clear cache frappe.clear_cache(user = frappe.form_dict.get('usr')) @@ -143,7 +154,7 @@ class LoginManager: self.setup_boot_cache() self.set_user_info() - def get_user_info(self, resume=False): + def get_user_info(self): self.info = frappe.db.get_value("User", self.user, ["user_type", "first_name", "last_name", "user_image"], as_dict=1) @@ -181,11 +192,13 @@ class LoginManager: frappe.local.response["redirect_to"] = redirect_to frappe.cache().hdel('redirect_after_login', self.user) - frappe.local.cookie_manager.set_cookie("full_name", self.full_name) frappe.local.cookie_manager.set_cookie("user_id", self.user) frappe.local.cookie_manager.set_cookie("user_image", self.info.user_image or "") + def clear_preferred_language(self): + frappe.local.cookie_manager.delete_cookie("preferred_language") + def make_session(self, resume=False): # start session frappe.local.session_obj = Session(user=self.user, resume=resume, diff --git a/frappe/automation/doctype/assignment_rule/assignment_rule.json b/frappe/automation/doctype/assignment_rule/assignment_rule.json index 0a57e06da6..541d176967 100644 --- a/frappe/automation/doctype/assignment_rule/assignment_rule.json +++ b/frappe/automation/doctype/assignment_rule/assignment_rule.json @@ -72,6 +72,7 @@ "fieldtype": "Code", "in_list_view": 1, "label": "Assign Condition", + "options": "PythonExpression", "reqd": 1 }, { @@ -82,7 +83,8 @@ "description": "Simple Python Expression, Example: Status in (\"Closed\", \"Cancelled\")", "fieldname": "unassign_condition", "fieldtype": "Code", - "label": "Unassign Condition" + "label": "Unassign Condition", + "options": "PythonExpression" }, { "fieldname": "assign_to_users_section", @@ -120,7 +122,8 @@ "description": "Simple Python Expression, Example: Status in (\"Invalid\")", "fieldname": "close_condition", "fieldtype": "Code", - "label": "Close Condition" + "label": "Close Condition", + "options": "PythonExpression" }, { "fieldname": "sb", @@ -151,7 +154,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2020-10-20 14:47:20.662954", + "modified": "2021-07-16 22:51:35.505575", "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 ef579aca01..a3e27d4da5 100644 --- a/frappe/automation/doctype/assignment_rule/assignment_rule.py +++ b/frappe/automation/doctype/assignment_rule/assignment_rule.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/automation/doctype/assignment_rule/test_assignment_rule.py b/frappe/automation/doctype/assignment_rule/test_assignment_rule.py index e287b83965..1c9e177f94 100644 --- a/frappe/automation/doctype/assignment_rule/test_assignment_rule.py +++ b/frappe/automation/doctype/assignment_rule/test_assignment_rule.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest from frappe.utils import random_string @@ -76,7 +76,7 @@ class TestAutoAssign(unittest.TestCase): # clear 5 assignments for first user # can't do a limit in "delete" since postgres does not support it for d in frappe.get_all('ToDo', dict(reference_type = 'Note', owner = 'test@example.com'), limit=5): - frappe.db.sql("delete from tabToDo where name = %s", d.name) + frappe.db.delete("ToDo", {"name": d.name}) # add 5 more assignments for i in range(5): @@ -177,7 +177,7 @@ class TestAutoAssign(unittest.TestCase): ), 'owner'), 'test@example.com') def check_assignment_rule_scheduling(self): - frappe.db.sql("DELETE FROM `tabAssignment Rule`") + frappe.db.delete("Assignment Rule") days_1 = [dict(day = 'Sunday'), dict(day = 'Monday'), dict(day = 'Tuesday')] @@ -204,7 +204,7 @@ class TestAutoAssign(unittest.TestCase): ), 'owner'), ['test3@example.com']) def test_assignment_rule_condition(self): - frappe.db.sql("DELETE FROM `tabAssignment Rule`") + frappe.db.delete("Assignment Rule") # Add expiry_date custom field from frappe.custom.doctype.custom_field.custom_field import create_custom_field @@ -253,7 +253,7 @@ class TestAutoAssign(unittest.TestCase): assignment_rule.delete() def clear_assignments(): - frappe.db.sql("delete from tabToDo where reference_type = 'Note'") + frappe.db.delete("ToDo", {"reference_type": "Note"}) def get_assignment_rule(days, assign=None): frappe.delete_doc_if_exists('Assignment Rule', 'For Note 1') diff --git a/frappe/automation/doctype/assignment_rule_day/assignment_rule_day.py b/frappe/automation/doctype/assignment_rule_day/assignment_rule_day.py index c734495c39..836ae3d453 100644 --- a/frappe/automation/doctype/assignment_rule_day/assignment_rule_day.py +++ b/frappe/automation/doctype/assignment_rule_day/assignment_rule_day.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/automation/doctype/assignment_rule_user/assignment_rule_user.py b/frappe/automation/doctype/assignment_rule_user/assignment_rule_user.py index 4d65efd5c1..1bb8953a7a 100644 --- a/frappe/automation/doctype/assignment_rule_user/assignment_rule_user.py +++ b/frappe/automation/doctype/assignment_rule_user/assignment_rule_user.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.js b/frappe/automation/doctype/auto_repeat/auto_repeat.js index 896a10dfe0..80f2255f47 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.js +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.js @@ -30,7 +30,7 @@ frappe.ui.form.on('Auto Repeat', { refresh: function(frm) { // auto repeat message if (frm.is_new()) { - let customize_form_link = `${__('Customize Form')}`; + let customize_form_link = `${__('Customize Form')}`; frm.dashboard.set_headline(__('To configure Auto Repeat, enable "Allow Auto Repeat" from {0}.', [customize_form_link])); } diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.py b/frappe/automation/doctype/auto_repeat/auto_repeat.py index d2afda1553..5ab6c86c00 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe import _ diff --git a/frappe/automation/doctype/auto_repeat/test_auto_repeat.py b/frappe/automation/doctype/auto_repeat/test_auto_repeat.py index 567c1161af..30a0310a92 100644 --- a/frappe/automation/doctype/auto_repeat/test_auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/test_auto_repeat.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2018, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import unittest import frappe diff --git a/frappe/automation/doctype/auto_repeat_day/auto_repeat_day.py b/frappe/automation/doctype/auto_repeat_day/auto_repeat_day.py index 8af3284cde..54fc0d14e9 100644 --- a/frappe/automation/doctype/auto_repeat_day/auto_repeat_day.py +++ b/frappe/automation/doctype/auto_repeat_day/auto_repeat_day.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/automation/doctype/milestone/milestone.py b/frappe/automation/doctype/milestone/milestone.py index 6ea6d7544a..eff65571fd 100644 --- a/frappe/automation/doctype/milestone/milestone.py +++ b/frappe/automation/doctype/milestone/milestone.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/automation/doctype/milestone/test_milestone.py b/frappe/automation/doctype/milestone/test_milestone.py index 175c56e552..f8fb910072 100644 --- a/frappe/automation/doctype/milestone/test_milestone.py +++ b/frappe/automation/doctype/milestone/test_milestone.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE #import frappe import unittest diff --git a/frappe/automation/doctype/milestone_tracker/milestone_tracker.py b/frappe/automation/doctype/milestone_tracker/milestone_tracker.py index 125cad7fa8..042e7b0391 100644 --- a/frappe/automation/doctype/milestone_tracker/milestone_tracker.py +++ b/frappe/automation/doctype/milestone_tracker/milestone_tracker.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/automation/doctype/milestone_tracker/test_milestone_tracker.py b/frappe/automation/doctype/milestone_tracker/test_milestone_tracker.py index 21b2779018..f4d5f00d83 100644 --- a/frappe/automation/doctype/milestone_tracker/test_milestone_tracker.py +++ b/frappe/automation/doctype/milestone_tracker/test_milestone_tracker.py @@ -1,13 +1,13 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import frappe.cache_manager import unittest class TestMilestoneTracker(unittest.TestCase): def test_milestone(self): - frappe.db.sql('delete from `tabMilestone Tracker`') + frappe.db.delete("Milestone Tracker") frappe.cache().delete_key('milestone_tracker_map') @@ -44,5 +44,5 @@ class TestMilestoneTracker(unittest.TestCase): self.assertEqual(milestones[0].value, 'Closed') # cleanup - frappe.db.sql('delete from tabMilestone') + frappe.db.delete("Milestone") milestone_tracker.delete() \ No newline at end of file diff --git a/frappe/automation/workspace/tools/tools.json b/frappe/automation/workspace/tools/tools.json index 4a0835657b..fa2606dc43 100644 --- a/frappe/automation/workspace/tools/tools.json +++ b/frappe/automation/workspace/tools/tools.json @@ -1,22 +1,20 @@ { - "category": "Administration", "charts": [], + "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Your Shortcuts\", \"level\": 4, \"col\": 12}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"ToDo\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Note\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"File\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Assignment Rule\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Auto Repeat\", \"col\": 4}}, {\"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Tools\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Email\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Automation\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Event Streaming\", \"col\": 4}}]", "creation": "2020-03-02 14:53:24.980279", - "developer_mode_only": 0, - "disable_user_customization": 0, "docstatus": 0, "doctype": "Workspace", - "extends_another_page": 0, + "for_user": "", "hide_custom": 0, "icon": "tool", "idx": 0, - "is_standard": 1, "label": "Tools", "links": [ { "hidden": 0, "is_query_report": 0, "label": "Tools", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -25,6 +23,7 @@ "hidden": 0, "is_query_report": 0, "label": "To Do", + "link_count": 0, "link_to": "ToDo", "link_type": "DocType", "onboard": 1, @@ -35,6 +34,7 @@ "hidden": 0, "is_query_report": 0, "label": "Calendar", + "link_count": 0, "link_to": "Event", "link_type": "DocType", "onboard": 1, @@ -45,6 +45,7 @@ "hidden": 0, "is_query_report": 0, "label": "Note", + "link_count": 0, "link_to": "Note", "link_type": "DocType", "onboard": 1, @@ -55,6 +56,7 @@ "hidden": 0, "is_query_report": 0, "label": "Files", + "link_count": 0, "link_to": "File", "link_type": "DocType", "onboard": 0, @@ -65,6 +67,7 @@ "hidden": 0, "is_query_report": 0, "label": "Activity", + "link_count": 0, "link_to": "activity", "link_type": "Page", "onboard": 0, @@ -74,6 +77,7 @@ "hidden": 0, "is_query_report": 0, "label": "Email", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -82,6 +86,7 @@ "hidden": 0, "is_query_report": 0, "label": "Newsletter", + "link_count": 0, "link_to": "Newsletter", "link_type": "DocType", "onboard": 1, @@ -92,6 +97,7 @@ "hidden": 0, "is_query_report": 0, "label": "Email Group", + "link_count": 0, "link_to": "Email Group", "link_type": "DocType", "onboard": 0, @@ -101,6 +107,7 @@ "hidden": 0, "is_query_report": 0, "label": "Automation", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -109,6 +116,7 @@ "hidden": 0, "is_query_report": 0, "label": "Assignment Rule", + "link_count": 0, "link_to": "Assignment Rule", "link_type": "DocType", "onboard": 0, @@ -119,6 +127,7 @@ "hidden": 0, "is_query_report": 0, "label": "Milestone", + "link_count": 0, "link_to": "Milestone", "link_type": "DocType", "onboard": 0, @@ -129,6 +138,7 @@ "hidden": 0, "is_query_report": 0, "label": "Auto Repeat", + "link_count": 0, "link_to": "Auto Repeat", "link_type": "DocType", "onboard": 0, @@ -138,6 +148,7 @@ "hidden": 0, "is_query_report": 0, "label": "Event Streaming", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -146,6 +157,7 @@ "hidden": 0, "is_query_report": 0, "label": "Event Producer", + "link_count": 0, "link_to": "Event Producer", "link_type": "DocType", "onboard": 0, @@ -156,6 +168,7 @@ "hidden": 0, "is_query_report": 0, "label": "Event Consumer", + "link_count": 0, "link_to": "Event Consumer", "link_type": "DocType", "onboard": 0, @@ -166,6 +179,7 @@ "hidden": 0, "is_query_report": 0, "label": "Event Update Log", + "link_count": 0, "link_to": "Event Update Log", "link_type": "DocType", "onboard": 0, @@ -176,6 +190,7 @@ "hidden": 0, "is_query_report": 0, "label": "Event Sync Log", + "link_count": 0, "link_to": "Event Sync Log", "link_type": "DocType", "onboard": 0, @@ -186,19 +201,23 @@ "hidden": 0, "is_query_report": 0, "label": "Document Type Mapping", + "link_count": 0, "link_to": "Document Type Mapping", "link_type": "DocType", "onboard": 0, "type": "Link" } ], - "modified": "2020-12-01 13:38:39.950350", + "modified": "2021-08-05 12:16:02.839181", "modified_by": "Administrator", "module": "Automation", "name": "Tools", "owner": "Administrator", - "pin_to_bottom": 0, - "pin_to_top": 0, + "parent_page": "", + "public": 1, + "restrict_to_domain": "", + "roles": [], + "sequence_id": 26, "shortcuts": [ { "label": "ToDo", @@ -225,5 +244,6 @@ "link_to": "Auto Repeat", "type": "DocType" } - ] + ], + "title": "Tools" } \ No newline at end of file diff --git a/frappe/boot.py b/frappe/boot.py index feace1a66d..fd7564d75a 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE """ bootstrap client session """ @@ -107,8 +107,8 @@ def load_conf_settings(bootinfo): if key in conf: bootinfo[key] = conf.get(key) def load_desktop_data(bootinfo): - from frappe.desk.desktop import get_desk_sidebar_items - bootinfo.allowed_workspaces = get_desk_sidebar_items() + from frappe.desk.desktop import get_wspace_sidebar_items + bootinfo.allowed_workspaces = get_wspace_sidebar_items().get('pages') bootinfo.module_page_map = get_controller("Workspace").get_module_page_map() bootinfo.dashboards = frappe.get_all("Dashboard") diff --git a/frappe/build.py b/frappe/build.py index ed19574cfd..6b93b8b93a 100644 --- a/frappe/build.py +++ b/frappe/build.py @@ -1,10 +1,11 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# License: MIT. See LICENSE import os import re import json import shutil import subprocess +from subprocess import getoutput from io import StringIO from tempfile import mkdtemp, mktemp from distutils.spawn import find_executable @@ -15,8 +16,9 @@ from frappe.utils.minify import JavascriptMinify import click import psutil from urllib.parse import urlparse -from simple_chalk import green from semantic_version import Version +from requests import head +from requests.exceptions import HTTPError timestamps = {} @@ -24,6 +26,12 @@ app_paths = None sites_path = os.path.abspath(os.getcwd()) +class AssetsNotDownloadedError(Exception): + pass + +class AssetsDontExistError(HTTPError): + pass + def download_file(url, prefix): from requests import get @@ -70,81 +78,94 @@ def build_missing_files(): bundle(build_mode, apps="frappe") -def get_assets_link(frappe_head): - from subprocess import getoutput - from requests import head - +def get_assets_link(frappe_head) -> str: tag = getoutput( - r"cd ../apps/frappe && git show-ref --tags -d | grep %s | sed -e 's,.*" - r" refs/tags/,,' -e 's/\^{}//'" - % frappe_head - ) + r"cd ../apps/frappe && git show-ref --tags -d | grep %s | sed -e 's,.*" + r" refs/tags/,,' -e 's/\^{}//'" + % frappe_head + ) if tag: # if tag exists, download assets from github release - url = "https://github.com/frappe/frappe/releases/download/{0}/assets.tar.gz".format(tag) + url = f"https://github.com/frappe/frappe/releases/download/{tag}/assets.tar.gz" else: - url = "http://assets.frappeframework.com/{0}.tar.gz".format(frappe_head) + url = f"http://assets.frappeframework.com/{frappe_head}.tar.gz" if not head(url): - raise ValueError("URL {0} doesn't exist".format(url)) + reference = f"Release {tag}" if tag else f"Commit {frappe_head}" + raise AssetsDontExistError(f"Assets for {reference} don't exist") return url +def fetch_assets(url, frappe_head): + click.secho("Retrieving assets...", fg="yellow") + + prefix = mkdtemp(prefix="frappe-assets-", suffix=frappe_head) + assets_archive = download_file(url, prefix) + + if not assets_archive: + raise AssetsNotDownloadedError(f"Assets could not be retrived from {url}") + + click.echo(click.style("✔", fg="green") + f" Downloaded Frappe assets from {url}") + + return assets_archive + + +def setup_assets(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) + click.echo(click.style("✔", fg="green") + f" Restored {show}") + + return directories_created + + def download_frappe_assets(verbose=True): """Downloads and sets up Frappe assets if they exist based on the current commit HEAD. Returns True if correctly setup else returns False. """ - from subprocess import getoutput - - assets_setup = False frappe_head = getoutput("cd ../apps/frappe && git rev-parse HEAD") - if frappe_head: + if not frappe_head: + return False + + try: + url = get_assets_link(frappe_head) + assets_archive = fetch_assets(url, frappe_head) + setup_assets(assets_archive) + build_missing_files() + return True + + except AssetsDontExistError as e: + click.secho(str(e), fg="yellow") + + except Exception as e: + # TODO: log traceback in bench.log + click.secho(str(e), fg="red") + + finally: try: - url = get_assets_link(frappe_head) - 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)) - - build_missing_files() - return True - else: - raise + shutil.rmtree(os.path.dirname(assets_archive)) except Exception: - # TODO: log traceback in bench.log - click.secho("An Error occurred while downloading assets...", fg="red") - assets_setup = False - finally: - try: - shutil.rmtree(os.path.dirname(assets_archive)) - except Exception: - pass + pass - return assets_setup + return False def symlink(target, link_name, overwrite=False): @@ -224,7 +245,7 @@ def bundle(mode, apps=None, hard_link=False, make_copy=False, restore=False, ver check_node_executable() frappe_app_path = frappe.get_app_path("frappe", "..") - frappe.commands.popen(command, cwd=frappe_app_path, env=get_node_env()) + frappe.commands.popen(command, cwd=frappe_app_path, env=get_node_env(), raise_err=True) def watch(apps=None): @@ -235,6 +256,13 @@ def watch(apps=None): if apps: command += " --apps {apps}".format(apps=apps) + live_reload = frappe.utils.cint( + os.environ.get("LIVE_RELOAD", frappe.conf.live_reload) + ) + + if live_reload: + command += " --live-reload" + check_node_executable() frappe_app_path = frappe.get_app_path("frappe", "..") frappe.commands.popen(command, cwd=frappe_app_path, env=get_node_env()) @@ -350,7 +378,7 @@ def make_asset_dirs(hard_link=False): except Exception: print(fail_message, end="\r") - print(unstrip(f"{green('✔')} Application Assets Linked") + "\n") + click.echo(unstrip(click.style("✔", fg="green") + " Application Assets Linked") + "\n") def link_assets_dir(source, target, hard_link=False): diff --git a/frappe/cache_manager.py b/frappe/cache_manager.py index 52fba4568d..0df8878da4 100644 --- a/frappe/cache_manager.py +++ b/frappe/cache_manager.py @@ -1,5 +1,5 @@ # Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe, json from frappe.model.document import Document @@ -53,7 +53,7 @@ def clear_domain_cache(user=None): cache.delete_value(domain_cache_keys) def clear_global_cache(): - from frappe.website.render import clear_cache as clear_website_cache + from frappe.website.utils import clear_website_cache clear_doctype_cache() clear_website_cache() @@ -141,18 +141,13 @@ def build_table_count_cache(): return _cache = frappe.cache() - data = frappe.db.multisql({ - "mariadb": """ - SELECT table_name AS name, - table_rows AS count - FROM information_schema.tables""", - "postgres": """ - SELECT "relname" AS name, - "n_tup_ins" AS count - FROM "pg_stat_all_tables" - """ - }, as_dict=1) + table_name = frappe.qb.Field("table_name").as_("name") + table_rows = frappe.qb.Field("table_rows").as_("count") + information_schema = frappe.qb.Schema("information_schema") + data = ( + frappe.qb.from_(information_schema.tables).select(table_name, table_rows) + ).run(as_dict=True) counts = {d.get('name').lstrip('tab'): d.get('count', None) for d in data} _cache.set_value("information_schema:counts", counts) diff --git a/frappe/chat/__init__.py b/frappe/chat/__init__.py deleted file mode 100644 index 4c9b1c5db7..0000000000 --- a/frappe/chat/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ - -import frappe -from frappe import _ - -session = frappe.session - -def authenticate(user, raise_err = True): - if session.user == 'Guest': - if not frappe.db.exists('Chat Token', user): - if raise_err: - frappe.throw(_("Sorry, you're not authorized.")) - else: - return False - else: - return True - else: - if user != session.user: - if raise_err: - frappe.throw(_("Sorry, you're not authorized.")) - else: - return False - else: - return True \ No newline at end of file diff --git a/frappe/chat/doctype/chat_message/chat_message.js b/frappe/chat/doctype/chat_message/chat_message.js deleted file mode 100644 index edaad011db..0000000000 --- a/frappe/chat/doctype/chat_message/chat_message.js +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) 2017, Frappe Technologies and contributors -// For license information, please see license.txt - -frappe.ui.form.on('Chat Message', { - onload: function(frm) { - if(frm.doc.type == 'File') { - frm.set_df_property('content', 'read_only', 1); - } - } -}); diff --git a/frappe/chat/doctype/chat_message/chat_message.json b/frappe/chat/doctype/chat_message/chat_message.json deleted file mode 100644 index 9d2d70c5e0..0000000000 --- a/frappe/chat/doctype/chat_message/chat_message.json +++ /dev/null @@ -1,91 +0,0 @@ -{ - "beta": 1, - "creation": "2017-11-10 11:10:40.011099", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "room_type", - "type", - "user", - "room", - "content", - "mentions", - "urls" - ], - "fields": [ - { - "fieldname": "room_type", - "fieldtype": "Select", - "in_list_view": 1, - "label": "Room Type", - "options": "Direct\nGroup\nVisitor", - "reqd": 1 - }, - { - "fieldname": "type", - "fieldtype": "Data", - "label": "Type", - "options": "Content\nFile" - }, - { - "fieldname": "user", - "fieldtype": "Link", - "hidden": 1, - "label": "User", - "options": "User", - "read_only": 1 - }, - { - "fieldname": "room", - "fieldtype": "Link", - "label": "Room", - "options": "Chat Room", - "reqd": 1 - }, - { - "fieldname": "content", - "fieldtype": "Text", - "label": "Content", - "reqd": 1 - }, - { - "fieldname": "mentions", - "fieldtype": "Code", - "hidden": 1, - "label": "Mentions" - }, - { - "fieldname": "urls", - "fieldtype": "Data", - "hidden": 1, - "label": "URLs" - } - ], - "modified": "2020-09-18 17:26:09.703215", - "modified_by": "Administrator", - "module": "Chat", - "name": "Chat Message", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "quick_entry": 1, - "search_fields": "content, user", - "sort_field": "modified", - "sort_order": "DESC", - "title_field": "content", - "track_changes": 1, - "track_seen": 1 -} \ No newline at end of file diff --git a/frappe/chat/doctype/chat_message/chat_message.py b/frappe/chat/doctype/chat_message/chat_message.py deleted file mode 100644 index bc470a5e9c..0000000000 --- a/frappe/chat/doctype/chat_message/chat_message.py +++ /dev/null @@ -1,215 +0,0 @@ -# imports - standard imports -import json - -# imports - third-party imports -import requests -from bs4 import BeautifulSoup as Soup - -# imports - module imports -from frappe.model.document import Document -from frappe import _, _dict -import frappe - -# imports - frappe module imports -from frappe.chat import authenticate -from frappe.chat.util import ( - get_if_empty, - check_url, - dictify, - get_emojis, - safe_json_loads, - get_user_doc, - squashify -) - -session = frappe.session - -class ChatMessage(Document): - pass - -def get_message_urls(content): - soup = Soup(content, 'html.parser') - anchors = soup.find_all('a') - urls = [ ] - - for anchor in anchors: - text = anchor.text - - if check_url(text): - urls.append(text) - - return urls - -def get_message_mentions(content): - mentions = [ ] - tokens = content.split(' ') - - for token in tokens: - if token.startswith('@'): - what = token[1:] - if frappe.db.exists('User', what): - mentions.append(what) - else: - if frappe.db.exists('User', token): - mentions.append(token) - - return mentions - -def get_message_meta(content): - ''' - Assumes content to be HTML. Sanitizes the content - into a dict of metadata values. - ''' - meta = _dict( - links = [ ], - mentions = [ ] - ) - - meta.content = content - meta.urls = get_message_urls(content) - meta.mentions = get_message_mentions(content) - - return meta - -def sanitize_message_content(content): - emojis = get_emojis() - - tokens = content.split(' ') - for token in tokens: - if token.startswith(':') and token.endswith(':'): - what = token[1:-1] - - # Expensive, I know. - for emoji in emojis: - for alias in emoji.aliases: - if what == alias: - content = content.replace(token, emoji.emoji) - - return content - -def get_new_chat_message_doc(user, room, content, type = "Content", link = True): - user = get_user_doc(user) - room = frappe.get_doc('Chat Room', room) - - meta = get_message_meta(content) - mess = frappe.new_doc('Chat Message') - mess.room = room.name - mess.room_type = room.type - mess.content = sanitize_message_content(content) - mess.type = type - mess.user = user.name - - mess.mentions = json.dumps(meta.mentions) - mess.urls = ','.join(meta.urls) - mess.save(ignore_permissions = True) - - if link: - room.update(dict( - last_message = mess.name - )) - room.save(ignore_permissions = True) - - return mess - -def get_new_chat_message(user, room, content, type = "Content"): - mess = get_new_chat_message_doc(user, room, content, type) - - resp = dict( - name = mess.name, - user = mess.user, - room = mess.room, - room_type = mess.room_type, - content = json.loads(mess.content) if mess.type in ["File"] else mess.content, - urls = mess.urls, - mentions = json.loads(mess.mentions), - creation = mess.creation, - seen = json.loads(mess._seen) if mess._seen else [ ], - ) - - return resp - -@frappe.whitelist(allow_guest = True) -def send(user, room, content, type = "Content"): - mess = get_new_chat_message(user, room, content, type) - - frappe.publish_realtime('frappe.chat.message:create', mess, room = room, - after_commit = True) - -@frappe.whitelist(allow_guest = True) -def seen(message, user = None): - authenticate(user) - - has_message = frappe.db.exists('Chat Message', message) - - if has_message: - mess = frappe.get_doc('Chat Message', message) - mess.add_seen(user) - mess.load_from_db() - room = mess.room - resp = dict(message = message, data = dict(seen = json.loads(mess._seen) if mess._seen else [])) - - frappe.publish_realtime('frappe.chat.message:update', resp, room = room, after_commit = True) - -def history(room, fields = None, limit = 10, start = None, end = None): - room = frappe.get_doc('Chat Room', room) - mess = frappe.get_all('Chat Message', - filters = [ - ('Chat Message', 'room', '=', room.name), - ('Chat Message', 'room_type', '=', room.type) - ], - fields = fields if fields else [ - 'name', 'room_type', 'room', 'content', 'type', 'user', 'mentions', 'urls', 'creation', '_seen' - ], - order_by = 'creation' - ) - - if not fields or 'seen' in fields: - for m in mess: - m['seen'] = json.loads(m._seen) if m._seen else [ ] - del m['_seen'] - if not fields or 'content' in fields: - for m in mess: - m['content'] = json.loads(m.content) if m.type in ["File"] else m.content - - frappe.enqueue('frappe.chat.doctype.chat_message.chat_message.mark_messages_as_seen', - message_names=[m.name for m in mess], user=frappe.session.user) - - return mess - -def mark_messages_as_seen(message_names, user): - ''' - Marks chat messages as seen, updates the _seen for each message - (should be run in background process) - ''' - for name in message_names: - seen = frappe.db.get_value('Chat Message', name, '_seen') or '[]' - seen = json.loads(seen) - seen.append(user) - seen = json.dumps(seen) - frappe.db.set_value('Chat Message', name, '_seen', seen, update_modified=False) - - frappe.db.commit() - - -@frappe.whitelist() -def get(name, rooms = None, fields = None): - rooms, fields = safe_json_loads(rooms, fields) - - has_message = frappe.db.exists('Chat Message', name) - - if has_message: - dmess = frappe.get_doc('Chat Message', name) - data = dict( - name = dmess.name, - user = dmess.user, - room = dmess.room, - room_type = dmess.room_type, - content = json.loads(dmess.content) if dmess.type in ["File"] else dmess.content, - type = dmess.type, - urls = dmess.urls, - mentions = dmess.mentions, - creation = dmess.creation, - seen = get_if_empty(dmess._seen, [ ]) - ) - - return data \ No newline at end of file diff --git a/frappe/chat/doctype/chat_message/chat_message_list.js b/frappe/chat/doctype/chat_message/chat_message_list.js deleted file mode 100644 index c5b717048b..0000000000 --- a/frappe/chat/doctype/chat_message/chat_message_list.js +++ /dev/null @@ -1,8 +0,0 @@ -frappe.listview_settings['Chat Message'] = { - filters: [ - ['Chat Message', 'user', '==', frappe.session.user, true] - // I need an or_filter here. - // ['Chat Room', 'owner', '==', frappe.session.user, true], - // ['Chat Room', frappe.session.user, 'in', 'users', true] - ] -}; \ No newline at end of file diff --git a/frappe/chat/doctype/chat_profile/chat_profile.js b/frappe/chat/doctype/chat_profile/chat_profile.js deleted file mode 100644 index b27a98faf5..0000000000 --- a/frappe/chat/doctype/chat_profile/chat_profile.js +++ /dev/null @@ -1,10 +0,0 @@ -/* eslint semi: "never" */ -frappe.ui.form.on('Chat Profile', { - refresh: function (form) { - if ( form.doc.name !== frappe.session.user ) { - form.disable_save() - form.set_read_only(true) - // There's one more that faris@frappe.io told me to add here. form.refresh_fields()? - } - } -}); diff --git a/frappe/chat/doctype/chat_profile/chat_profile.json b/frappe/chat/doctype/chat_profile/chat_profile.json deleted file mode 100644 index eb36f803fe..0000000000 --- a/frappe/chat/doctype/chat_profile/chat_profile.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "autoname": "field:user", - "beta": 1, - "creation": "2017-11-13 18:26:57.943027", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "user", - "status", - "chat_background", - "notifications", - "message_preview", - "notification_tones", - "conversation_tones", - "settings", - "enable_chat" - ], - "fields": [ - { - "fieldname": "user", - "fieldtype": "Link", - "label": "User", - "options": "User", - "reqd": 1 - }, - { - "default": "Online", - "fieldname": "status", - "fieldtype": "Select", - "in_list_view": 1, - "label": "Status", - "options": "Online\nAway\nBusy\nOffline" - }, - { - "fieldname": "chat_background", - "fieldtype": "Attach Image", - "label": "Chat Background" - }, - { - "fieldname": "notifications", - "fieldtype": "Section Break", - "label": "Notifications" - }, - { - "default": "1", - "fieldname": "message_preview", - "fieldtype": "Check", - "label": "Message Preview" - }, - { - "default": "1", - "fieldname": "notification_tones", - "fieldtype": "Check", - "label": "Notification Tones" - }, - { - "default": "1", - "fieldname": "conversation_tones", - "fieldtype": "Check", - "label": "Conversation Tones" - }, - { - "fieldname": "settings", - "fieldtype": "Section Break", - "label": "Settings" - }, - { - "default": "1", - "fieldname": "enable_chat", - "fieldtype": "Check", - "label": "Enable Chat" - } - ], - "in_create": 1, - "modified": "2019-11-07 13:21:36.414961", - "modified_by": "Administrator", - "module": "Chat", - "name": "Chat 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/chat/doctype/chat_profile/chat_profile.py b/frappe/chat/doctype/chat_profile/chat_profile.py deleted file mode 100644 index da10a836c4..0000000000 --- a/frappe/chat/doctype/chat_profile/chat_profile.py +++ /dev/null @@ -1,98 +0,0 @@ -# imports - module imports -from frappe.model.document import Document -from frappe import _ -import frappe - -# imports - frappe module imports -from frappe.core.doctype.version.version import get_diff -from frappe.chat.doctype.chat_room import chat_room -from frappe.chat.util import ( - safe_json_loads, - filter_dict, - dictify -) - -session = frappe.session - -class ChatProfile(Document): - def on_update(self): - if not self.is_new(): - b, a = self.get_doc_before_save(), self - diff = dictify(get_diff(a, b)) - if diff: - user = session.user - - fields = [changed[0] for changed in diff.changed] - - if 'status' in fields: - rooms = chat_room.get(user, filters = ['Chat Room', 'type', '=', 'Direct']) - update = dict(user = user, data = dict(status = self.status)) - - for room in rooms: - frappe.publish_realtime('frappe.chat.profile:update', update, room = room.name, after_commit = True) - - if 'enable_chat' in fields: - update = dict(user = user, data = dict(enable_chat = bool(self.enable_chat))) - frappe.publish_realtime('frappe.chat.profile:update', update, user = user, after_commit = True) - -def authenticate(user): - if user != session.user: - frappe.throw(_("Sorry, you're not authorized.")) - -@frappe.whitelist() -def get(user, fields = None): - duser = frappe.get_doc('User', user) - - if frappe.db.exists('Chat Profile', user): - dprof = frappe.get_doc('Chat Profile', user) - - # If you're adding something here, make sure the client recieves it. - profile = dict( - # User - name = duser.name, - email = duser.email, - first_name = duser.first_name, - last_name = duser.last_name, - username = duser.username, - avatar = duser.user_image, - bio = duser.bio, - # Chat Profile - status = dprof.status, - chat_background = dprof.chat_background, - message_preview = bool(dprof.message_preview), - notification_tones = bool(dprof.notification_tones), - conversation_tones = bool(dprof.conversation_tones), - enable_chat = bool(dprof.enable_chat) - ) - profile = filter_dict(profile, fields) - - return dictify(profile) - -@frappe.whitelist() -def create(user, exists_ok = False, fields = None): - authenticate(user) - - exists_ok, fields = safe_json_loads(exists_ok, fields) - - try: - dprof = frappe.new_doc('Chat Profile') - dprof.user = user - dprof.save(ignore_permissions = True) - except frappe.DuplicateEntryError: - frappe.clear_messages() - if not exists_ok: - frappe.throw(_('Chat Profile for User {0} exists.').format(user)) - - profile = get(user, fields = fields) - - return profile - -@frappe.whitelist() -def update(user, data): - authenticate(user) - - data = safe_json_loads(data) - - dprof = frappe.get_doc('Chat Profile', user) - dprof.update(data) - dprof.save(ignore_permissions = True) \ No newline at end of file diff --git a/frappe/chat/doctype/chat_profile/chat_profile_list.js b/frappe/chat/doctype/chat_profile/chat_profile_list.js deleted file mode 100644 index 4d97b75e65..0000000000 --- a/frappe/chat/doctype/chat_profile/chat_profile_list.js +++ /dev/null @@ -1,11 +0,0 @@ -frappe.listview_settings['Chat Profile'] = -{ - get_indicator: function (doc) - { - const status = frappe.utils.squash(frappe.chat.profile.STATUSES.filter( - s => s.name === doc.status - )); - - return [__(status.name), status.color, `status,=,${status.name}`] - } -}; \ No newline at end of file diff --git a/frappe/chat/doctype/chat_room/chat_room.js b/frappe/chat/doctype/chat_room/chat_room.js deleted file mode 100644 index 00b9c8d8f7..0000000000 --- a/frappe/chat/doctype/chat_room/chat_room.js +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2017, Frappe Technologies and contributors -// For license information, please see license.txt - -frappe.ui.form.on('Chat Room', { - refresh: function (form) { - - } -}); diff --git a/frappe/chat/doctype/chat_room/chat_room.json b/frappe/chat/doctype/chat_room/chat_room.json deleted file mode 100644 index 1417306c45..0000000000 --- a/frappe/chat/doctype/chat_room/chat_room.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "autoname": "CR.#####", - "beta": 1, - "creation": "2017-11-08 15:27:21.156667", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "type", - "room_name", - "avatar", - "last_message", - "message_count", - "owner", - "user_list", - "users" - ], - "fields": [ - { - "default": "Direct", - "fieldname": "type", - "fieldtype": "Select", - "in_list_view": 1, - "label": "Type", - "options": "Direct\nGroup\nVisitor", - "reqd": 1, - "set_only_once": 1 - }, - { - "depends_on": "eval:doc.type==\"Group\"", - "fieldname": "room_name", - "fieldtype": "Data", - "label": "Name" - }, - { - "depends_on": "eval:doc.type==\"Group\"", - "fieldname": "avatar", - "fieldtype": "Attach Image", - "hidden": 1, - "label": "Avatar" - }, - { - "fieldname": "last_message", - "fieldtype": "Data", - "hidden": 1, - "label": "Last Message" - }, - { - "fieldname": "message_count", - "fieldtype": "Int", - "hidden": 1, - "label": "Message Count" - }, - { - "fieldname": "owner", - "fieldtype": "Data", - "hidden": 1, - "label": "Owner", - "read_only": 1 - }, - { - "fieldname": "user_list", - "fieldtype": "Section Break", - "label": "Users" - }, - { - "fieldname": "users", - "fieldtype": "Table", - "label": "Users", - "options": "Chat Room User" - } - ], - "image_field": "avatar", - "modified": "2019-11-07 13:20:24.625329", - "modified_by": "Administrator", - "module": "Chat", - "name": "Chat Room", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "set_user_permissions": 1, - "share": 1, - "write": 1 - } - ], - "search_fields": "room_name", - "show_name_in_global_search": 1, - "sort_field": "modified", - "sort_order": "DESC", - "title_field": "room_name", - "track_changes": 1 -} \ No newline at end of file diff --git a/frappe/chat/doctype/chat_room/chat_room.py b/frappe/chat/doctype/chat_room/chat_room.py deleted file mode 100644 index bdbee44d7a..0000000000 --- a/frappe/chat/doctype/chat_room/chat_room.py +++ /dev/null @@ -1,227 +0,0 @@ -# imports - module imports -from frappe.model.document import Document -from frappe import _ -import frappe - -# imports - frappe module imports -from frappe.chat import authenticate -from frappe.core.doctype.version.version import get_diff -from frappe.chat.doctype.chat_message import chat_message -from frappe.chat.util import ( - safe_json_loads, - dictify, - listify, - squashify, - get_if_empty -) - -session = frappe.session - - -def is_direct(owner, other, bidirectional=False): - def get_room(owner, other): - room = frappe.get_all('Chat Room', filters=[ - ['Chat Room', 'type', 'in', ('Direct', 'Visitor')], - ['Chat Room', 'owner', '=', owner], - ['Chat Room User', 'user', '=', other] - ], distinct=True) - - return room - - exists = len(get_room(owner, other)) == 1 - if bidirectional: - exists = exists or len(get_room(other, owner)) == 1 - - return exists - - -def get_chat_room_user_set(users, filter_=None): - seen, uset = set(), list() - - for u in users: - if filter_(u) and u.user not in seen: - uset.append(u) - seen.add(u.user) - - return uset - - -class ChatRoom(Document): - def validate(self): - if self.is_new(): - users = get_chat_room_user_set(self.users, filter_=lambda u: u.user != session.user) - self.update(dict( - users=users - )) - - if self.type == "Direct": - if len(self.users) != 1: - frappe.throw(_('{0} room must have atmost one user.').format(self.type)) - - other = squashify(self.users) - - if self.is_new(): - if is_direct(self.owner, other.user, bidirectional=True): - frappe.throw(_('Direct room with {0} already exists.').format(other.user)) - - if self.type == "Group" and not self.room_name: - frappe.throw(_('Group name cannot be empty.')) - - def on_update(self): - if not self.is_new(): - before = self.get_doc_before_save() - if not before: return - - after = self - diff = dictify(get_diff(before, after)) - if diff: - update = {} - for changed in diff.changed: - field, old, new = changed - - if field == 'last_message': - new = chat_message.get(new) - - update.update({field: new}) - - if diff.added or diff.removed: - update.update(dict(users=[u.user for u in self.users])) - - update = dict(room=self.name, data=update) - - frappe.publish_realtime('frappe.chat.room:update', update, room=self.name, - after_commit=True) - - -@frappe.whitelist(allow_guest=True) -def get(user=None, token=None, rooms=None, fields=None, filters=None): - # There is this horrible bug out here. - # Looks like if frappe.call sends optional arguments (not in right order), - # the argument turns to an empty string. - # I'm not even going to think searching for it. - # Hence, the hack was get_if_empty (previous assign_if_none) - # - Achilles Rasquinha achilles@frappe.io - data = user or token - authenticate(data) - - rooms, fields, filters = safe_json_loads(rooms, fields, filters) - - rooms = listify(get_if_empty(rooms, [])) - fields = listify(get_if_empty(fields, [])) - - const = [] # constraints - if rooms: - const.append(['Chat Room', 'name', 'in', rooms]) - if filters: - if isinstance(filters[0], list): - const = const + filters - else: - const.append(filters) - - default = ['name', 'type', 'room_name', 'creation', 'owner', 'avatar'] - handle = ['users', 'last_message'] - - param = [f for f in fields if f not in handle] - - rooms = frappe.get_all('Chat Room', - or_filters=[ - ['Chat Room', 'owner', '=', frappe.session.user], - ['Chat Room User', 'user', '=', frappe.session.user] - ], - filters=const, - fields=param + ['name'] if param else default, - distinct=True - ) - - if not fields or 'users' in fields: - for i, r in enumerate(rooms): - droom = frappe.get_doc('Chat Room', r.name) - rooms[i]['users'] = [] - - for duser in droom.users: - rooms[i]['users'].append(duser.user) - - if not fields or 'last_message' in fields: - for i, r in enumerate(rooms): - droom = frappe.get_doc('Chat Room', r.name) - if droom.last_message: - rooms[i]['last_message'] = chat_message.get(droom.last_message) - else: - rooms[i]['last_message'] = None - - rooms = squashify(dictify(rooms)) - - return rooms - - -@frappe.whitelist(allow_guest=True) -def create(kind, token, users=None, name=None): - authenticate(token) - - users = safe_json_loads(users) - create = True - - if kind == 'Visitor': - room = squashify(frappe.db.sql(""" - SELECT name - FROM `tabChat Room` - WHERE owner=%s - """, (frappe.session.user), as_dict=True)) - - if room: - room = frappe.get_doc('Chat Room', room.name) - create = False - - if create: - room = frappe.new_doc('Chat Room') - room.type = kind - room.owner = frappe.session.user - room.room_name = name - - dusers = [] - - if kind != 'Visitor': - if users: - users = listify(users) - for user in users: - duser = frappe.new_doc('Chat Room User') - duser.user = user - dusers.append(duser) - - room.users = dusers - else: - dsettings = frappe.get_single('Website Settings') - room.room_name = dsettings.chat_room_name - - users = [user for user in room.users] if hasattr(room, 'users') else [] - - for user in dsettings.chat_operators: - if user.user not in users: - # appending user to room.users will remove the user from chat_operators - # this is undesirable, create a new Chat Room User instead - chat_room_user = {"doctype": "Chat Room User", "user": user.user} - room.append('users', chat_room_user) - - room.save(ignore_permissions=True) - - room = get(token=token, rooms=room.name) - if room: - users = [room.owner] + [u for u in room.users] - - for user in users: - frappe.publish_realtime('frappe.chat.room:create', room, user=user, after_commit=True) - - return room - - -@frappe.whitelist(allow_guest=True) -def history(room, user, fields=None, limit=10, start=None, end=None): - if frappe.get_doc('Chat Room', room).type != 'Visitor': - authenticate(user) - - fields = safe_json_loads(fields) - - mess = chat_message.history(room, limit=limit, start=start, end=end) - mess = squashify(mess) - - return dictify(mess) diff --git a/frappe/chat/doctype/chat_room/chat_room_list.js b/frappe/chat/doctype/chat_room/chat_room_list.js deleted file mode 100644 index 70c708c7bd..0000000000 --- a/frappe/chat/doctype/chat_room/chat_room_list.js +++ /dev/null @@ -1,6 +0,0 @@ -frappe.listview_settings['Chat Room'] = { - filters: [ - ['Chat Room', 'owner', '=', frappe.session.user, true], - ['Chat Room User', 'user', '=', frappe.session.user, true] - ] -}; \ No newline at end of file diff --git a/frappe/chat/doctype/chat_room_user/chat_room_user.json b/frappe/chat/doctype/chat_room_user/chat_room_user.json deleted file mode 100644 index f7bdf6706b..0000000000 --- a/frappe/chat/doctype/chat_room_user/chat_room_user.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "beta": 1, - "creation": "2017-11-08 15:24:21.029314", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "user", - "is_admin" - ], - "fields": [ - { - "fieldname": "user", - "fieldtype": "Link", - "in_list_view": 1, - "label": "User", - "options": "User", - "reqd": 1 - }, - { - "default": "0", - "fieldname": "is_admin", - "fieldtype": "Check", - "label": "Admin" - } - ], - "in_create": 1, - "istable": 1, - "modified": "2019-11-07 13:21:05.297337", - "modified_by": "Administrator", - "module": "Chat", - "name": "Chat Room User", - "owner": "Administrator", - "permissions": [], - "quick_entry": 1, - "read_only": 1, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1 -} \ No newline at end of file diff --git a/frappe/chat/doctype/chat_room_user/chat_room_user.py b/frappe/chat/doctype/chat_room_user/chat_room_user.py deleted file mode 100644 index f6dbdc7659..0000000000 --- a/frappe/chat/doctype/chat_room_user/chat_room_user.py +++ /dev/null @@ -1,8 +0,0 @@ -# imports - module imports -from frappe.model.document import Document -import frappe - -session = frappe.session - -class ChatRoomUser(Document): - pass \ No newline at end of file diff --git a/frappe/chat/doctype/chat_token/chat_token.js b/frappe/chat/doctype/chat_token/chat_token.js deleted file mode 100644 index 78f03026ec..0000000000 --- a/frappe/chat/doctype/chat_token/chat_token.js +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2018, Frappe Technologies and contributors -// For license information, please see license.txt - -frappe.ui.form.on('Chat Token', { - refresh: function(frm) { - - } -}); diff --git a/frappe/chat/doctype/chat_token/chat_token.json b/frappe/chat/doctype/chat_token/chat_token.json deleted file mode 100644 index b73505ac2c..0000000000 --- a/frappe/chat/doctype/chat_token/chat_token.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "autoname": "field:token", - "beta": 1, - "creation": "2018-03-26 18:20:13.825652", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "token", - "ip_address", - "country" - ], - "fields": [ - { - "fieldname": "token", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Token", - "reqd": 1 - }, - { - "fieldname": "ip_address", - "fieldtype": "Data", - "label": "IP Address" - }, - { - "fieldname": "country", - "fieldtype": "Data", - "label": "Country" - } - ], - "in_create": 1, - "modified": "2019-11-07 13:21:24.514558", - "modified_by": "Administrator", - "module": "Chat", - "name": "Chat Token", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "quick_entry": 1, - "read_only": 1, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1 -} \ No newline at end of file diff --git a/frappe/chat/doctype/chat_token/chat_token.py b/frappe/chat/doctype/chat_token/chat_token.py deleted file mode 100644 index 63d69a58be..0000000000 --- a/frappe/chat/doctype/chat_token/chat_token.py +++ /dev/null @@ -1,9 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2018, Frappe Technologies and contributors -# For license information, please see license.txt - -import frappe -from frappe.model.document import Document - -class ChatToken(Document): - pass diff --git a/frappe/chat/util/__init__.py b/frappe/chat/util/__init__.py deleted file mode 100644 index 383df581cd..0000000000 --- a/frappe/chat/util/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# imports - module imports -from frappe.chat.util.util import ( - get_user_doc, - squashify, - safe_json_loads, - filter_dict, - get_if_empty, - listify, - dictify, - check_url, - create_test_user, - get_emojis -) \ No newline at end of file diff --git a/frappe/chat/util/test_util.py b/frappe/chat/util/test_util.py deleted file mode 100644 index e2d05a4024..0000000000 --- a/frappe/chat/util/test_util.py +++ /dev/null @@ -1,35 +0,0 @@ -# imports - standard imports -import unittest - -# imports - module imports -from frappe.chat.util import ( - get_user_doc, - safe_json_loads -) -import frappe - -class TestChatUtil(unittest.TestCase): - def test_safe_json_loads(self): - number = safe_json_loads("1") - self.assertEqual(type(number), int) - - number = safe_json_loads("1.0") - self.assertEqual(type(number), float) - - string = safe_json_loads("foobar") - self.assertEqual(type(string), str) - - array = safe_json_loads('[{ "foo": "bar" }]') - self.assertEqual(type(array), list) - - objekt = safe_json_loads('{ "foo": "bar" }') - self.assertEqual(type(objekt), dict) - - true, null = safe_json_loads("true", "null") - self.assertEqual(true, True) - self.assertEqual(null, None) - - def test_get_user_doc(self): - # Needs more test cases. - user = get_user_doc() - self.assertEqual(user.name, frappe.session.user) \ No newline at end of file diff --git a/frappe/chat/util/util.py b/frappe/chat/util/util.py deleted file mode 100644 index b7e7991c2b..0000000000 --- a/frappe/chat/util/util.py +++ /dev/null @@ -1,108 +0,0 @@ -# imports - standard imports -import json -from collections.abc import MutableMapping, MutableSequence, Sequence - -# imports - third-party imports -import requests -from urllib.parse import urlparse - -# imports - module imports -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 - - user = user or session.user - user = frappe.get_doc('User', user) - - return user - -def squashify(what): - if isinstance(what, Sequence) and len(what) == 1: - return what[0] - - return what - -def safe_json_loads(*args): - results = [] - - for arg in args: - try: - arg = json.loads(arg) - except Exception: - pass - - results.append(arg) - - return squashify(results) - -def filter_dict(what, keys, ignore = False): - copy = dict() - - if keys: - for k in keys: - if k not in what and not ignore: - raise KeyError('{key} not in dict.'.format(key = k)) - else: - copy.update({ - k: what[k] - }) - else: - copy = what.copy() - - return copy - -def get_if_empty(a, b): - if not a: - a = b - return a - -def listify(arg): - if not isinstance(arg, list): - arg = [arg] - return arg - -def dictify(arg): - if isinstance(arg, MutableSequence): - for i, a in enumerate(arg): - arg[i] = dictify(a) - elif isinstance(arg, MutableMapping): - arg = frappe._dict(arg) - - return arg - -def check_url(what, raise_err = False): - if not urlparse(what).scheme: - if raise_err: - raise ValueError('{what} not a valid URL.') - else: - return False - - return True - -def create_test_user(module): - try: - test_user = frappe.new_doc('User') - test_user.first_name = '{module}'.format(module = module) - test_user.email = 'testuser.{module}@example.com'.format(module = module) - test_user.save() - except DuplicateEntryError: - frappe.log('Test User Chat Profile exists.') - -def get_emojis(): - redis = frappe.cache() - emojis = redis.hget('frappe_emojis', 'emojis') - - if not emojis: - resp = requests.get('http://git.io/frappe-emoji') - if resp.ok: - emojis = resp.json() - redis.hset('frappe_emojis', 'emojis', emojis) - - return dictify(emojis) diff --git a/frappe/chat/website/__init__.py b/frappe/chat/website/__init__.py deleted file mode 100644 index 12affd2782..0000000000 --- a/frappe/chat/website/__init__.py +++ /dev/null @@ -1,42 +0,0 @@ - -import frappe -from frappe.chat.util import filter_dict, safe_json_loads - -from frappe.sessions import get_geo_ip_country - -@frappe.whitelist(allow_guest = True) -def settings(fields = None): - fields = safe_json_loads(fields) - - dsettings = frappe.get_single('Website Settings') - response = dict( - socketio = dict( - port = frappe.conf.socketio_port - ), - enable = bool(dsettings.chat_enable), - enable_from = dsettings.chat_enable_from, - enable_to = dsettings.chat_enable_to, - room_name = dsettings.chat_room_name, - welcome_message = dsettings.chat_welcome_message, - operators = [ - duser.user for duser in dsettings.chat_operators - ] - ) - - if fields: - response = filter_dict(response, fields) - - return response - -@frappe.whitelist(allow_guest = True) -def token(): - dtoken = frappe.new_doc('Chat Token') - - dtoken.token = frappe.generate_hash() - dtoken.ip_address = frappe.local.request_ip - country = get_geo_ip_country(dtoken.ip_address) - if country: - dtoken.country = country['iso_code'] - dtoken.save(ignore_permissions = True) - - return dtoken.token \ No newline at end of file diff --git a/frappe/client.py b/frappe/client.py index 66c457e893..a3ed0fa37d 100644 --- a/frappe/client.py +++ b/frappe/client.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe from frappe import _ import frappe.model @@ -87,7 +87,7 @@ def get_value(doctype, fieldname, filters=None, as_dict=True, debug=False, paren filters = {"name": filters} try: - fields = json.loads(fieldname) + fields = frappe.parse_json(fieldname) except (TypeError, ValueError): # name passed, not json fields = [fieldname] @@ -258,6 +258,12 @@ def set_default(key, value, parent=None): frappe.db.set_default(key, value, parent or frappe.session.user) frappe.clear_cache(user=frappe.session.user) +@frappe.whitelist() +def get_default(key, parent=None): + """set a user default value""" + return frappe.db.get_default(key, parent) + + @frappe.whitelist(methods=['POST', 'PUT']) def make_width_property_setter(doc): '''Set width Property Setter @@ -276,18 +282,17 @@ def bulk_update(docs): docs = json.loads(docs) failed_docs = [] for doc in docs: + doc.pop("flags", None) try: - ddoc = {key: val for key, val in doc.items() if key not in ['doctype', 'docname']} - doctype = doc['doctype'] - docname = doc['docname'] - doc = frappe.get_doc(doctype, docname) - doc.update(ddoc) - doc.save() - except: + existing_doc = frappe.get_doc(doc["doctype"], doc["docname"]) + existing_doc.update(doc) + existing_doc.save() + except Exception: failed_docs.append({ 'doc': doc, 'exc': frappe.utils.get_traceback() }) + return {'failed_docs': failed_docs} @frappe.whitelist() @@ -400,3 +405,45 @@ def is_document_amended(doctype, docname): pass return False + +@frappe.whitelist() +def validate_link(doctype: str, docname: str, fields=None): + if not isinstance(doctype, str): + frappe.throw(_("DocType must be a string")) + + if not isinstance(docname, str): + frappe.throw(_("Document Name must be a string")) + + if doctype != "DocType" and not ( + frappe.has_permission(doctype, "select") + or frappe.has_permission(doctype, "read") + ): + frappe.throw( + _("You do not have Read or Select Permissions for {}") + .format(frappe.bold(doctype)), + frappe.PermissionError + ) + + values = frappe._dict() + values.name = frappe.db.get_value(doctype, docname, cache=True) + + fields = frappe.parse_json(fields) + if not values.name or not fields: + return values + + try: + values.update(get_value(doctype, fields, docname)) + except frappe.PermissionError: + frappe.clear_last_message() + frappe.msgprint( + _("You need {0} permission to fetch values from {1} {2}") + .format( + frappe.bold(_("Read")), + frappe.bold(doctype), + frappe.bold(docname) + ), + title=_("Cannot Fetch Values"), + indicator="orange" + ) + + return values diff --git a/frappe/commands/__init__.py b/frappe/commands/__init__.py index be9d107025..82a71ce7b4 100644 --- a/frappe/commands/__init__.py +++ b/frappe/commands/__init__.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Web Notes Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import sys import click @@ -102,7 +102,24 @@ def get_commands(): from .site import commands as site_commands from .translate import commands as translate_commands from .utils import commands as utils_commands + from .redis_utils import commands as redis_commands + + clickable_link = ( + "\x1b]8;;https://frappeframework.com/docs\afrappeframework.com\x1b]8;;\a" + ) + all_commands = ( + scheduler_commands + + site_commands + + translate_commands + + utils_commands + + redis_commands + ) + + for command in all_commands: + if not command.help: + command.help = f"Refer to {clickable_link}" + + return all_commands - return list(set(scheduler_commands + site_commands + translate_commands + utils_commands)) commands = get_commands() diff --git a/frappe/commands/redis_utils.py b/frappe/commands/redis_utils.py new file mode 100644 index 0000000000..3556050782 --- /dev/null +++ b/frappe/commands/redis_utils.py @@ -0,0 +1,53 @@ +import os + +import click + +import frappe +from frappe.utils.redis_queue import RedisQueue +from frappe.installer import update_site_config + +@click.command('create-rq-users') +@click.option('--set-admin-password', is_flag=True, default=False, help='Set new Redis admin(default user) password') +@click.option('--use-rq-auth', is_flag=True, default=False, help='Enable Redis authentication for sites') +def create_rq_users(set_admin_password=False, use_rq_auth=False): + """Create Redis Queue users and add to acl and app configs. + + acl config file will be used by redis server while starting the server + and app config is used by app while connecting to redis server. + """ + acl_file_path = os.path.abspath('../config/redis_queue.acl') + + with frappe.init_site(): + acl_list, user_credentials = RedisQueue.gen_acl_list( + set_admin_password=set_admin_password) + + with open(acl_file_path, 'w') as f: + f.writelines([acl+'\n' for acl in acl_list]) + + sites_path = os.getcwd() + common_site_config_path = os.path.join(sites_path, 'common_site_config.json') + update_site_config("rq_username", user_credentials['bench'][0], validate=False, + site_config_path=common_site_config_path) + update_site_config("rq_password", user_credentials['bench'][1], validate=False, + site_config_path=common_site_config_path) + update_site_config("use_rq_auth", use_rq_auth, validate=False, + site_config_path=common_site_config_path) + + click.secho('* ACL and site configs are updated with new user credentials. ' + 'Please restart Redis Queue server to enable namespaces.', + fg='green') + + if set_admin_password: + env_key = 'RQ_ADMIN_PASWORD' + click.secho('* Redis admin password is successfully set up. ' + 'Include below line in .bashrc file for system to use', + fg='green') + click.secho(f"`export {env_key}={user_credentials['default'][1]}`") + click.secho('NOTE: Please save the admin password as you ' + 'can not access redis server without the password', + fg='yellow') + + +commands = [ + create_rq_users +] diff --git a/frappe/commands/scheduler.py b/frappe/commands/scheduler.py index d69ebb3024..f82473fd55 100755 --- a/frappe/commands/scheduler.py +++ b/frappe/commands/scheduler.py @@ -172,9 +172,13 @@ def start_scheduler(): @click.command('worker') @click.option('--queue', type=str) @click.option('--quiet', is_flag = True, default = False, help = 'Hide Log Outputs') -def start_worker(queue, quiet = False): +@click.option('-u', '--rq-username', default=None, help='Redis ACL user') +@click.option('-p', '--rq-password', default=None, help='Redis ACL user password') +def start_worker(queue, quiet = False, rq_username=None, rq_password=None): + """Site is used to find redis credentals. + """ from frappe.utils.background_jobs import start_worker - start_worker(queue, quiet = quiet) + start_worker(queue, quiet = quiet, rq_username=rq_username, rq_password=rq_password) @click.command('ready-for-migration') @click.option('--site', help='site name') diff --git a/frappe/commands/site.py b/frappe/commands/site.py index 22a063651c..c5f78e2680 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -55,8 +55,11 @@ def new_site(site, mariadb_root_username=None, mariadb_root_password=None, admin @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='Ignore the validations and downgrade warnings. This action is not recommended') +@click.option('--encryption-key', help='Backup encryption key') @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): +def restore(context, sql_file_path, encryption_key=None, 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 ( _new_site, @@ -66,27 +69,75 @@ def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_pas is_partial, validate_database_sql ) - - force = context.force or force - decompressed_file_name = extract_sql_from_archive(sql_file_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" - ) + from frappe.utils.backups import Backup + if not os.path.exists(sql_file_path): + print("Invalid path", sql_file_path) sys.exit(1) - # check if valid SQL file - validate_database_sql(decompressed_file_name, _raise=not force) + _backup = Backup(sql_file_path) site = get_site(context) frappe.init(site=site) + force = context.force or force + + try: + decompressed_file_name = extract_sql_from_archive(sql_file_path) + 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" + ) + _backup.decryption_rollback() + sys.exit(1) + + except UnicodeDecodeError: + _backup.decryption_rollback() + if encryption_key: + click.secho( + "Encrypted backup file detected. Decrypting using provided key.", + fg="yellow" + ) + _backup.backup_decryption(encryption_key) + + else: + click.secho( + "Encrypted backup file detected. Decrypting using site config.", + fg="yellow" + ) + encryption_key = frappe.get_site_config().encryption_key + _backup.backup_decryption(encryption_key) + + # Rollback on unsuccessful decryrption + if not os.path.exists(sql_file_path): + click.secho( + "Decryption failed. Please provide a valid key and try again.", + fg="red" + ) + + _backup.decryption_rollback() + sys.exit(1) + + decompressed_file_name = extract_sql_from_archive(sql_file_path) + + 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" + ) + _backup.decryption_rollback() + sys.exit(1) + + + + validate_database_sql(decompressed_file_name, _raise=not force) # dont allow downgrading to older versions of frappe without force if not force and is_downgrade(decompressed_file_name, verbose=True): @@ -96,23 +147,51 @@ def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_pas ) click.confirm(warn_message, abort=True) - _new_site(frappe.conf.db_name, site, mariadb_root_username=mariadb_root_username, - mariadb_root_password=mariadb_root_password, admin_password=admin_password, - verbose=context.verbose, install_apps=install_app, source_sql=decompressed_file_name, - force=True, db_type=frappe.conf.db_type) - # Extract public and/or private files to the restored site, if user has given the path - if with_public_files: - public = extract_files(site, with_public_files) - os.remove(public) - if with_private_files: - private = extract_files(site, with_private_files) - os.remove(private) + try: + _new_site(frappe.conf.db_name, site, mariadb_root_username=mariadb_root_username, + mariadb_root_password=mariadb_root_password, admin_password=admin_password, + verbose=context.verbose, install_apps=install_app, source_sql=decompressed_file_name, + force=True, db_type=frappe.conf.db_type) + + except Exception as err: + print(err.args[1]) + _backup.decryption_rollback() + sys.exit(1) # Removing temporarily created file if decompressed_file_name != sql_file_path: os.remove(decompressed_file_name) + _backup.decryption_rollback() + + # Extract public and/or private files to the restored site, if user has given the path + if with_public_files: + # Decrypt data if there is a Key + if encryption_key: + _backup = Backup(with_public_files) + _backup.backup_decryption(encryption_key) + if not os.path.exists(with_public_files): + _backup.decryption_rollback() + public = extract_files(site, with_public_files) + + # Removing temporarily created file + os.remove(public) + _backup.decryption_rollback() + + + if with_private_files: + # Decrypt data if there is a Key + if encryption_key: + _backup = Backup(with_private_files) + _backup.backup_decryption(encryption_key) + if not os.path.exists(with_private_files): + _backup.decryption_rollback() + private = extract_files(site, with_private_files) + + # Removing temporarily created file + os.remove(private) + _backup.decryption_rollback() success_message = "Site {0} has been restored{1}".format( site, @@ -120,19 +199,92 @@ def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_pas ) click.secho(success_message, fg="green") - @click.command('partial-restore') @click.argument('sql-file-path') @click.option("--verbose", "-v", is_flag=True) +@click.option('--encryption-key', help='Backup encryption key') @pass_context -def partial_restore(context, sql_file_path, verbose): - from frappe.installer import partial_restore - verbose = context.verbose or verbose +def partial_restore(context, sql_file_path, verbose, encryption_key=None): + from frappe.installer import partial_restore, extract_sql_from_archive + from frappe.utils.backups import Backup + + if not os.path.exists(sql_file_path): + print("Invalid path", sql_file_path) + sys.exit(1) site = get_site(context) frappe.init(site=site) + + _backup = Backup(sql_file_path) + + verbose = context.verbose or verbose + frappe.connect(site=site) + try: + decompressed_file_name = extract_sql_from_archive(sql_file_path) + + with open(decompressed_file_name) as f: + header = " ".join(f.readline() for _ in range(5)) + + #Check for full backup file + if "Partial Backup" not in header: + click.secho( + "Full backup file detected.Use `bench restore` to restore a Frappe Site.", + fg="red" + ) + _backup.decryption_rollback() + sys.exit(1) + + + except UnicodeDecodeError: + _backup.decryption_rollback() + if encryption_key: + click.secho( + "Encrypted backup file detected. Decrypting using provided key.", + fg="yellow" + ) + key = encryption_key + + else: + click.secho( + "Encrypted backup file detected. Decrypting using site config.", + fg="yellow" + ) + key = frappe.get_site_config().encryption_key + + _backup.backup_decryption(key) + + # Rollback on unsuccessful decryrption + if not os.path.exists(sql_file_path): + click.secho( + "Decryption failed. Please provide a valid key and try again.", + fg="red" + ) + _backup.decryption_rollback() + sys.exit(1) + + decompressed_file_name = extract_sql_from_archive(sql_file_path) + + with open(decompressed_file_name) as f: + header = " ".join(f.readline() for _ in range(5)) + + #Check for Full backup file. + if "Partial Backup" not in header: + click.secho( + "Full Backup file detected.Use `bench restore` to restore a Frappe Site.", + fg="red" + ) + _backup.decryption_rollback() + sys.exit(1) + + partial_restore(sql_file_path, verbose) + + # Removing temporarily created file + _backup.decryption_rollback() + if os.path.exists(sql_file_path.rstrip(".gz")): + os.remove(sql_file_path.rstrip(".gz")) + frappe.destroy() @@ -193,7 +345,7 @@ def install_app(context, apps): print("App {} is Incompatible with Site {}{}".format(app, site, err_msg)) exit_code = 1 except Exception as err: - err_msg = ":\n{}".format(err if str(err) else frappe.get_traceback()) + err_msg = ": {}\n{}".format(str(err), frappe.get_traceback()) print("An error occurred while installing {}{}".format(app, err_msg)) exit_code = 1 @@ -418,6 +570,7 @@ def backup(context, with_files=False, backup_path=None, backup_path_db=None, bac 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 @@ -441,14 +594,25 @@ def backup(context, with_files=False, backup_path=None, backup_path_db=None, bac force=True ) except Exception: - click.secho("Backup failed for Site {0}. Database or site_config.json may be corrupted".format(site), fg="red") + click.secho( + "Backup failed for Site {0}. Database or site_config.json may be corrupted".format(site), + fg="red" + ) if verbose: print(frappe.get_traceback()) exit_code = 1 continue + if frappe.get_system_settings("encrypt_backup") and frappe.get_site_config().encryption_key: + click.secho( + "Backup encryption is turned on. Please note the backup encryption key.", + fg="yellow" + ) odb.print_summary() - click.secho("Backup for Site {0} has been successfully completed{1}".format(site, " with files" if with_files else ""), fg="green") + 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: @@ -456,6 +620,7 @@ def backup(context, with_files=False, backup_path=None, backup_path_db=None, bac sys.exit(exit_code) + @click.command('remove-from-installed-apps') @click.argument('app') @pass_context @@ -474,7 +639,7 @@ def remove_from_installed_apps(context, app): @click.command('uninstall-app') @click.argument('app') -@click.option('--yes', '-y', help='To bypass confirmation prompt for uninstalling the app', is_flag=True, default=False, multiple=True) +@click.option('--yes', '-y', help='To bypass confirmation prompt for uninstalling the app', is_flag=True, default=False) @click.option('--dry-run', help='List all doctypes that will be deleted', is_flag=True, default=False) @click.option('--no-backup', help='Do not backup the site', is_flag=True, default=False) @click.option('--force', help='Force remove app from site', is_flag=True, default=False) @@ -561,30 +726,54 @@ def move(dest_dir, site): return final_new_path -@click.command('set-admin-password') -@click.argument('admin-password') +@click.command('set-password') +@click.argument('user') +@click.argument('password', required=False) @click.option('--logout-all-sessions', help='Logout from all sessions', is_flag=True, default=False) @pass_context -def set_admin_password(context, admin_password, logout_all_sessions=False): +def set_password(context, user, password=None, logout_all_sessions=False): + "Set password for a user on a site" + if not context.sites: + raise SiteNotSpecifiedError + + for site in context.sites: + set_user_password(site, user, password, logout_all_sessions) + + +@click.command('set-admin-password') +@click.argument('admin-password', required=False) +@click.option('--logout-all-sessions', help='Logout from all sessions', is_flag=True, default=False) +@pass_context +def set_admin_password(context, admin_password=None, logout_all_sessions=False): "Set Administrator password for a site" + if not context.sites: + raise SiteNotSpecifiedError + + for site in context.sites: + set_user_password(site, "Administrator", admin_password, logout_all_sessions) + + +def set_user_password(site, user, password, logout_all_sessions=False): import getpass from frappe.utils.password import update_password - for site in context.sites: - try: - frappe.init(site=site) + try: + frappe.init(site=site) - while not admin_password: - admin_password = getpass.getpass("Administrator's password for {0}: ".format(site)) + while not password: + password = getpass.getpass(f"{user}'s password for {site}: ") + + frappe.connect() + if not frappe.db.exists("User", user): + print(f"User {user} does not exist") + sys.exit(1) + + update_password(user=user, pwd=password, logout_all_sessions=logout_all_sessions) + frappe.db.commit() + password = None + finally: + frappe.destroy() - frappe.connect() - update_password(user='Administrator', pwd=admin_password, logout_all_sessions=logout_all_sessions) - frappe.db.commit() - admin_password = None - finally: - frappe.destroy() - if not context.sites: - raise SiteNotSpecifiedError @click.command('set-last-active-for-user') @click.option('--user', help="Setup last active date for user") @@ -635,10 +824,14 @@ def publish_realtime(context, event, message, room, user, doctype, docname, afte @click.command('browse') @click.argument('site', required=False) +@click.option('--user', required=False, help='Login as user') @pass_context -def browse(context, site): +def browse(context, site, user=None): '''Opens the site on web browser''' + from frappe.auth import LoginManager + from frappe.auth import CookieManager import webbrowser + site = context.sites[0] if context.sites else site if not site: @@ -648,7 +841,24 @@ def browse(context, site): site = site.lower() if site in frappe.utils.get_sites(): - webbrowser.open(frappe.utils.get_site_url(site), new=2) + frappe.init(site=site) + frappe.connect() + + sid = '' + if user: + if frappe.conf.developer_mode or user == "Administrator": + frappe.utils.set_request(path="/") + frappe.local.cookie_manager = CookieManager() + frappe.local.login_manager = LoginManager() + frappe.local.login_manager.login_as(user) + sid = f'/app?sid={frappe.session.sid}' + else: + print("Please enable developer mode to login as a user") + + url = f'{frappe.utils.get_site_url(site)}{sid}' + if user == "Administrator": + print(f'Login URL: {url}') + webbrowser.open(url, new=2) else: click.echo("\nSite named \033[1m{}\033[0m doesn't exist\n".format(site)) @@ -714,6 +924,131 @@ def build_search_index(context): finally: frappe.destroy() +@click.command('trim-database') +@click.option('--dry-run', is_flag=True, default=False, help='Show what would be deleted') +@click.option('--format', '-f', default='text', type=click.Choice(['json', 'text']), help='Output format') +@click.option('--no-backup', is_flag=True, default=False, help='Do not backup the site') +@pass_context +def trim_database(context, dry_run, format, no_backup): + if not context.sites: + raise SiteNotSpecifiedError + + from frappe.utils.backups import scheduled_backup + + ALL_DATA = {} + + for site in context.sites: + frappe.init(site=site) + frappe.connect() + + TABLES_TO_DROP = [] + STANDARD_TABLES = get_standard_tables() + information_schema = frappe.qb.Schema("information_schema") + table_name = frappe.qb.Field("table_name").as_("name") + + queried_result = frappe.qb.from_( + information_schema.tables + ).select(table_name).where( + information_schema.tables.table_schema == frappe.conf.db_name + ).run() + + database_tables = [x[0] for x in queried_result] + doctype_tables = frappe.get_all("DocType", pluck="name") + + for x in database_tables: + doctype = x.lstrip("tab") + if not (doctype in doctype_tables or x.startswith("__") or x in STANDARD_TABLES): + TABLES_TO_DROP.append(x) + + if not TABLES_TO_DROP: + if format == "text": + click.secho(f"No ghost tables found in {frappe.local.site}...Great!", fg="green") + else: + if not (no_backup or dry_run): + if format == "text": + print(f"Backing Up Tables: {', '.join(TABLES_TO_DROP)}") + + odb = scheduled_backup( + ignore_conf=False, + include_doctypes=",".join(x.lstrip("tab") for x in TABLES_TO_DROP), + ignore_files=True, + force=True, + ) + if format == "text": + odb.print_summary() + print("\nTrimming Database") + + for table in TABLES_TO_DROP: + if format == "text": + print(f"* Dropping Table '{table}'...") + if not dry_run: + frappe.db.sql_ddl(f"drop table `{table}`") + + ALL_DATA[frappe.local.site] = TABLES_TO_DROP + frappe.destroy() + + if format == "json": + import json + print(json.dumps(ALL_DATA, indent=1)) + + +def get_standard_tables(): + import re + + tables = [] + sql_file = os.path.join( + "..", "apps", "frappe", "frappe", "database", frappe.conf.db_type, f'framework_{frappe.conf.db_type}.sql' + ) + content = open(sql_file).read().splitlines() + + for line in content: + table_found = re.search(r"""CREATE TABLE ("|`)(.*)?("|`) \(""", line) + if table_found: + tables.append(table_found.group(2)) + + return tables + +@click.command('trim-tables') +@click.option('--dry-run', is_flag=True, default=False, help='Show what would be deleted') +@click.option('--format', '-f', default='table', type=click.Choice(['json', 'table']), help='Output format') +@click.option('--no-backup', is_flag=True, default=False, help='Do not backup the site') +@pass_context +def trim_tables(context, dry_run, format, no_backup): + if not context.sites: + raise SiteNotSpecifiedError + + from frappe.model.meta import trim_tables + from frappe.utils.backups import scheduled_backup + + for site in context.sites: + frappe.init(site=site) + frappe.connect() + + if not (no_backup or dry_run): + click.secho(f"Taking backup for {frappe.local.site}", fg="green") + odb = scheduled_backup(ignore_files=False, force=True) + odb.print_summary() + + try: + trimmed_data = trim_tables(dry_run=dry_run, quiet=format == 'json') + + if format == 'table' and not dry_run: + click.secho(f"The following data have been removed from {frappe.local.site}", fg='green') + + handle_data(trimmed_data, format=format) + finally: + frappe.destroy() + +def handle_data(data: dict, format='json'): + if format == 'json': + import json + print(json.dumps({frappe.local.site: data}, indent=1, sort_keys=True)) + else: + from frappe.utils.commands import render_table + data = [["DocType", "Fields"]] + [[table, ", ".join(columns)] for table, columns in data.items()] + render_table(data) + + commands = [ add_system_manager, backup, @@ -729,6 +1064,7 @@ commands = [ remove_from_installed_apps, restore, run_patch, + set_password, set_admin_password, uninstall, disable_user, @@ -741,5 +1077,7 @@ commands = [ add_to_hosts, start_ngrok, build_search_index, - partial_restore + partial_restore, + trim_tables, + trim_database, ] diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index 8ef70d739c..e311b8db6a 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - import json import os import subprocess @@ -11,7 +9,13 @@ import click import frappe from frappe.commands import get_site, pass_context from frappe.exceptions import SiteNotSpecifiedError -from frappe.utils import get_bench_path, update_progress_bar, cint +from frappe.utils import update_progress_bar, cint +from frappe.coverage import CodeCoverage + +DATA_IMPORT_DEPRECATION = ( + "[DEPRECATED] The `import-csv` command used 'Data Import Legacy' which has been deprecated.\n" + "Use `data-import` command instead to import data via 'Data Import'." +) @click.command('build') @@ -69,14 +73,14 @@ def watch(apps=None): def clear_cache(context): "Clear cache, doctype cache and defaults" import frappe.sessions - import frappe.website.render + from frappe.website.utils import clear_website_cache from frappe.desk.notifications import clear_notifications for site in context.sites: try: frappe.connect(site) frappe.clear_cache() clear_notifications() - frappe.website.render.clear_cache() + clear_website_cache() finally: frappe.destroy() if not context.sites: @@ -86,12 +90,12 @@ def clear_cache(context): @pass_context def clear_website_cache(context): "Clear website cache" - import frappe.website.render + from frappe.website.utils import clear_website_cache for site in context.sites: try: frappe.init(site=site) frappe.connect() - frappe.website.render.clear_cache() + clear_website_cache() finally: frappe.destroy() if not context.sites: @@ -350,7 +354,8 @@ def import_doc(context, path, force=False): if not context.sites: raise SiteNotSpecifiedError -@click.command('import-csv') + +@click.command('import-csv', help=DATA_IMPORT_DEPRECATION) @click.argument('path') @click.option('--only-insert', default=False, is_flag=True, help='Do not overwrite existing records') @click.option('--submit-after-import', default=False, is_flag=True, help='Submit document after importing it') @@ -358,32 +363,8 @@ def import_doc(context, path, force=False): @click.option('--no-email', default=True, is_flag=True, help='Send email if applicable') @pass_context def import_csv(context, path, only_insert=False, submit_after_import=False, ignore_encoding_errors=False, no_email=True): - "Import CSV using data import" - from frappe.core.doctype.data_import_legacy import importer - from frappe.utils.csvutils import read_csv_content - site = get_site(context) - - if not os.path.exists(path): - path = os.path.join('..', path) - if not os.path.exists(path): - print('Invalid path {0}'.format(path)) - sys.exit(1) - - with open(path, 'r') as csvfile: - content = read_csv_content(csvfile.read()) - - frappe.init(site=site) - frappe.connect() - - try: - importer.upload(content, submit_after_import=submit_after_import, no_email=no_email, - ignore_encoding_errors=ignore_encoding_errors, overwrite=not only_insert, - via_console=True) - frappe.db.commit() - except Exception: - print(frappe.get_traceback()) - - frappe.destroy() + click.secho(DATA_IMPORT_DEPRECATION, fg="yellow") + sys.exit(1) @click.command('data-import') @@ -426,20 +407,47 @@ def bulk_rename(context, doctype, path): frappe.destroy() +@click.command('db-console') +@pass_context +def database(context): + """ + Enter into the Database console for given site. + """ + site = get_site(context) + if not site: + raise SiteNotSpecifiedError + frappe.init(site=site) + if not frappe.conf.db_type or frappe.conf.db_type == "mariadb": + _mariadb() + elif frappe.conf.db_type == "postgres": + _psql() + + @click.command('mariadb') @pass_context def mariadb(context): """ Enter into mariadb console for a given site. """ - import os - site = get_site(context) if not site: raise SiteNotSpecifiedError frappe.init(site=site) + _mariadb() - # This is assuming you're within the bench instance. + +@click.command('postgres') +@pass_context +def postgres(context): + """ + Enter into postgres console for a given site. + """ + site = get_site(context) + frappe.init(site=site) + _psql() + + +def _mariadb(): mysql = find_executable('mysql') os.execv(mysql, [ mysql, @@ -452,15 +460,7 @@ def mariadb(context): "-A"]) -@click.command('postgres') -@pass_context -def postgres(context): - """ - Enter into postgres console for a given site. - """ - site = get_site(context) - frappe.init(site=site) - # This is assuming you're within the bench instance. +def _psql(): psql = find_executable('psql') subprocess.run([ psql, '-d', frappe.conf.db_name]) @@ -503,16 +503,36 @@ frappe.db.connect() ]) +def _console_cleanup(): + # Execute rollback_observers on console close + frappe.db.rollback() + frappe.destroy() + + @click.command('console') +@click.option( + '--autoreload', + is_flag=True, + help="Reload changes to code automatically" +) @pass_context -def console(context): +def console(context, autoreload=False): "Start ipython console for a site" site = get_site(context) frappe.init(site=site) frappe.connect() frappe.local.lang = frappe.db.get_default("lang") - import IPython + from IPython.terminal.embed import InteractiveShellEmbed + from atexit import register + + register(_console_cleanup) + + terminal = InteractiveShellEmbed() + if autoreload: + terminal.extension_manager.load_extension("autoreload") + terminal.run_line_magic("autoreload", "2") + all_apps = frappe.get_installed_apps() failed_to_import = [] @@ -527,7 +547,77 @@ def console(context): if failed_to_import: print("\nFailed to import:\n{}".format(", ".join(failed_to_import))) - IPython.embed(display_banner="", header="", colors="neutral") + terminal.colors = "neutral" + terminal.display_banner = False + terminal() + + +@click.command('transform-database', help="Change tables' internal settings changing engine and row formats") +@click.option('--table', required=True, help="Comma separated name of tables to convert. To convert all tables, pass 'all'") +@click.option('--engine', default=None, type=click.Choice(["InnoDB", "MyISAM"]), help="Choice of storage engine for said table(s)") +@click.option('--row_format', default=None, type=click.Choice(["DYNAMIC", "COMPACT", "REDUNDANT", "COMPRESSED"]), help="Set ROW_FORMAT parameter for said table(s)") +@click.option('--failfast', is_flag=True, default=False, help="Exit on first failure occurred") +@pass_context +def transform_database(context, table, engine, row_format, failfast): + "Transform site database through given parameters" + site = get_site(context) + check_table = [] + add_line = False + skipped = 0 + frappe.init(site=site) + + if frappe.conf.db_type and frappe.conf.db_type != "mariadb": + click.secho("This command only has support for MariaDB databases at this point", fg="yellow") + sys.exit(1) + + if not (engine or row_format): + click.secho("Values for `--engine` or `--row_format` must be set") + sys.exit(1) + + frappe.connect() + + if table == "all": + information_schema = frappe.qb.Schema("information_schema") + queried_tables = frappe.qb.from_( + information_schema.tables + ).select("table_name").where( + (information_schema.tables.row_format != row_format) + & (information_schema.tables.table_schema == frappe.conf.db_name) + ).run() + tables = [x[0] for x in queried_tables] + else: + tables = [x.strip() for x in table.split(",")] + + total = len(tables) + + for current, table in enumerate(tables): + values_to_set = "" + if engine: + values_to_set += f" ENGINE={engine}" + if row_format: + values_to_set += f" ROW_FORMAT={row_format}" + + try: + frappe.db.sql(f"ALTER TABLE `{table}`{values_to_set}") + update_progress_bar("Updating table schema", current - skipped, total) + add_line = True + + except Exception as e: + check_table.append([table, e.args]) + skipped += 1 + + if failfast: + break + + if add_line: + print() + + for errored_table in check_table: + table, err = errored_table + err_msg = f"{table}: ERROR {err[0]}: {err[1]}" + click.secho(err_msg, fg="yellow") + + frappe.destroy() @click.command('run-tests') @@ -542,74 +632,39 @@ def console(context): @click.option('--skip-test-records', is_flag=True, default=False, help="Don't create test records") @click.option('--skip-before-tests', is_flag=True, default=False, help="Don't run before tests hook") @click.option('--junit-xml-output', help="Destination file path for junit xml report") -@click.option('--failfast', is_flag=True, default=False) +@click.option('--failfast', is_flag=True, default=False, help="Stop the test run on the first error or failure") @pass_context def run_tests(context, app=None, module=None, doctype=None, test=(), profile=False, coverage=False, junit_xml_output=False, ui_tests = False, doctype_list_path=None, skip_test_records=False, skip_before_tests=False, failfast=False): - "Run tests" - import frappe.test_runner - tests = test + with CodeCoverage(coverage, app): + import frappe.test_runner + tests = test + site = get_site(context) - site = get_site(context) + allow_tests = frappe.get_conf(site).allow_tests - allow_tests = frappe.get_conf(site).allow_tests + if not (allow_tests or os.environ.get('CI')): + click.secho('Testing is disabled for the site!', bold=True) + click.secho('You can enable tests by entering following command:') + click.secho('bench --site {0} set-config allow_tests true'.format(site), fg='green') + return - if not (allow_tests or os.environ.get('CI')): - click.secho('Testing is disabled for the site!', bold=True) - click.secho('You can enable tests by entering following command:') - click.secho('bench --site {0} set-config allow_tests true'.format(site), fg='green') - return + frappe.init(site=site) - frappe.init(site=site) + frappe.flags.skip_before_tests = skip_before_tests + frappe.flags.skip_test_records = skip_test_records - frappe.flags.skip_before_tests = skip_before_tests - frappe.flags.skip_test_records = skip_test_records + ret = frappe.test_runner.main(app, module, doctype, context.verbose, tests=tests, + force=context.force, profile=profile, junit_xml_output=junit_xml_output, + ui_tests=ui_tests, doctype_list_path=doctype_list_path, failfast=failfast) - if coverage: - from coverage import Coverage + if len(ret.failures) == 0 and len(ret.errors) == 0: + ret = 0 - # Generate coverage report only for app that is being tested - source_path = os.path.join(get_bench_path(), 'apps', app or 'frappe') - incl = [ - '*.py', - ] - omit = [ - '*.js', - '*.xml', - '*.pyc', - '*.css', - '*.less', - '*.scss', - '*.vue', - '*.html', - '*/test_*', - '*/node_modules/*', - '*/doctype/*/*_dashboard.py', - '*/patches/*', - ] - - if not app or app == 'frappe': - omit.append('*/tests/*') - omit.append('*/commands/*') - - cov = Coverage(source=[source_path], omit=omit, include=incl) - cov.start() - - ret = frappe.test_runner.main(app, module, doctype, context.verbose, tests=tests, - force=context.force, profile=profile, junit_xml_output=junit_xml_output, - ui_tests=ui_tests, doctype_list_path=doctype_list_path, failfast=failfast) - - if coverage: - cov.stop() - cov.save() - - if len(ret.failures) == 0 and len(ret.errors) == 0: - ret = 0 - - if os.environ.get('CI'): - sys.exit(ret) + if os.environ.get('CI'): + sys.exit(ret) @click.command('run-parallel-tests') @click.option('--app', help="For App", default='frappe') @@ -619,21 +674,23 @@ def run_tests(context, app=None, module=None, doctype=None, test=(), profile=Fal @click.option('--use-orchestrator', is_flag=True, help="Use orchestrator to run parallel tests") @pass_context def run_parallel_tests(context, app, build_number, total_builds, with_coverage=False, use_orchestrator=False): - site = get_site(context) - if use_orchestrator: - from frappe.parallel_test_runner import ParallelTestWithOrchestrator - ParallelTestWithOrchestrator(app, site=site, with_coverage=with_coverage) - else: - from frappe.parallel_test_runner import ParallelTestRunner - ParallelTestRunner(app, site=site, build_number=build_number, total_builds=total_builds, with_coverage=with_coverage) + with CodeCoverage(with_coverage, app): + site = get_site(context) + if use_orchestrator: + from frappe.parallel_test_runner import ParallelTestWithOrchestrator + ParallelTestWithOrchestrator(app, site=site) + else: + from frappe.parallel_test_runner import ParallelTestRunner + ParallelTestRunner(app, site=site, build_number=build_number, total_builds=total_builds) @click.command('run-ui-tests') @click.argument('app') @click.option('--headless', is_flag=True, help="Run UI Test in headless mode") @click.option('--parallel', is_flag=True, help="Run UI Test in parallel mode") +@click.option('--with-coverage', is_flag=True, help="Generate coverage report") @click.option('--ci-build-id') @pass_context -def run_ui_tests(context, app, headless=False, parallel=True, ci_build_id=None): +def run_ui_tests(context, app, headless=False, parallel=True, with_coverage=False, ci_build_id=None): "Run UI tests" site = get_site(context) app_base_path = os.path.abspath(os.path.join(frappe.get_app_path(app), '..')) @@ -641,35 +698,39 @@ def run_ui_tests(context, app, headless=False, parallel=True, ci_build_id=None): admin_password = frappe.get_conf(site).admin_password # override baseUrl using env variable - site_env = 'CYPRESS_baseUrl={}'.format(site_url) - password_env = 'CYPRESS_adminPassword={}'.format(admin_password) if admin_password else '' + site_env = f'CYPRESS_baseUrl={site_url}' + password_env = f'CYPRESS_adminPassword={admin_password}' if admin_password else '' + coverage_env = f'CYPRESS_coverage={str(with_coverage).lower()}' os.chdir(app_base_path) node_bin = subprocess.getoutput("npm bin") - cypress_path = "{0}/cypress".format(node_bin) - plugin_path = "{0}/../cypress-file-upload".format(node_bin) + cypress_path = f"{node_bin}/cypress" + plugin_path = f"{node_bin}/../cypress-file-upload" + testing_library_path = f"{node_bin}/../@testing-library" + coverage_plugin_path = f"{node_bin}/../@cypress/code-coverage" # check if cypress in path...if not, install it. if not ( os.path.exists(cypress_path) and os.path.exists(plugin_path) + and os.path.exists(testing_library_path) + and os.path.exists(coverage_plugin_path) and cint(subprocess.getoutput("npm view cypress version")[:1]) >= 6 ): # install cypress click.secho("Installing Cypress...", fg="yellow") - frappe.commands.popen("yarn add cypress@^6 cypress-file-upload@^5 --no-lockfile") + frappe.commands.popen("yarn add cypress@^6 cypress-file-upload@^5 @testing-library/cypress@^8 @cypress/code-coverage@^3 --no-lockfile") # run for headless mode - run_or_open = 'run --browser firefox --record --key 4a48f41c-11b3-425b-aa88-c58048fa69eb' if headless else '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) + run_or_open = 'run --browser chrome --record' if headless else 'open' + formatted_command = f'{site_env} {password_env} {coverage_env} {cypress_path} {run_or_open}' if parallel: formatted_command += ' --parallel' if ci_build_id: - formatted_command += ' --ci-build-id {}'.format(ci_build_id) + formatted_command += f' --ci-build-id {ci_build_id}' click.secho("Running Cypress...", fg="yellow") frappe.commands.popen(formatted_command, cwd=app_base_path, raise_err=True) @@ -767,22 +828,49 @@ def set_config(context, key, value, global_=False, parse=False, as_dict=False): frappe.destroy() -@click.command('version') -def get_version(): - "Show the versions of all the installed apps" +@click.command("version") +@click.option("-f", "--format", "output", + type=click.Choice(["plain", "table", "json", "legacy"]), help="Output format", default="legacy") +def get_version(output): + """Show the versions of all the installed apps.""" + from git import Repo + from frappe.utils.commands import render_table from frappe.utils.change_log import get_app_branch - frappe.init('') - for m in sorted(frappe.get_all_apps()): - branch_name = get_app_branch(m) - module = frappe.get_module(m) - app_hooks = frappe.get_module(m + ".hooks") + frappe.init("") + data = [] - if hasattr(app_hooks, '{0}_version'.format(branch_name)): - print("{0} {1}".format(m, getattr(app_hooks, '{0}_version'.format(branch_name)))) + for app in sorted(frappe.get_all_apps()): + module = frappe.get_module(app) + app_hooks = frappe.get_module(app + ".hooks") + repo = Repo(frappe.get_app_path(app, "..")) - elif hasattr(module, "__version__"): - print("{0} {1}".format(m, module.__version__)) + app_info = frappe._dict() + app_info.app = app + app_info.branch = get_app_branch(app) + app_info.commit = repo.head.object.hexsha[:7] + app_info.version = getattr(app_hooks, f"{app_info.branch}_version", None) or module.__version__ + + data.append(app_info) + + { + "legacy": lambda: [ + click.echo(f"{app_info.app} {app_info.version}") + for app_info in data + ], + "plain": lambda: [ + click.echo(f"{app_info.app} {app_info.version} {app_info.branch} ({app_info.commit})") + for app_info in data + ], + "table": lambda: render_table( + [["App", "Version", "Branch", "Commit"]] + + [ + [app_info.app, app_info.version, app_info.branch, app_info.commit] + for app_info in data + ] + ), + "json": lambda: click.echo(json.dumps(data, indent=4)), + }[output]() @click.command('rebuild-global-search') @@ -821,6 +909,8 @@ commands = [ build, clear_cache, clear_website_cache, + database, + transform_database, jupyter, console, destroy_all_sessions, diff --git a/frappe/config/__init__.py b/frappe/config/__init__.py index 62a877be24..aa441b7d71 100644 --- a/frappe/config/__init__.py +++ b/frappe/config/__init__.py @@ -39,18 +39,17 @@ def get_modules_from_app(app): ) def get_all_empty_tables_by_module(): - empty_tables = set(r[0] for r in frappe.db.multisql({ - "mariadb": """ - SELECT table_name - FROM information_schema.tables - WHERE table_rows = 0 and table_schema = "{}" - """.format(frappe.conf.db_name), - "postgres": """ - SELECT "relname" as "table_name" - FROM "pg_stat_all_tables" - WHERE n_tup_ins = 0 - """ - })) + table_rows = frappe.qb.Field("table_rows") + table_name = frappe.qb.Field("table_name") + information_schema = frappe.qb.Schema("information_schema") + + empty_tables = ( + frappe.qb.from_(information_schema.tables) + .select(table_name) + .where(table_rows == 0) + ).run() + + empty_tables = {r[0] for r in empty_tables} results = frappe.get_all("DocType", fields=["name", "module"]) empty_tables_by_module = {} diff --git a/frappe/contacts/address_and_contact.py b/frappe/contacts/address_and_contact.py index 77305168c1..7824568a43 100644 --- a/frappe/contacts/address_and_contact.py +++ b/frappe/contacts/address_and_contact.py @@ -1,5 +1,5 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# License: MIT. See LICENSE import frappe @@ -16,7 +16,7 @@ def load_address_and_contact(doc, key=None): ["Dynamic Link", "link_name", "=", doc.name], ["Dynamic Link", "parenttype", "=", "Address"], ] - address_list = frappe.get_all("Address", filters=filters, fields=["*"]) + address_list = frappe.get_list("Address", filters=filters, fields=["*"]) address_list = [a.update({"display": get_address_display(a)}) for a in address_list] @@ -34,16 +34,16 @@ def load_address_and_contact(doc, key=None): ["Dynamic Link", "link_name", "=", doc.name], ["Dynamic Link", "parenttype", "=", "Contact"], ] - contact_list = frappe.get_all("Contact", filters=filters, fields=["*"]) + contact_list = frappe.get_list("Contact", filters=filters, fields=["*"]) for contact in contact_list: - contact["email_ids"] = frappe.get_list("Contact Email", filters={ + contact["email_ids"] = frappe.get_all("Contact Email", filters={ "parenttype": "Contact", "parent": contact.name, "is_primary": 0 }, fields=["email_id"]) - contact["phone_nos"] = frappe.get_list("Contact Phone", filters={ + contact["phone_nos"] = frappe.get_all("Contact Phone", filters={ "parenttype": "Contact", "parent": contact.name, "is_primary_phone": 0, @@ -178,4 +178,4 @@ def set_link_title(doc): for link in doc.links: if not link.link_title: linked_doc = frappe.get_doc(link.link_doctype, link.link_name) - link.link_title = linked_doc.get("title_field") or linked_doc.get("name") + link.link_title = linked_doc.get_title() or link.link_name diff --git a/frappe/contacts/doctype/address/address.py b/frappe/contacts/doctype/address/address.py index 755bc63064..5d0ed18d5f 100644 --- a/frappe/contacts/doctype/address/address.py +++ b/frappe/contacts/doctype/address/address.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe @@ -65,7 +65,7 @@ class Address(Document): def has_link(self, doctype, name): for link in self.links: - if link.link_doctype==doctype and link.link_name== name: + if link.link_doctype == doctype and link.link_name == name: return True def has_common_link(self, doc): diff --git a/frappe/contacts/doctype/address/test_address.py b/frappe/contacts/doctype/address/test_address.py index ed61b6f0ee..dd6cd1ca83 100644 --- a/frappe/contacts/doctype/address/test_address.py +++ b/frappe/contacts/doctype/address/test_address.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe, unittest from frappe.contacts.doctype.address.address import get_address_display diff --git a/frappe/contacts/doctype/address_template/address_template.py b/frappe/contacts/doctype/address_template/address_template.py index 2d69a792ab..005f414303 100644 --- a/frappe/contacts/doctype/address_template/address_template.py +++ b/frappe/contacts/doctype/address_template/address_template.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/contacts/doctype/address_template/test_address_template.py b/frappe/contacts/doctype/address_template/test_address_template.py index 6b519a3bb7..b86623b548 100644 --- a/frappe/contacts/doctype/address_template/test_address_template.py +++ b/frappe/contacts/doctype/address_template/test_address_template.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe, unittest class TestAddressTemplate(unittest.TestCase): diff --git a/frappe/contacts/doctype/contact/contact.py b/frappe/contacts/doctype/contact/contact.py index d1dd1f1010..9152655b85 100644 --- a/frappe/contacts/doctype/contact/contact.py +++ b/frappe/contacts/doctype/contact/contact.py @@ -1,5 +1,5 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# License: MIT. See LICENSE import frappe from frappe.utils import cstr, has_gravatar from frappe import _ @@ -47,14 +47,14 @@ class Contact(Document): def get_link_for(self, link_doctype): '''Return the link name, if exists for the given link DocType''' for link in self.links: - if link.link_doctype==link_doctype: + if link.link_doctype == link_doctype: return link.link_name return None def has_link(self, doctype, name): for link in self.links: - if link.link_doctype==doctype and link.link_name== name: + if link.link_doctype == doctype and link.link_name == name: return True def has_common_link(self, doc): @@ -262,7 +262,7 @@ def get_contact_with_phone_number(number): return contacts[0].parent if contacts else None def get_contact_name(email_id): - contact = frappe.get_list("Contact Email", filters={"email_id": email_id}, fields=["parent"], limit=1) + contact = frappe.get_all("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): diff --git a/frappe/contacts/doctype/contact/test_contact.py b/frappe/contacts/doctype/contact/test_contact.py index 6c6089edeb..1170ba843a 100644 --- a/frappe/contacts/doctype/contact/test_contact.py +++ b/frappe/contacts/doctype/contact/test_contact.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/contacts/doctype/contact_email/contact_email.py b/frappe/contacts/doctype/contact_email/contact_email.py index 5fc2fef316..58d37376b8 100644 --- a/frappe/contacts/doctype/contact_email/contact_email.py +++ b/frappe/contacts/doctype/contact_email/contact_email.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/contacts/doctype/contact_phone/contact_phone.py b/frappe/contacts/doctype/contact_phone/contact_phone.py index 63f5f73cf1..ed7d3b9911 100644 --- a/frappe/contacts/doctype/contact_phone/contact_phone.py +++ b/frappe/contacts/doctype/contact_phone/contact_phone.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/contacts/doctype/gender/gender.py b/frappe/contacts/doctype/gender/gender.py index 319800de7e..b4efcb64b9 100644 --- a/frappe/contacts/doctype/gender/gender.py +++ b/frappe/contacts/doctype/gender/gender.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE from frappe.model.document import Document diff --git a/frappe/contacts/doctype/gender/test_gender.py b/frappe/contacts/doctype/gender/test_gender.py index 071ed47df0..8549cc2130 100644 --- a/frappe/contacts/doctype/gender/test_gender.py +++ b/frappe/contacts/doctype/gender/test_gender.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import unittest class TestGender(unittest.TestCase): diff --git a/frappe/contacts/doctype/salutation/salutation.py b/frappe/contacts/doctype/salutation/salutation.py index d79ad66845..380af6de28 100644 --- a/frappe/contacts/doctype/salutation/salutation.py +++ b/frappe/contacts/doctype/salutation/salutation.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE from frappe.model.document import Document diff --git a/frappe/contacts/doctype/salutation/test_salutation.py b/frappe/contacts/doctype/salutation/test_salutation.py index e2e9075855..59333fb61e 100644 --- a/frappe/contacts/doctype/salutation/test_salutation.py +++ b/frappe/contacts/doctype/salutation/test_salutation.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import unittest class TestSalutation(unittest.TestCase): diff --git a/frappe/contacts/report/addresses_and_contacts/addresses_and_contacts.py b/frappe/contacts/report/addresses_and_contacts/addresses_and_contacts.py index bf48b6b185..671e1c6bc8 100644 --- a/frappe/contacts/report/addresses_and_contacts/addresses_and_contacts.py +++ b/frappe/contacts/report/addresses_and_contacts/addresses_and_contacts.py @@ -1,5 +1,5 @@ # Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe import _ diff --git a/frappe/core/__init__.py b/frappe/core/__init__.py index f064a66c17..98029dd956 100644 --- a/frappe/core/__init__.py +++ b/frappe/core/__init__.py @@ -1,2 +1,2 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE diff --git a/frappe/core/doctype/__init__.py b/frappe/core/doctype/__init__.py index 0e57cb68c3..eb5ba62e5c 100644 --- a/frappe/core/doctype/__init__.py +++ b/frappe/core/doctype/__init__.py @@ -1,3 +1,3 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE diff --git a/frappe/core/doctype/access_log/access_log.py b/frappe/core/doctype/access_log/access_log.py index d2fbee108b..48c12fd93f 100644 --- a/frappe/core/doctype/access_log/access_log.py +++ b/frappe/core/doctype/access_log/access_log.py @@ -1,10 +1,8 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt - -# imports - standard imports -# imports - module imports +# Copyright (c) 2021, Frappe Technologies and contributors +# License: MIT. See LICENSE import frappe +from frappe.utils import cstr +from tenacity import retry, retry_if_exception_type, stop_after_attempt from frappe.model.document import Document @@ -13,24 +11,37 @@ class AccessLog(Document): @frappe.whitelist() -def make_access_log(doctype=None, document=None, method=None, file_type=None, - report_name=None, filters=None, page=None, columns=None): - +@frappe.write_only() +@retry( + stop=stop_after_attempt(3), retry=retry_if_exception_type(frappe.DuplicateEntryError) +) +def make_access_log( + doctype=None, + document=None, + method=None, + file_type=None, + report_name=None, + filters=None, + page=None, + columns=None, +): user = frappe.session.user + in_request = frappe.request and frappe.request.method == "GET" - doc = frappe.get_doc({ - 'doctype': 'Access Log', - 'user': user, - 'export_from': doctype, - 'reference_document': document, - 'file_type': file_type, - 'report_name': report_name, - 'page': page, - 'method': method, - 'filters': frappe.utils.cstr(filters) if filters else None, - 'columns': columns - }) - doc.insert(ignore_permissions=True) + frappe.get_doc({ + "doctype": "Access Log", + "user": user, + "export_from": doctype, + "reference_document": document, + "file_type": file_type, + "report_name": report_name, + "page": page, + "method": method, + "filters": cstr(filters) or None, + "columns": columns, + }).db_insert() # `frappe.db.commit` added because insert doesnt `commit` when called in GET requests like `printview` - frappe.db.commit() + # dont commit in test mode + if not frappe.flags.in_test or in_request: + frappe.db.commit() diff --git a/frappe/core/doctype/access_log/test_access_log.py b/frappe/core/doctype/access_log/test_access_log.py index 9830507423..42878d0eb4 100644 --- a/frappe/core/doctype/access_log/test_access_log.py +++ b/frappe/core/doctype/access_log/test_access_log.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE # imports - standard imports import unittest diff --git a/frappe/core/doctype/activity_log/activity_log.json b/frappe/core/doctype/activity_log/activity_log.json index a1ee4dafdb..ad12246a95 100644 --- a/frappe/core/doctype/activity_log/activity_log.json +++ b/frappe/core/doctype/activity_log/activity_log.json @@ -154,7 +154,7 @@ "icon": "fa fa-comment", "index_web_pages_for_search": 1, "links": [], - "modified": "2020-08-28 11:43:57.504565", + "modified": "2021-10-25 11:43:57.504565", "modified_by": "Administrator", "module": "Core", "name": "Activity Log", @@ -182,6 +182,5 @@ "sort_field": "modified", "sort_order": "DESC", "title_field": "subject", - "track_changes": 1, "track_seen": 1 } diff --git a/frappe/core/doctype/activity_log/activity_log.py b/frappe/core/doctype/activity_log/activity_log.py index efec0dc217..69565a2c2a 100644 --- a/frappe/core/doctype/activity_log/activity_log.py +++ b/frappe/core/doctype/activity_log/activity_log.py @@ -1,12 +1,15 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE from frappe import _ from frappe.utils import get_fullname, now from frappe.model.document import Document from frappe.core.utils import set_timeline_doc import frappe +from frappe.query_builder import DocType, Interval +from frappe.query_builder.functions import Now +from pypika.terms import PseudoColumn class ActivityLog(Document): def before_insert(self): @@ -44,6 +47,7 @@ def clear_activity_logs(days=None): if not days: days = 90 - - frappe.db.sql("""delete from `tabActivity Log` where \ - creation< (NOW() - INTERVAL '{0}' DAY)""".format(days)) \ No newline at end of file + doctype = DocType("Activity Log") + frappe.db.delete(doctype, filters=( + doctype.creation < PseudoColumn(f"({Now() - Interval(days=days)})") + )) \ No newline at end of file diff --git a/frappe/core/doctype/activity_log/feed.py b/frappe/core/doctype/activity_log/feed.py index caa3cae613..358272ac63 100644 --- a/frappe/core/doctype/activity_log/feed.py +++ b/frappe/core/doctype/activity_log/feed.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: See license.txt +# License: MIT. See LICENSE import frappe import frappe.permissions @@ -29,10 +29,12 @@ def update_feed(doc, method=None): name = feed.name or doc.name # delete earlier feed - frappe.db.sql("""delete from `tabActivity Log` - where - reference_doctype=%s and reference_name=%s - and link_doctype=%s""", (doctype, name,feed.link_doctype)) + frappe.db.delete("Activity Log", { + "reference_doctype": doctype, + "reference_name": name, + "link_doctype": feed.link_doctype + }) + frappe.get_doc({ "doctype": "Activity Log", "reference_doctype": doctype, diff --git a/frappe/core/doctype/activity_log/test_activity_log.py b/frappe/core/doctype/activity_log/test_activity_log.py index ed7b70cca1..87d3538cc7 100644 --- a/frappe/core/doctype/activity_log/test_activity_log.py +++ b/frappe/core/doctype/activity_log/test_activity_log.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest import time diff --git a/frappe/core/doctype/block_module/block_module.py b/frappe/core/doctype/block_module/block_module.py index d9723f9170..cc6c222a04 100644 --- a/frappe/core/doctype/block_module/block_module.py +++ b/frappe/core/doctype/block_module/block_module.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/comment/comment.py b/frappe/core/doctype/comment/comment.py index e29bae25a2..e28d350d04 100644 --- a/frappe/core/doctype/comment/comment.py +++ b/frappe/core/doctype/comment/comment.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe import _ import json @@ -9,7 +9,7 @@ from frappe.core.doctype.user.user import extract_mentions from frappe.desk.doctype.notification_log.notification_log import enqueue_create_notification,\ get_title, get_title_html from frappe.utils import get_fullname -from frappe.website.render import clear_cache +from frappe.website.utils import clear_cache from frappe.database.schema import add_column from frappe.exceptions import ImplicitCommitError diff --git a/frappe/core/doctype/comment/test_comment.py b/frappe/core/doctype/comment/test_comment.py index 13db92e7a8..99bd19c106 100644 --- a/frappe/core/doctype/comment/test_comment.py +++ b/frappe/core/doctype/comment/test_comment.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe, json import unittest @@ -30,7 +30,7 @@ class TestComment(unittest.TestCase): from frappe.website.doctype.blog_post.test_blog_post import make_test_blog test_blog = make_test_blog() - frappe.db.sql("delete from `tabComment` where reference_doctype = 'Blog Post'") + frappe.db.delete("Comment", {"reference_doctype": "Blog Post"}) from frappe.templates.includes.comments.comments import add_comment add_comment('Good comment with 10 chars', 'test@test.com', 'Good Tester', @@ -41,7 +41,7 @@ class TestComment(unittest.TestCase): reference_name = test_blog.name ))[0].published, 1) - frappe.db.sql("delete from `tabComment` where reference_doctype = 'Blog Post'") + frappe.db.delete("Comment", {"reference_doctype": "Blog Post"}) add_comment('pleez vizits my site http://mysite.com', 'test@test.com', 'bad commentor', 'Blog Post', test_blog.name, test_blog.route) diff --git a/frappe/core/doctype/communication/__init__.py b/frappe/core/doctype/communication/__init__.py index 0e57cb68c3..eb5ba62e5c 100644 --- a/frappe/core/doctype/communication/__init__.py +++ b/frappe/core/doctype/communication/__init__.py @@ -1,3 +1,3 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE diff --git a/frappe/core/doctype/communication/communication.py b/frappe/core/doctype/communication/communication.py index 17b1290776..3a78a6a599 100644 --- a/frappe/core/doctype/communication/communication.py +++ b/frappe/core/doctype/communication/communication.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE from collections import Counter import frappe @@ -255,7 +255,7 @@ class Communication(Document, CommunicationEmailMixin): def set_delivery_status(self, commit=False): '''Look into the status of Email Queue linked to this Communication and set the Delivery Status of this Communication''' delivery_status = None - status_counts = Counter(frappe.db.sql_list('''select status from `tabEmail Queue` where communication=%s''', self.name)) + status_counts = Counter(frappe.get_all("Email Queue", pluck="status", filters={"communication": self.name})) if self.sent_or_received == "Received": return @@ -406,7 +406,7 @@ def get_contacts(email_strings, auto_create_contact=False): return contacts def add_contact_links_to_communication(communication, contact_name): - contact_links = frappe.get_list("Dynamic Link", filters={ + contact_links = frappe.get_all("Dynamic Link", filters={ "parenttype": "Contact", "parent": contact_name }, fields=["link_doctype", "link_name"]) diff --git a/frappe/core/doctype/communication/email.py b/frappe/core/doctype/communication/email.py index d35c118550..4d22075b78 100755 --- a/frappe/core/doctype/communication/email.py +++ b/frappe/core/doctype/communication/email.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe import json @@ -85,8 +85,6 @@ def make(doctype=None, name=None, content=None, subject=None, sent_or_received = if attachments: add_attachments(comm.name, attachments) - frappe.db.commit() - if cint(send_email): if not comm.get_outgoing_email_account(): frappe.throw(msg=OUTGOING_EMAIL_ACCOUNT_MISSING, exc=frappe.OutgoingEmailError) diff --git a/frappe/core/doctype/communication/mixins.py b/frappe/core/doctype/communication/mixins.py index 82a47d24d9..b6d8070d00 100644 --- a/frappe/core/doctype/communication/mixins.py +++ b/frappe/core/doctype/communication/mixins.py @@ -3,6 +3,7 @@ from frappe import _ from frappe.core.utils import get_parent_doc from frappe.utils import parse_addr, get_formatted_email, get_url from frappe.email.doctype.email_account.email_account import EmailAccount +from frappe.desk.doctype.todo.todo import ToDo class CommunicationEmailMixin: """Mixin class to handle communication mails. @@ -76,6 +77,7 @@ class CommunicationEmailMixin: if is_inbound_mail_communcation: cc.append(self.get_owner()) cc = set(cc) - {self.sender_mailid} + cc.update(self.get_assignees()) cc = set(cc) - set(self.filter_thread_notification_disbled_users(cc)) cc = cc - set(self.mail_recipients(is_inbound_mail_communcation=is_inbound_mail_communcation)) @@ -89,7 +91,7 @@ class CommunicationEmailMixin: return self._final_cc def get_mail_cc_with_displayname(self, is_inbound_mail_communcation=False, include_sender = False): - cc_list = self.mail_cc(is_inbound_mail_communcation=False, include_sender = False) + cc_list = self.mail_cc(is_inbound_mail_communcation=is_inbound_mail_communcation, include_sender = include_sender) return [self.get_email_with_displayname(email) for email in cc_list] def mail_bcc(self, is_inbound_mail_communcation=False): @@ -176,8 +178,8 @@ class CommunicationEmailMixin: def mail_attachments(self, print_format=None, print_html=None): final_attachments = [] - if print_format and print_html: - d = {'print_format': print_format, 'print_html': print_html, 'print_format_attachment': 1, + if print_format or print_html: + d = {'print_format': print_format, 'html': print_html, 'print_format_attachment': 1, 'doctype': self.reference_doctype, 'name': self.reference_name} final_attachments.append(d) @@ -201,6 +203,13 @@ class CommunicationEmailMixin: self.mail_cc(is_inbound_mail_communcation = is_inbound_mail_communcation, include_sender=include_sender) return set(all_ids) - set(final_ids) + def get_assignees(self): + """Get owners of the reference document. + """ + filters = {'status': 'Open', 'reference_name': self.reference_name, + 'reference_type': self.reference_doctype} + return ToDo.get_owners(filters) + @staticmethod def filter_thread_notification_disbled_users(emails): """Filter users based on notifications for email threads setting is disabled. @@ -208,17 +217,7 @@ class CommunicationEmailMixin: if not emails: return [] - disabled_users = frappe.db.sql_list(""" - SELECT - email - FROM - `tabUser` - where - email in %(emails)s - and - thread_notify=0 - """, {'emails': tuple(emails)}) - return disabled_users + return frappe.get_all("User", pluck="email", filters={"email": ["in", emails], "thread_notify": 0}) @staticmethod def filter_disabled_users(emails): @@ -227,17 +226,7 @@ class CommunicationEmailMixin: if not emails: return [] - disabled_users = frappe.db.sql_list(""" - SELECT - email - FROM - `tabUser` - where - email in %(emails)s - and - enabled=0 - """, {'emails': tuple(emails)}) - return disabled_users + return frappe.get_all("User", pluck="email", filters={"email": ["in", emails], "enabled": 0}) def sendmail_input_dict(self, print_html=None, print_format=None, send_me_a_copy=None, print_letterhead=None, is_inbound_mail_communcation=None): diff --git a/frappe/core/doctype/communication/test_communication.py b/frappe/core/doctype/communication/test_communication.py index d50a4db88a..b0c8e1fcee 100644 --- a/frappe/core/doctype/communication/test_communication.py +++ b/frappe/core/doctype/communication/test_communication.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt +# License: MIT. See LICENSE import unittest from urllib.parse import quote diff --git a/frappe/core/doctype/communication_link/communication_link.py b/frappe/core/doctype/communication_link/communication_link.py index d3307d1d32..a895ad3df5 100644 --- a/frappe/core/doctype/communication_link/communication_link.py +++ b/frappe/core/doctype/communication_link/communication_link.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/custom_docperm/custom_docperm.py b/frappe/core/doctype/custom_docperm/custom_docperm.py index 225f5db79b..1790344776 100644 --- a/frappe/core/doctype/custom_docperm/custom_docperm.py +++ b/frappe/core/doctype/custom_docperm/custom_docperm.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/custom_docperm/test_custom_docperm.py b/frappe/core/doctype/custom_docperm/test_custom_docperm.py index 6e0c82d1db..422b711e5b 100644 --- a/frappe/core/doctype/custom_docperm/test_custom_docperm.py +++ b/frappe/core/doctype/custom_docperm/test_custom_docperm.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/core/doctype/custom_role/custom_role.py b/frappe/core/doctype/custom_role/custom_role.py index 89e478dd38..c6630baf6d 100644 --- a/frappe/core/doctype/custom_role/custom_role.py +++ b/frappe/core/doctype/custom_role/custom_role.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/custom_role/test_custom_role.py b/frappe/core/doctype/custom_role/test_custom_role.py index 0ad77524fa..21511a7408 100644 --- a/frappe/core/doctype/custom_role/test_custom_role.py +++ b/frappe/core/doctype/custom_role/test_custom_role.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/core/doctype/data_export/data_export.py b/frappe/core/doctype/data_export/data_export.py index c376b25230..46fe3570a1 100644 --- a/frappe/core/doctype/data_export/data_export.py +++ b/frappe/core/doctype/data_export/data_export.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE from frappe.model.document import Document diff --git a/frappe/core/doctype/data_export/exporter.py b/frappe/core/doctype/data_export/exporter.py index 389948449e..c5cf67ba57 100644 --- a/frappe/core/doctype/data_export/exporter.py +++ b/frappe/core/doctype/data_export/exporter.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe from frappe import _ @@ -7,7 +7,6 @@ import frappe.permissions import re, csv, os from frappe.utils.csvutils import UnicodeWriter from frappe.utils import cstr, formatdate, format_datetime, parse_json, cint, format_duration -from frappe.core.doctype.data_import_legacy.importer import get_data_keys from frappe.core.doctype.access_log.access_log import make_access_log reflags = { @@ -20,6 +19,15 @@ reflags = { "D": re.DEBUG } +def get_data_keys(): + return frappe._dict({ + "data_separator": _('Start entering data below this line'), + "main_table": _("Table") + ":", + "parent_table": _("Parent Table") + ":", + "columns": _("Column Name") + ":", + "doctype": _("DocType") + ":" + }) + @frappe.whitelist() def export_data(doctype=None, parent_doctype=None, all_doctypes=True, with_data=False, select_columns=None, file_type='CSV', template=False, filters=None): @@ -253,6 +261,7 @@ class DataExporter: self.writer.writerow([self.data_keys.data_separator]) def add_data(self): + from frappe.query_builder import DocType if self.template and not self.with_data: return @@ -297,9 +306,15 @@ class DataExporter: if self.all_doctypes: # add child tables for c in self.child_doctypes: - for ci, child in enumerate(frappe.db.sql("""select * from `tab{0}` - where parent=%s and parentfield=%s order by idx""".format(c['doctype']), - (doc.name, c['parentfield']), as_dict=1)): + child_doctype_table = DocType(c["doctype"]) + data_row = ( + frappe.qb.from_(child_doctype_table) + .select("*") + .where(child_doctype_table.parent == doc.name) + .where(child_doctype_table.parentfield == c["parentfield"]) + .orderby(child_doctype_table.idx) + ) + for ci, child in enumerate(data_row.run()): self.add_data_row(rows, c['doctype'], c['parentfield'], child, ci) for row in rows: diff --git a/frappe/core/doctype/data_import/data_import.py b/frappe/core/doctype/data_import/data_import.py index 7e8374a0a2..5935ddc4ba 100644 --- a/frappe/core/doctype/data_import/data_import.py +++ b/frappe/core/doctype/data_import/data_import.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import os @@ -171,9 +171,6 @@ def import_file( i.import_data() -############## - - def import_doc(path, pre_process=None): if os.path.isdir(path): files = [os.path.join(path, f) for f in os.listdir(path)] @@ -192,19 +189,8 @@ def import_doc(path, pre_process=None): ) frappe.flags.mute_emails = False frappe.db.commit() - elif f.endswith(".csv"): - validate_csv_import_file(f) - frappe.db.commit() - - -def validate_csv_import_file(path): - if path.endswith(".csv"): - print() - print("This method is deprecated.") - print('Import CSV files using the command "bench --site sitename data-import"') - print("Or use the method frappe.core.doctype.data_import.data_import.import_file") - print() - raise Exception("Method deprecated") + else: + raise NotImplementedError("Only .json files can be imported") def export_json( diff --git a/frappe/core/doctype/data_import/exporter.py b/frappe/core/doctype/data_import/exporter.py index acaa294a6f..684328a4c7 100644 --- a/frappe/core/doctype/data_import/exporter.py +++ b/frappe/core/doctype/data_import/exporter.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import typing diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py index bb922f1f5d..cd20a5c0f3 100644 --- a/frappe/core/doctype/data_import/importer.py +++ b/frappe/core/doctype/data_import/importer.py @@ -1,5 +1,5 @@ # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import os import io @@ -763,7 +763,9 @@ class Column: seen = [] fields_column_map = {} - def __init__(self, index, header, doctype, column_values, map_to_field=None, seen=[]): + def __init__(self, index, header, doctype, column_values, map_to_field=None, seen=None): + if seen is None: + seen = [] self.index = index self.column_number = index + 1 self.doctype = doctype diff --git a/frappe/core/doctype/data_import/test_data_import.py b/frappe/core/doctype/data_import/test_data_import.py index c9366a97ba..c0e4f50d6d 100644 --- a/frappe/core/doctype/data_import/test_data_import.py +++ b/frappe/core/doctype/data_import/test_data_import.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE # import frappe import unittest diff --git a/frappe/core/doctype/data_import/test_exporter.py b/frappe/core/doctype/data_import/test_exporter.py index dfe9926906..cb9461451f 100644 --- a/frappe/core/doctype/data_import/test_exporter.py +++ b/frappe/core/doctype/data_import/test_exporter.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import unittest import frappe from frappe.core.doctype.data_import.exporter import Exporter diff --git a/frappe/core/doctype/data_import/test_importer.py b/frappe/core/doctype/data_import/test_importer.py index 54a7788a2d..e1bc0e7ca5 100644 --- a/frappe/core/doctype/data_import/test_importer.py +++ b/frappe/core/doctype/data_import/test_importer.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import unittest import frappe from frappe.core.doctype.data_import.importer import Importer @@ -62,9 +62,9 @@ class TestImporter(unittest.TestCase): data_import.reload() import_log = frappe.parse_json(data_import.import_log) self.assertEqual(import_log[0]['row_indexes'], [2,3]) - expected_error = "Error: Child 1 of DocType for Import Row #1: Value missing for: Child Title" + expected_error = "Error: Child 1 of DocType for Import Row #1: Value missing for: Child Title" self.assertEqual(frappe.parse_json(import_log[0]['messages'][0])['message'], expected_error) - expected_error = "Error: Child 1 of DocType for Import Row #2: Value missing for: Child Title" + expected_error = "Error: Child 1 of DocType for Import Row #2: Value missing for: Child Title" self.assertEqual(frappe.parse_json(import_log[0]['messages'][1])['message'], expected_error) self.assertEqual(import_log[1]['row_indexes'], [4]) diff --git a/frappe/core/doctype/data_import_legacy/data_import_legacy.js b/frappe/core/doctype/data_import_legacy/data_import_legacy.js deleted file mode 100644 index 8e4f397171..0000000000 --- a/frappe/core/doctype/data_import_legacy/data_import_legacy.js +++ /dev/null @@ -1,324 +0,0 @@ -// Copyright (c) 2017, Frappe Technologies and contributors -// For license information, please see license.txt - -frappe.ui.form.on('Data Import Legacy', { - onload: function(frm) { - if (frm.doc.__islocal) { - frm.set_value("action", ""); - } - - frappe.call({ - method: "frappe.core.doctype.data_import_legacy.data_import_legacy.get_importable_doctypes", - callback: function (r) { - let importable_doctypes = r.message; - frm.set_query("reference_doctype", function () { - return { - "filters": { - "issingle": 0, - "istable": 0, - "name": ['in', importable_doctypes] - } - }; - }); - } - }), - - // should never check public - frm.fields_dict["import_file"].df.is_private = 1; - - frappe.realtime.on("data_import_progress", function(data) { - if (data.data_import === frm.doc.name) { - if (data.reload && data.reload === true) { - frm.reload_doc(); - } - if (data.progress) { - 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 + "%"); - } - } - } - }); - }, - - reference_doctype: function(frm){ - if (frm.doc.reference_doctype) { - frappe.model.with_doctype(frm.doc.reference_doctype); - } - }, - - refresh: function(frm) { - frm.disable_save(); - frm.dashboard.clear_headline(); - if (frm.doc.reference_doctype && !frm.doc.import_file) { - frm.page.set_indicator(__('Attach file'), 'orange'); - } else { - if (frm.doc.import_status) { - const listview_settings = frappe.listview_settings['Data Import Legacy']; - const indicator = listview_settings.get_indicator(frm.doc); - - frm.page.set_indicator(indicator[0], indicator[1]); - - if (frm.doc.import_status === "In Progress") { - frm.dashboard.add_progress("Data Import Progress", "0"); - frm.set_read_only(); - frm.refresh_fields(); - } - } - } - - if (frm.doc.reference_doctype) { - frappe.model.with_doctype(frm.doc.reference_doctype); - } - - if(frm.doc.action == "Insert new records" || frm.doc.action == "Update records") { - frm.set_df_property("action", "read_only", 1); - } - - frm.add_custom_button(__("Help"), function() { - frappe.help.show_video("6wiriRKPhmg"); - }); - - if (frm.doc.reference_doctype && frm.doc.docstatus === 0) { - frm.add_custom_button(__("Download template"), function() { - frappe.data_import.download_dialog(frm).show(); - }); - } - - if (frm.doc.reference_doctype && frm.doc.import_file && frm.doc.total_rows && - frm.doc.docstatus === 0 && (!frm.doc.import_status || frm.doc.import_status == "Failed")) { - frm.page.set_primary_action(__("Start Import"), function() { - frappe.call({ - btn: frm.page.btn_primary, - method: "frappe.core.doctype.data_import_legacy.data_import_legacy.import_data", - args: { - data_import: frm.doc.name - } - }); - }).addClass('btn btn-primary'); - } - - if (frm.doc.log_details) { - frm.events.create_log_table(frm); - } else { - $(frm.fields_dict.import_log.wrapper).empty(); - } - }, - - action: function(frm) { - if(!frm.doc.action) return; - if(!frm.doc.reference_doctype) { - frappe.msgprint(__("Please select document type first.")); - frm.set_value("action", ""); - return; - } - - if(frm.doc.action == "Insert new records") { - frm.doc.insert_new = 1; - } else if (frm.doc.action == "Update records"){ - frm.doc.overwrite = 1; - } - frm.save(); - }, - - only_update: function(frm) { - frm.save(); - }, - - submit_after_import: function(frm) { - frm.save(); - }, - - skip_errors: function(frm) { - frm.save(); - }, - - ignore_encoding_errors: function(frm) { - frm.save(); - }, - - no_email: function(frm) { - frm.save(); - }, - - show_only_errors: function(frm) { - frm.events.create_log_table(frm); - }, - - create_log_table: function(frm) { - let msg = JSON.parse(frm.doc.log_details); - var $log_wrapper = $(frm.fields_dict.import_log.wrapper).empty(); - $(frappe.render_template("log_details", { - data: msg.messages, - import_status: frm.doc.import_status, - show_only_errors: frm.doc.show_only_errors, - })).appendTo($log_wrapper); - } -}); - -frappe.provide('frappe.data_import'); -frappe.data_import.download_dialog = function(frm) { - var dialog; - const filter_fields = df => frappe.model.is_value_type(df) && !df.hidden; - const get_fields = dt => frappe.meta.get_docfields(dt).filter(filter_fields); - - const get_doctype_checkbox_fields = () => { - return dialog.fields.filter(df => df.fieldname.endsWith('_fields')) - .map(df => dialog.fields_dict[df.fieldname]); - }; - - const doctype_fields = get_fields(frm.doc.reference_doctype) - .map(df => { - let reqd = (df.reqd || df.fieldname == 'naming_series') ? 1 : 0; - return { - label: df.label, - reqd: reqd, - danger: reqd, - value: df.fieldname, - checked: 1 - }; - }); - - let fields = [ - { - "label": __("Select Columns"), - "fieldname": "select_columns", - "fieldtype": "Select", - "options": "All\nMandatory\nManually", - "reqd": 1, - "onchange": function() { - const fields = get_doctype_checkbox_fields(); - fields.map(f => f.toggle(true)); - if(this.value == 'Mandatory' || this.value == 'Manually') { - checkbox_toggle(true); - fields.map(multicheck_field => { - multicheck_field.options.map(option => { - if(!option.reqd) return; - $(multicheck_field.$wrapper).find(`:checkbox[data-unit="${option.value}"]`) - .prop('checked', false) - .trigger('click'); - }); - }); - } else if(this.value == 'All'){ - $(dialog.body).find(`[data-fieldtype="MultiCheck"] :checkbox`) - .prop('disabled', true); - } - } - }, - { - "label": __("File Type"), - "fieldname": "file_type", - "fieldtype": "Select", - "options": "Excel\nCSV", - "default": "Excel" - }, - { - "label": __("Download with Data"), - "fieldname": "with_data", - "fieldtype": "Check", - "hidden": !frm.doc.overwrite, - "default": 1 - }, - { - "label": __("Select All"), - "fieldname": "select_all", - "fieldtype": "Button", - "depends_on": "eval:doc.select_columns=='Manually'", - click: function() { - checkbox_toggle(); - } - }, - { - "label": __("Unselect All"), - "fieldname": "unselect_all", - "fieldtype": "Button", - "depends_on": "eval:doc.select_columns=='Manually'", - click: function() { - checkbox_toggle(true); - } - }, - { - "label": frm.doc.reference_doctype, - "fieldname": "doctype_fields", - "fieldtype": "MultiCheck", - "options": doctype_fields, - "columns": 2, - "hidden": 1 - } - ]; - - const child_table_fields = frappe.meta.get_table_fields(frm.doc.reference_doctype) - .map(df => { - return { - "label": df.options, - "fieldname": df.fieldname + '_fields', - "fieldtype": "MultiCheck", - "options": frappe.meta.get_docfields(df.options) - .filter(filter_fields) - .map(df => ({ - label: df.label, - reqd: df.reqd ? 1 : 0, - value: df.fieldname, - checked: 1, - danger: df.reqd - })), - "columns": 2, - "hidden": 1 - }; - }); - - fields = fields.concat(child_table_fields); - - dialog = new frappe.ui.Dialog({ - title: __('Download Template'), - fields: fields, - primary_action: function(values) { - var data = values; - if (frm.doc.reference_doctype) { - var export_params = () => { - let columns = {}; - if(values.select_columns) { - columns = get_doctype_checkbox_fields().reduce((columns, field) => { - const options = field.get_checked_options(); - columns[field.df.label] = options; - return columns; - }, {}); - } - - return { - doctype: frm.doc.reference_doctype, - parent_doctype: frm.doc.reference_doctype, - select_columns: JSON.stringify(columns), - with_data: frm.doc.overwrite && data.with_data, - all_doctypes: true, - file_type: data.file_type, - template: true - }; - }; - let get_template_url = '/api/method/frappe.core.doctype.data_export.exporter.export_data'; - open_url_post(get_template_url, export_params()); - } else { - frappe.msgprint(__("Please select the Document Type.")); - } - dialog.hide(); - }, - primary_action_label: __('Download') - }); - - $(dialog.body).find('div[data-fieldname="select_all"], div[data-fieldname="unselect_all"]') - .wrapAll('
'); - const button_container = $(dialog.body).find('.inline-buttons'); - button_container.addClass('flex'); - $(button_container).find('.frappe-control').map((index, button) => { - $(button).css({"margin-right": "1em"}); - }); - - function checkbox_toggle(checked=false) { - $(dialog.body).find('[data-fieldtype="MultiCheck"]').map((index, element) => { - $(element).find(`:checkbox`).prop("checked", checked).trigger('click'); - }); - } - - return dialog; -}; diff --git a/frappe/core/doctype/data_import_legacy/data_import_legacy.json b/frappe/core/doctype/data_import_legacy/data_import_legacy.json deleted file mode 100644 index 852ccba156..0000000000 --- a/frappe/core/doctype/data_import_legacy/data_import_legacy.json +++ /dev/null @@ -1,218 +0,0 @@ -{ - "actions": [], - "allow_copy": 1, - "creation": "2020-06-11 16:13:23.813709", - "doctype": "DocType", - "document_type": "Document", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "reference_doctype", - "action", - "insert_new", - "overwrite", - "only_update", - "section_break_4", - "import_file", - "column_break_4", - "error_file", - "section_break_6", - "skip_errors", - "submit_after_import", - "ignore_encoding_errors", - "no_email", - "import_detail", - "import_status", - "show_only_errors", - "import_log", - "log_details", - "amended_from", - "total_rows", - "amended_from" - ], - "fields": [ - { - "fieldname": "reference_doctype", - "fieldtype": "Link", - "ignore_user_permissions": 1, - "in_list_view": 1, - "label": "Document Type", - "options": "DocType", - "reqd": 1 - }, - { - "fieldname": "action", - "fieldtype": "Select", - "label": "Action", - "options": "Insert new records\nUpdate records", - "reqd": 1 - }, - { - "default": "0", - "depends_on": "eval:!doc.overwrite", - "description": "New data will be inserted.", - "fieldname": "insert_new", - "fieldtype": "Check", - "hidden": 1, - "label": "Insert new records", - "set_only_once": 1 - }, - { - "default": "0", - "depends_on": "eval:!doc.insert_new", - "description": "If you are updating/overwriting already created records.", - "fieldname": "overwrite", - "fieldtype": "Check", - "hidden": 1, - "label": "Update records", - "set_only_once": 1 - }, - { - "default": "0", - "depends_on": "overwrite", - "description": "If you don't want to create any new records while updating the older records.", - "fieldname": "only_update", - "fieldtype": "Check", - "label": "Don't create new records" - }, - { - "depends_on": "eval:(!doc.__islocal)", - "fieldname": "section_break_4", - "fieldtype": "Section Break" - }, - { - "fieldname": "import_file", - "fieldtype": "Attach", - "label": "Attach file for Import" - }, - { - "fieldname": "column_break_4", - "fieldtype": "Column Break" - }, - { - "depends_on": "eval: doc.import_status == \"Partially Successful\"", - "description": "This is the template file generated with only the rows having some error. You should use this file for correction and import.", - "fieldname": "error_file", - "fieldtype": "Attach", - "label": "Generated File" - }, - { - "depends_on": "eval:(!doc.__islocal)", - "fieldname": "section_break_6", - "fieldtype": "Section Break" - }, - { - "default": "0", - "description": "If this is checked, rows with valid data will be imported and invalid rows will be dumped into a new file for you to import later.", - "fieldname": "skip_errors", - "fieldtype": "Check", - "label": "Skip rows with errors" - }, - { - "default": "0", - "fieldname": "submit_after_import", - "fieldtype": "Check", - "label": "Submit after importing" - }, - { - "default": "0", - "fieldname": "ignore_encoding_errors", - "fieldtype": "Check", - "label": "Ignore encoding errors" - }, - { - "default": "1", - "fieldname": "no_email", - "fieldtype": "Check", - "label": "Do not send Emails" - }, - { - "collapsible": 1, - "collapsible_depends_on": "eval: doc.import_status == \"Failed\"", - "depends_on": "import_status", - "fieldname": "import_detail", - "fieldtype": "Section Break", - "label": "Import Log" - }, - { - "fieldname": "import_status", - "fieldtype": "Select", - "label": "Import Status", - "options": "\nSuccessful\nFailed\nIn Progress\nPartially Successful", - "read_only": 1 - }, - { - "allow_on_submit": 1, - "default": "1", - "fieldname": "show_only_errors", - "fieldtype": "Check", - "label": "Show only errors", - "no_copy": 1, - "print_hide": 1 - }, - { - "allow_on_submit": 1, - "depends_on": "import_status", - "fieldname": "import_log", - "fieldtype": "HTML", - "label": "Import Log" - }, - { - "allow_on_submit": 1, - "fieldname": "log_details", - "fieldtype": "Code", - "hidden": 1, - "label": "Log Details", - "read_only": 1 - }, - { - "fieldname": "amended_from", - "fieldtype": "Link", - "label": "Amended From", - "no_copy": 1, - "options": "Data Import", - "print_hide": 1, - "read_only": 1 - }, - { - "fieldname": "total_rows", - "fieldtype": "Int", - "hidden": 1, - "label": "Total Rows", - "read_only": 1 - }, - { - "fieldname": "amended_from", - "fieldtype": "Link", - "label": "Amended From", - "no_copy": 1, - "options": "Data Import Legacy", - "print_hide": 1, - "read_only": 1 - } - ], - "is_submittable": 1, - "links": [], - "max_attachments": 1, - "modified": "2020-06-11 16:13:23.813709", - "modified_by": "Administrator", - "module": "Core", - "name": "Data Import Legacy", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "read": 1, - "role": "System Manager", - "share": 1, - "submit": 1, - "write": 1 - } - ], - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1, - "track_seen": 1 -} \ No newline at end of file diff --git a/frappe/core/doctype/data_import_legacy/data_import_legacy.py b/frappe/core/doctype/data_import_legacy/data_import_legacy.py deleted file mode 100644 index 63f806d75b..0000000000 --- a/frappe/core/doctype/data_import_legacy/data_import_legacy.py +++ /dev/null @@ -1,126 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2017, Frappe Technologies and contributors -# For license information, please see license.txt - -import os - -import frappe -import frappe.modules.import_file -from frappe import _ -from frappe.core.doctype.data_import_legacy.importer import upload -from frappe.model.document import Document -from frappe.modules.import_file import import_file_by_path as _import_file_by_path -from frappe.utils.background_jobs import enqueue -from frappe.utils.data import format_datetime - - -class DataImportLegacy(Document): - def autoname(self): - if not self.name: - self.name = "Import on " + format_datetime(self.creation) - - def validate(self): - if not self.import_file: - self.db_set("total_rows", 0) - if self.import_status == "In Progress": - frappe.throw(_("Can't save the form as data import is in progress.")) - - # validate the template just after the upload - # if there is total_rows in the doc, it means that the template is already validated and error free - if self.import_file and not self.total_rows: - upload(data_import_doc=self, from_data_import="Yes", validate_template=True) - - -@frappe.whitelist() -def get_importable_doctypes(): - return frappe.cache().hget("can_import", frappe.session.user) - - -@frappe.whitelist() -def import_data(data_import): - frappe.db.set_value("Data Import Legacy", data_import, "import_status", "In Progress", update_modified=False) - frappe.publish_realtime("data_import_progress", {"progress": "0", - "data_import": data_import, "reload": True}, user=frappe.session.user) - - from frappe.core.page.background_jobs.background_jobs import get_info - enqueued_jobs = [d.get("job_name") for d in get_info()] - - if data_import not in enqueued_jobs: - enqueue(upload, queue='default', timeout=6000, event='data_import', job_name=data_import, - data_import_doc=data_import, from_data_import="Yes", user=frappe.session.user) - - -def import_doc(path, overwrite=False, ignore_links=False, ignore_insert=False, - insert=False, submit=False, pre_process=None): - if os.path.isdir(path): - files = [os.path.join(path, f) for f in os.listdir(path)] - else: - files = [path] - - for f in files: - if f.endswith(".json"): - frappe.flags.mute_emails = True - _import_file_by_path(f, data_import=True, force=True, pre_process=pre_process, reset_permissions=True) - frappe.flags.mute_emails = False - frappe.db.commit() - elif f.endswith(".csv"): - import_file_by_path(f, ignore_links=ignore_links, overwrite=overwrite, submit=submit, pre_process=pre_process) - frappe.db.commit() - - -def import_file_by_path(path, ignore_links=False, overwrite=False, submit=False, pre_process=None, no_email=True): - from frappe.utils.csvutils import read_csv_content - print("Importing " + path) - with open(path, "r") as infile: - upload(rows=read_csv_content(infile.read()), ignore_links=ignore_links, no_email=no_email, overwrite=overwrite, - submit_after_import=submit, pre_process=pre_process) - - -def export_json(doctype, path, filters=None, or_filters=None, name=None, order_by="creation asc"): - def post_process(out): - del_keys = ('modified_by', 'creation', 'owner', 'idx') - for doc in out: - for key in del_keys: - if key in doc: - del doc[key] - for k, v in doc.items(): - if isinstance(v, list): - for child in v: - for key in del_keys + ('docstatus', 'doctype', 'modified', 'name'): - if key in child: - del child[key] - - out = [] - if name: - out.append(frappe.get_doc(doctype, name).as_dict()) - elif frappe.db.get_value("DocType", doctype, "issingle"): - out.append(frappe.get_doc(doctype).as_dict()) - else: - for doc in frappe.get_all(doctype, fields=["name"], filters=filters, or_filters=or_filters, limit_page_length=0, order_by=order_by): - out.append(frappe.get_doc(doctype, doc.name).as_dict()) - post_process(out) - - dirname = os.path.dirname(path) - if not os.path.exists(dirname): - path = os.path.join('..', path) - - with open(path, "w") as outfile: - outfile.write(frappe.as_json(out)) - - -def export_csv(doctype, path): - from frappe.core.doctype.data_export.exporter import export_data - with open(path, "wb") as csvfile: - export_data(doctype=doctype, all_doctypes=True, template=True, with_data=True) - csvfile.write(frappe.response.result.encode("utf-8")) - - -@frappe.whitelist() -def export_fixture(doctype, app): - if frappe.session.user != "Administrator": - raise frappe.PermissionError - - if not os.path.exists(frappe.get_app_path(app, "fixtures")): - os.mkdir(frappe.get_app_path(app, "fixtures")) - - export_json(doctype, frappe.get_app_path(app, "fixtures", frappe.scrub(doctype) + ".json"), order_by="name asc") diff --git a/frappe/core/doctype/data_import_legacy/data_import_legacy_list.js b/frappe/core/doctype/data_import_legacy/data_import_legacy_list.js deleted file mode 100644 index fcf2391313..0000000000 --- a/frappe/core/doctype/data_import_legacy/data_import_legacy_list.js +++ /dev/null @@ -1,24 +0,0 @@ -frappe.listview_settings['Data Import Legacy'] = { - add_fields: ["import_status"], - has_indicator_for_draft: 1, - get_indicator: function(doc) { - - let status = { - 'Successful': [__("Success"), "green", "import_status,=,Successful"], - 'Partially Successful': [__("Partial Success"), "blue", "import_status,=,Partially Successful"], - 'In Progress': [__("In Progress"), "orange", "import_status,=,In Progress"], - 'Failed': [__("Failed"), "red", "import_status,=,Failed"], - 'Pending': [__("Pending"), "orange", "import_status,=,"] - } - - if (doc.import_status) { - return status[doc.import_status]; - } - - if (doc.docstatus == 0) { - return status['Pending']; - } - - return status['Pending']; - } -}; diff --git a/frappe/core/doctype/data_import_legacy/importer.py b/frappe/core/doctype/data_import_legacy/importer.py deleted file mode 100644 index ceefff4410..0000000000 --- a/frappe/core/doctype/data_import_legacy/importer.py +++ /dev/null @@ -1,538 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt - -import requests -import frappe, json -import frappe.permissions - -from frappe import _ - -from frappe.utils.csvutils import getlink -from frappe.utils.dateutils import parse_date - -from frappe.utils import cint, cstr, flt, getdate, get_datetime, get_url, get_absolute_url, duration_to_seconds - - -@frappe.whitelist() -def get_data_keys(): - return frappe._dict({ - "data_separator": _('Start entering data below this line'), - "main_table": _("Table") + ":", - "parent_table": _("Parent Table") + ":", - "columns": _("Column Name") + ":", - "doctype": _("DocType") + ":" - }) - - - -@frappe.whitelist() -def upload(rows = None, submit_after_import=None, ignore_encoding_errors=False, no_email=True, overwrite=None, - update_only = None, ignore_links=False, pre_process=None, via_console=False, from_data_import="No", - skip_errors = True, data_import_doc=None, validate_template=False, user=None): - """upload data""" - - # for translations - if user: - frappe.cache().hdel("lang", user) - frappe.set_user_lang(user) - - if data_import_doc and isinstance(data_import_doc, str): - data_import_doc = frappe.get_doc("Data Import Legacy", data_import_doc) - if data_import_doc and from_data_import == "Yes": - no_email = data_import_doc.no_email - ignore_encoding_errors = data_import_doc.ignore_encoding_errors - update_only = data_import_doc.only_update - submit_after_import = data_import_doc.submit_after_import - overwrite = data_import_doc.overwrite - skip_errors = data_import_doc.skip_errors - else: - # extra input params - params = json.loads(frappe.form_dict.get("params") or '{}') - if params.get("submit_after_import"): - submit_after_import = True - if params.get("ignore_encoding_errors"): - ignore_encoding_errors = True - if not params.get("no_email"): - no_email = False - if params.get('update_only'): - update_only = True - if params.get('from_data_import'): - from_data_import = params.get('from_data_import') - if not params.get('skip_errors'): - skip_errors = params.get('skip_errors') - - frappe.flags.in_import = True - frappe.flags.mute_emails = no_email - - def get_data_keys_definition(): - return get_data_keys() - - def bad_template(): - frappe.throw(_("Please do not change the rows above {0}").format(get_data_keys_definition().data_separator)) - - def check_data_length(): - if not data: - frappe.throw(_("No data found in the file. Please reattach the new file with data.")) - - def get_start_row(): - for i, row in enumerate(rows): - if row and row[0]==get_data_keys_definition().data_separator: - return i+1 - bad_template() - - def get_header_row(key): - return get_header_row_and_idx(key)[0] - - def get_header_row_and_idx(key): - for i, row in enumerate(header): - if row and row[0]==key: - return row, i - return [], -1 - - def filter_empty_columns(columns): - empty_cols = list(filter(lambda x: x in ("", None), columns)) - - if empty_cols: - if columns[-1*len(empty_cols):] == empty_cols: - # filter empty columns if they exist at the end - columns = columns[:-1*len(empty_cols)] - else: - frappe.msgprint(_("Please make sure that there are no empty columns in the file."), - raise_exception=1) - - return columns - - def make_column_map(): - doctype_row, row_idx = get_header_row_and_idx(get_data_keys_definition().doctype) - if row_idx == -1: # old style - return - - dt = None - for i, d in enumerate(doctype_row[1:]): - if d not in ("~", "-"): - if d and doctype_row[i] in (None, '' ,'~', '-', _("DocType") + ":"): - dt, parentfield = d, None - # xls format truncates the row, so it may not have more columns - if len(doctype_row) > i+2: - parentfield = doctype_row[i+2] - doctypes.append((dt, parentfield)) - column_idx_to_fieldname[(dt, parentfield)] = {} - column_idx_to_fieldtype[(dt, parentfield)] = {} - if dt: - column_idx_to_fieldname[(dt, parentfield)][i+1] = rows[row_idx + 2][i+1] - column_idx_to_fieldtype[(dt, parentfield)][i+1] = rows[row_idx + 4][i+1] - - def get_doc(start_idx): - if doctypes: - doc = {} - attachments = [] - last_error_row_idx = None - for idx in range(start_idx, len(rows)): - last_error_row_idx = idx # pylint: disable=W0612 - if (not doc) or main_doc_empty(rows[idx]): - for dt, parentfield in doctypes: - d = {} - for column_idx in column_idx_to_fieldname[(dt, parentfield)]: - try: - fieldname = column_idx_to_fieldname[(dt, parentfield)][column_idx] - fieldtype = column_idx_to_fieldtype[(dt, parentfield)][column_idx] - - if not fieldname or not rows[idx][column_idx]: - continue - - d[fieldname] = rows[idx][column_idx] - if fieldtype in ("Int", "Check"): - d[fieldname] = cint(d[fieldname]) - elif fieldtype in ("Float", "Currency", "Percent"): - d[fieldname] = flt(d[fieldname]) - elif fieldtype == "Date": - if d[fieldname] and isinstance(d[fieldname], str): - d[fieldname] = getdate(parse_date(d[fieldname])) - elif fieldtype == "Datetime": - if d[fieldname]: - if " " in d[fieldname]: - _date, _time = d[fieldname].split() - else: - _date, _time = d[fieldname], '00:00:00' - _date = parse_date(d[fieldname]) - d[fieldname] = get_datetime(_date + " " + _time) - else: - d[fieldname] = None - elif fieldtype == "Duration": - d[fieldname] = duration_to_seconds(cstr(d[fieldname])) - elif fieldtype in ("Image", "Attach Image", "Attach"): - # added file to attachments list - attachments.append(d[fieldname]) - - elif fieldtype in ("Link", "Dynamic Link", "Data") and d[fieldname]: - # as fields can be saved in the number format(long type) in data import template - d[fieldname] = cstr(d[fieldname]) - - except IndexError: - pass - - # scrub quotes from name and modified - if d.get("name") and d["name"].startswith('"'): - d["name"] = d["name"][1:-1] - - if sum(0 if not val else 1 for val in d.values()): - d['doctype'] = dt - if dt == doctype: - doc.update(d) - else: - if not overwrite and doc.get("name"): - d['parent'] = doc["name"] - d['parenttype'] = doctype - d['parentfield'] = parentfield - doc.setdefault(d['parentfield'], []).append(d) - else: - break - - return doc, attachments, last_error_row_idx - else: - doc = frappe._dict(zip(columns, rows[start_idx][1:])) - doc['doctype'] = doctype - return doc, [], None - - # used in testing whether a row is empty or parent row or child row - # checked only 3 first columns since first two columns can be blank for example the case of - # importing the item variant where item code and item name will be blank. - def main_doc_empty(row): - if row: - for i in range(3,0,-1): - if len(row) > i and row[i]: - return False - return True - - def validate_naming(doc): - autoname = frappe.get_meta(doctype).autoname - if autoname: - if autoname[0:5] == 'field': - autoname = autoname[6:] - elif autoname == 'naming_series:': - autoname = 'naming_series' - else: - return True - - if (autoname not in doc) or (not doc[autoname]): - from frappe.model.base_document import get_controller - if not hasattr(get_controller(doctype), "autoname"): - frappe.throw(_("{0} is a mandatory field").format(autoname)) - return True - - users = frappe.db.sql_list("select name from tabUser") - def prepare_for_insert(doc): - # don't block data import if user is not set - # migrating from another system - if not doc.owner in users: - doc.owner = frappe.session.user - if not doc.modified_by in users: - doc.modified_by = frappe.session.user - - def is_valid_url(url): - is_valid = False - if url.startswith("/files") or url.startswith("/private/files"): - url = get_url(url) - - try: - r = requests.get(url) - is_valid = True if r.status_code == 200 else False - except Exception: - pass - - return is_valid - - def attach_file_to_doc(doctype, docname, file_url): - # check if attachment is already available - # check if the attachement link is relative or not - if not file_url: - return - if not is_valid_url(file_url): - return - - files = frappe.db.sql("""Select name from `tabFile` where attached_to_doctype='{doctype}' and - attached_to_name='{docname}' and (file_url='{file_url}' or thumbnail_url='{file_url}')""".format( - doctype=doctype, - docname=docname, - file_url=file_url - )) - - if files: - # file is already attached - return - - _file = frappe.get_doc({ - "doctype": "File", - "file_url": file_url, - "attached_to_name": docname, - "attached_to_doctype": doctype, - "attached_to_field": 0, - "folder": "Home/Attachments"}) - _file.save() - - - # header - filename, file_extension = ['',''] - if not rows: - _file = frappe.get_doc("File", {"file_url": data_import_doc.import_file}) - fcontent = _file.get_content() - filename, file_extension = _file.get_extension() - - if file_extension == '.xlsx' and from_data_import == 'Yes': - from frappe.utils.xlsxutils import read_xlsx_file_from_attached_file - rows = read_xlsx_file_from_attached_file(file_url=data_import_doc.import_file) - - elif file_extension == '.csv': - from frappe.utils.csvutils import read_csv_content - rows = read_csv_content(fcontent, ignore_encoding_errors) - - else: - frappe.throw(_("Unsupported File Format")) - - start_row = get_start_row() - header = rows[:start_row] - data = rows[start_row:] - try: - doctype = get_header_row(get_data_keys_definition().main_table)[1] - columns = filter_empty_columns(get_header_row(get_data_keys_definition().columns)[1:]) - except: - frappe.throw(_("Cannot change header content")) - doctypes = [] - column_idx_to_fieldname = {} - column_idx_to_fieldtype = {} - - if skip_errors: - data_rows_with_error = header - - if submit_after_import and not cint(frappe.db.get_value("DocType", - doctype, "is_submittable")): - submit_after_import = False - - parenttype = get_header_row(get_data_keys_definition().parent_table) - - if len(parenttype) > 1: - parenttype = parenttype[1] - - # check permissions - if not frappe.permissions.can_import(parenttype or doctype): - frappe.flags.mute_emails = False - return {"messages": [_("Not allowed to Import") + ": " + _(doctype)], "error": True} - - # Throw expception in case of the empty data file - check_data_length() - make_column_map() - total = len(data) - - if validate_template: - if total: - data_import_doc.total_rows = total - return True - - if overwrite==None: - overwrite = params.get('overwrite') - - # delete child rows (if parenttype) - parentfield = None - if parenttype: - parentfield = get_parent_field(doctype, parenttype) - - if overwrite: - delete_child_rows(data, doctype) - - import_log = [] - def log(**kwargs): - if via_console: - print((kwargs.get("title") + kwargs.get("message")).encode('utf-8')) - else: - import_log.append(kwargs) - - def as_link(doctype, name): - if via_console: - return "{0}: {1}".format(doctype, name) - else: - return getlink(doctype, name) - - # publish realtime task update - def publish_progress(achieved, reload=False): - if data_import_doc: - frappe.publish_realtime("data_import_progress", {"progress": str(int(100.0*achieved/total)), - "data_import": data_import_doc.name, "reload": reload}, user=frappe.session.user) - - - error_flag = rollback_flag = False - - batch_size = frappe.conf.data_import_batch_size or 1000 - - for batch_start in range(0, total, batch_size): - batch = data[batch_start:batch_start + batch_size] - - for i, row in enumerate(batch): - # bypass empty rows - if main_doc_empty(row): - continue - - row_idx = i + start_row - doc = None - - publish_progress(i) - - try: - doc, attachments, last_error_row_idx = get_doc(row_idx) - validate_naming(doc) - if pre_process: - pre_process(doc) - - original = None - if parentfield: - parent = frappe.get_doc(parenttype, doc["parent"]) - doc = parent.append(parentfield, doc) - parent.save() - else: - if overwrite and doc.get("name") and frappe.db.exists(doctype, doc["name"]): - original = frappe.get_doc(doctype, doc["name"]) - original_name = original.name - original.update(doc) - # preserve original name for case sensitivity - original.name = original_name - original.flags.ignore_links = ignore_links - original.save() - doc = original - else: - if not update_only: - doc = frappe.get_doc(doc) - prepare_for_insert(doc) - doc.flags.ignore_links = ignore_links - doc.insert() - if attachments: - # check file url and create a File document - for file_url in attachments: - attach_file_to_doc(doc.doctype, doc.name, file_url) - if submit_after_import: - doc.submit() - - # log errors - if parentfield: - log(**{"row": doc.idx, "title": 'Inserted row for "%s"' % (as_link(parenttype, doc.parent)), - "link": get_absolute_url(parenttype, doc.parent), "message": 'Document successfully saved', "indicator": "green"}) - elif submit_after_import: - log(**{"row": row_idx + 1, "title":'Submitted row for "%s"' % (as_link(doc.doctype, doc.name)), - "message": "Document successfully submitted", "link": get_absolute_url(doc.doctype, doc.name), "indicator": "blue"}) - elif original: - log(**{"row": row_idx + 1,"title":'Updated row for "%s"' % (as_link(doc.doctype, doc.name)), - "message": "Document successfully updated", "link": get_absolute_url(doc.doctype, doc.name), "indicator": "green"}) - elif not update_only: - log(**{"row": row_idx + 1, "title":'Inserted row for "%s"' % (as_link(doc.doctype, doc.name)), - "message": "Document successfully saved", "link": get_absolute_url(doc.doctype, doc.name), "indicator": "green"}) - else: - log(**{"row": row_idx + 1, "title":'Ignored row for %s' % (row[1]), "link": None, - "message": "Document updation ignored", "indicator": "orange"}) - - except Exception as e: - error_flag = True - - # build error message - if frappe.local.message_log: - err_msg = "\n".join(['

{}

'.format(json.loads(msg).get('message')) for msg in frappe.local.message_log]) - else: - err_msg = '

{}

'.format(cstr(e)) - - error_trace = frappe.get_traceback() - if error_trace: - error_log_doc = frappe.log_error(error_trace) - error_link = get_absolute_url("Error Log", error_log_doc.name) - else: - error_link = None - - log(**{ - "row": row_idx + 1, - "title": 'Error for row %s' % (len(row)>1 and frappe.safe_decode(row[1]) or ""), - "message": err_msg, - "indicator": "red", - "link":error_link - }) - - # data with error to create a new file - # include the errored data in the last row as last_error_row_idx will not be updated for the last row - if skip_errors: - if last_error_row_idx == len(rows)-1: - last_error_row_idx = len(rows) - data_rows_with_error += rows[row_idx:last_error_row_idx] - else: - rollback_flag = True - finally: - frappe.local.message_log = [] - - start_row += batch_size - if rollback_flag: - frappe.db.rollback() - else: - frappe.db.commit() - - frappe.flags.mute_emails = False - frappe.flags.in_import = False - - log_message = {"messages": import_log, "error": error_flag} - if data_import_doc: - data_import_doc.log_details = json.dumps(log_message) - - import_status = None - if error_flag and data_import_doc.skip_errors and len(data) != len(data_rows_with_error): - import_status = "Partially Successful" - # write the file with the faulty row - file_name = 'error_' + filename + file_extension - if file_extension == '.xlsx': - from frappe.utils.xlsxutils import make_xlsx - xlsx_file = make_xlsx(data_rows_with_error, "Data Import Template") - file_data = xlsx_file.getvalue() - else: - from frappe.utils.csvutils import to_csv - file_data = to_csv(data_rows_with_error) - _file = frappe.get_doc({ - "doctype": "File", - "file_name": file_name, - "attached_to_doctype": "Data Import Legacy", - "attached_to_name": data_import_doc.name, - "folder": "Home/Attachments", - "content": file_data}) - _file.save() - data_import_doc.error_file = _file.file_url - - elif error_flag: - import_status = "Failed" - else: - import_status = "Successful" - - data_import_doc.import_status = import_status - data_import_doc.save() - if data_import_doc.import_status in ["Successful", "Partially Successful"]: - data_import_doc.submit() - publish_progress(100, True) - else: - publish_progress(0, True) - frappe.db.commit() - else: - return log_message - -def get_parent_field(doctype, parenttype): - parentfield = None - - # get parentfield - if parenttype: - for d in frappe.get_meta(parenttype).get_table_fields(): - if d.options==doctype: - parentfield = d.fieldname - break - - if not parentfield: - frappe.msgprint(_("Did not find {0} for {0} ({1})").format("parentfield", parenttype, doctype)) - raise Exception - - return parentfield - -def delete_child_rows(rows, doctype): - """delete child rows for all parents""" - for p in list(set(r[1] for r in rows)): - if p: - frappe.db.sql("""delete from `tab{0}` where parent=%s""".format(doctype), p) diff --git a/frappe/core/doctype/data_import_legacy/log_details.html b/frappe/core/doctype/data_import_legacy/log_details.html deleted file mode 100644 index aa160a742b..0000000000 --- a/frappe/core/doctype/data_import_legacy/log_details.html +++ /dev/null @@ -1,38 +0,0 @@ -
-
- - - - - - - - {% for row in data %} - {% if (!show_only_errors) || (show_only_errors && row.indicator == "red") %} - - - - - - {% endif %} - {% endfor %} -
{{ __("Row No") }} {{ __("Row Status") }} {{ __("Message") }}
- {{ row.row }} - - {{ row.title }} - - {% if (import_status != "Failed" || (row.indicator == "red")) { %} -
{{ row.message }}
- {% if row.link %} - - - - - - {% endif %} - {% } else { %} - {{ __("Document can't saved.") }} - {% } %} -
-
-
\ No newline at end of file diff --git a/frappe/core/doctype/data_import_legacy/test_data_import_legacy.py b/frappe/core/doctype/data_import_legacy/test_data_import_legacy.py deleted file mode 100644 index 6f9964e8f5..0000000000 --- a/frappe/core/doctype/data_import_legacy/test_data_import_legacy.py +++ /dev/null @@ -1,8 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2020, Frappe Technologies and Contributors -# See license.txt -# import frappe -import unittest - -class TestDataImportLegacy(unittest.TestCase): - pass diff --git a/frappe/core/doctype/defaultvalue/__init__.py b/frappe/core/doctype/defaultvalue/__init__.py index 0e57cb68c3..eb5ba62e5c 100644 --- a/frappe/core/doctype/defaultvalue/__init__.py +++ b/frappe/core/doctype/defaultvalue/__init__.py @@ -1,3 +1,3 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE diff --git a/frappe/core/doctype/defaultvalue/defaultvalue.py b/frappe/core/doctype/defaultvalue/defaultvalue.py index 0ae088ee96..1d597c7fc4 100644 --- a/frappe/core/doctype/defaultvalue/defaultvalue.py +++ b/frappe/core/doctype/defaultvalue/defaultvalue.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe diff --git a/frappe/core/doctype/deleted_document/deleted_document.py b/frappe/core/doctype/deleted_document/deleted_document.py index f4109c8197..b398ec5410 100644 --- a/frappe/core/doctype/deleted_document/deleted_document.py +++ b/frappe/core/doctype/deleted_document/deleted_document.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe import json diff --git a/frappe/core/doctype/deleted_document/test_deleted_document.py b/frappe/core/doctype/deleted_document/test_deleted_document.py index d9dc2bb2d1..fb2376de90 100644 --- a/frappe/core/doctype/deleted_document/test_deleted_document.py +++ b/frappe/core/doctype/deleted_document/test_deleted_document.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/core/doctype/docfield/__init__.py b/frappe/core/doctype/docfield/__init__.py index 0e57cb68c3..eb5ba62e5c 100644 --- a/frappe/core/doctype/docfield/__init__.py +++ b/frappe/core/doctype/docfield/__init__.py @@ -1,3 +1,3 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE diff --git a/frappe/core/doctype/docfield/docfield.json b/frappe/core/doctype/docfield/docfield.json index ca134665b8..6910d615d3 100644 --- a/frappe/core/doctype/docfield/docfield.json +++ b/frappe/core/doctype/docfield/docfield.json @@ -1,498 +1,543 @@ { - "actions": [], - "autoname": "hash", - "creation": "2013-02-22 01:27:33", - "doctype": "DocType", - "document_type": "Setup", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "label_and_type", - "label", - "fieldtype", - "fieldname", - "precision", - "length", - "non_negative", - "hide_days", - "hide_seconds", - "reqd", - "search_index", - "in_list_view", - "in_standard_filter", - "in_global_search", - "in_preview", - "allow_in_quick_entry", - "bold", - "translatable", - "collapsible", - "collapsible_depends_on", - "column_break_6", - "options", - "default", - "fetch_from", - "fetch_if_empty", - "permissions", - "depends_on", - "hidden", - "read_only", - "unique", - "set_only_once", - "allow_bulk_edit", - "column_break_13", - "permlevel", - "ignore_user_permissions", - "allow_on_submit", - "report_hide", - "remember_last_selected_value", - "ignore_xss_filter", - "hide_border", - "property_depends_on_section", - "mandatory_depends_on", - "column_break_38", - "read_only_depends_on", - "display", - "in_filter", - "no_copy", - "print_hide", - "print_hide_if_no_value", - "print_width", - "width", - "columns", - "column_break_22", - "description", - "oldfieldname", - "oldfieldtype" - ], - "fields": [ - { - "fieldname": "label_and_type", - "fieldtype": "Section Break" - }, - { - "bold": 1, - "fieldname": "label", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Label", - "oldfieldname": "label", - "oldfieldtype": "Data", - "print_width": "163", - "search_index": 1, - "width": "163" - }, - { - "bold": 1, - "default": "Data", - "fieldname": "fieldtype", - "fieldtype": "Select", - "in_list_view": 1, - "label": "Type", - "oldfieldname": "fieldtype", - "oldfieldtype": "Select", - "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRead Only\nRating\nSection Break\nSelect\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nSignature", - "reqd": 1, - "search_index": 1 - }, - { - "bold": 1, - "fieldname": "fieldname", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Name", - "oldfieldname": "fieldname", - "oldfieldtype": "Data", - "search_index": 1 - }, - { - "default": "0", - "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", - "print_width": "50px", - "width": "50px" - }, - { - "depends_on": "eval:in_list([\"Float\", \"Currency\", \"Percent\"], doc.fieldtype)", - "description": "Set non-standard precision for a Float or Currency field", - "fieldname": "precision", - "fieldtype": "Select", - "label": "Precision", - "options": "\n0\n1\n2\n3\n4\n5\n6\n7\n8\n9", - "print_hide": 1 - }, - { - "depends_on": "eval:in_list(['Data', 'Link', 'Dynamic Link', 'Password', 'Select', 'Read Only', 'Attach', 'Attach Image', 'Int'], doc.fieldtype)", - "fieldname": "length", - "fieldtype": "Int", - "label": "Length" - }, - { - "default": "0", - "fieldname": "search_index", - "fieldtype": "Check", - "label": "Index", - "oldfieldname": "search_index", - "oldfieldtype": "Check", - "print_width": "50px", - "width": "50px" - }, - { - "default": "0", - "fieldname": "in_list_view", - "fieldtype": "Check", - "label": "In List View", - "print_width": "70px", - "width": "70px" - }, - { - "default": "0", - "fieldname": "in_standard_filter", - "fieldtype": "Check", - "label": "In Standard Filter" - }, - { - "default": "0", - "depends_on": "eval:([\"Data\", \"Select\", \"Table\", \"Text\", \"Text Editor\", \"Link\", \"Small Text\", \"Long Text\", \"Read Only\", \"Heading\", \"Dynamic Link\"].indexOf(doc.fieldtype) !== -1)", - "fieldname": "in_global_search", - "fieldtype": "Check", - "label": "In Global Search" - }, - { - "default": "0", - "depends_on": "eval:!in_list(['Table', 'Table MultiSelect'], doc.fieldtype);", - "fieldname": "in_preview", - "fieldtype": "Check", - "label": "In Preview" - }, - { - "default": "0", - "fieldname": "allow_in_quick_entry", - "fieldtype": "Check", - "label": "Allow in Quick Entry" - }, - { - "default": "0", - "fieldname": "bold", - "fieldtype": "Check", - "label": "Bold" - }, - { - "default": "0", - "depends_on": "eval:['Data', 'Select', 'Text', 'Small Text', 'Text Editor'].includes(doc.fieldtype)", - "fieldname": "translatable", - "fieldtype": "Check", - "label": "Translatable" - }, - { - "default": "0", - "depends_on": "eval:doc.fieldtype===\"Section Break\"", - "fieldname": "collapsible", - "fieldtype": "Check", - "label": "Collapsible", - "length": 255 - }, - { - "depends_on": "eval:doc.fieldtype==\"Section Break\"", - "fieldname": "collapsible_depends_on", - "fieldtype": "Code", - "label": "Collapsible Depends On", - "options": "JS" - }, - { - "fieldname": "column_break_6", - "fieldtype": "Column Break" - }, - { - "description": "For Links, enter the DocType as range.\nFor Select, enter list of Options, each on a new line.", - "fieldname": "options", - "fieldtype": "Small Text", - "in_list_view": 1, - "label": "Options", - "oldfieldname": "options", - "oldfieldtype": "Text" - }, - { - "fieldname": "default", - "fieldtype": "Small Text", - "label": "Default", - "oldfieldname": "default", - "oldfieldtype": "Text" - }, - { - "fieldname": "fetch_from", - "fieldtype": "Small Text", - "label": "Fetch From" - }, - { - "default": "0", - "description": "If checked, this field will be not overwritten based on Fetch From if a value already exists.", - "fieldname": "fetch_if_empty", - "fieldtype": "Check", - "label": "Fetch If Empty" - }, - { - "fieldname": "permissions", - "fieldtype": "Section Break", - "label": "Permissions" - }, - { - "fieldname": "depends_on", - "fieldtype": "Code", - "label": "Display Depends On", - "length": 255, - "oldfieldname": "depends_on", - "oldfieldtype": "Data", - "options": "JS" - }, - { - "default": "0", - "fieldname": "hidden", - "fieldtype": "Check", - "label": "Hidden", - "oldfieldname": "hidden", - "oldfieldtype": "Check", - "print_width": "50px", - "width": "50px" - }, - { - "default": "0", - "fieldname": "read_only", - "fieldtype": "Check", - "label": "Read Only", - "print_width": "50px", - "width": "50px" - }, - { - "default": "0", - "fieldname": "unique", - "fieldtype": "Check", - "label": "Unique" - }, - { - "default": "0", - "description": "Do not allow user to change after set the first time", - "fieldname": "set_only_once", - "fieldtype": "Check", - "label": "Set Only Once" - }, - { - "default": "0", - "depends_on": "eval: doc.fieldtype == \"Table\"", - "fieldname": "allow_bulk_edit", - "fieldtype": "Check", - "label": "Allow Bulk Edit" - }, - { - "fieldname": "column_break_13", - "fieldtype": "Column Break" - }, - { - "default": "0", - "fieldname": "permlevel", - "fieldtype": "Int", - "label": "Perm Level", - "oldfieldname": "permlevel", - "oldfieldtype": "Int", - "print_width": "50px", - "width": "50px" - }, - { - "default": "0", - "description": "User permissions should not apply for this Link", - "fieldname": "ignore_user_permissions", - "fieldtype": "Check", - "label": "Ignore User Permissions" - }, - { - "default": "0", - "depends_on": "eval: parent.is_submittable", - "fieldname": "allow_on_submit", - "fieldtype": "Check", - "label": "Allow on Submit", - "oldfieldname": "allow_on_submit", - "oldfieldtype": "Check", - "print_width": "50px", - "width": "50px" - }, - { - "default": "0", - "fieldname": "report_hide", - "fieldtype": "Check", - "label": "Report Hide", - "oldfieldname": "report_hide", - "oldfieldtype": "Check", - "print_width": "50px", - "width": "50px" - }, - { - "default": "0", - "depends_on": "eval:(doc.fieldtype == 'Link')", - "fieldname": "remember_last_selected_value", - "fieldtype": "Check", - "label": "Remember Last Selected Value" - }, - { - "default": "0", - "description": "Don't HTML Encode HTML tags like <script> or just characters like < or >, as they could be intentionally used in this field", - "fieldname": "ignore_xss_filter", - "fieldtype": "Check", - "label": "Ignore XSS Filter" - }, - { - "fieldname": "display", - "fieldtype": "Section Break", - "label": "Display" - }, - { - "default": "0", - "fieldname": "in_filter", - "fieldtype": "Check", - "label": "In Filter", - "oldfieldname": "in_filter", - "oldfieldtype": "Check", - "print_width": "50px", - "width": "50px" - }, - { - "default": "0", - "fieldname": "no_copy", - "fieldtype": "Check", - "label": "No Copy", - "oldfieldname": "no_copy", - "oldfieldtype": "Check", - "print_width": "50px", - "width": "50px" - }, - { - "default": "0", - "fieldname": "print_hide", - "fieldtype": "Check", - "label": "Print Hide", - "oldfieldname": "print_hide", - "oldfieldtype": "Check", - "print_width": "50px", - "width": "50px" - }, - { - "default": "0", - "depends_on": "eval:[\"Int\", \"Float\", \"Currency\", \"Percent\"].indexOf(doc.fieldtype)!==-1", - "fieldname": "print_hide_if_no_value", - "fieldtype": "Check", - "label": "Print Hide If No Value" - }, - { - "fieldname": "print_width", - "fieldtype": "Data", - "label": "Print Width" - }, - { - "fieldname": "width", - "fieldtype": "Data", - "label": "Width", - "oldfieldname": "width", - "oldfieldtype": "Data", - "print_width": "50px", - "width": "50px" - }, - { - "description": "Number of columns for a field in a List View or a Grid (Total Columns should be less than 11)", - "fieldname": "columns", - "fieldtype": "Int", - "label": "Columns" - }, - { - "fieldname": "column_break_22", - "fieldtype": "Column Break" - }, - { - "fieldname": "description", - "fieldtype": "Small Text", - "in_list_view": 1, - "label": "Description", - "oldfieldname": "description", - "oldfieldtype": "Text", - "print_width": "300px", - "width": "300px" - }, - { - "fieldname": "oldfieldname", - "fieldtype": "Data", - "hidden": 1, - "oldfieldname": "oldfieldname", - "oldfieldtype": "Data" - }, - { - "fieldname": "oldfieldtype", - "fieldtype": "Data", - "hidden": 1, - "oldfieldname": "oldfieldtype", - "oldfieldtype": "Data" - }, - { - "fieldname": "mandatory_depends_on", - "fieldtype": "Code", - "label": "Mandatory Depends On", - "options": "JS" - }, - { - "fieldname": "read_only_depends_on", - "fieldtype": "Code", - "label": "Read Only Depends On", - "options": "JS" - }, - { - "fieldname": "property_depends_on_section", - "fieldtype": "Section Break", - "label": "Property Depends On" - }, - { - "fieldname": "column_break_38", - "fieldtype": "Column Break" - }, - { - "default": "0", - "depends_on": "eval:doc.fieldtype=='Duration'", - "fieldname": "hide_days", - "fieldtype": "Check", - "label": "Hide Days" - }, - { - "default": "0", - "depends_on": "eval:doc.fieldtype=='Duration'", - "fieldname": "hide_seconds", - "fieldtype": "Check", - "label": "Hide Seconds" - }, - { - "default": "0", - "depends_on": "eval:doc.fieldtype=='Section Break'", - "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-10-29 06:09:26.454990", - "modified_by": "Administrator", - "module": "Core", - "name": "DocField", - "owner": "Administrator", - "permissions": [], - "sort_field": "modified", - "sort_order": "ASC" + "actions": [], + "autoname": "hash", + "creation": "2013-02-22 01:27:33", + "doctype": "DocType", + "document_type": "Setup", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "label_and_type", + "label", + "fieldtype", + "fieldname", + "precision", + "length", + "non_negative", + "hide_days", + "hide_seconds", + "reqd", + "search_index", + "column_break_18", + "options", + "defaults_section", + "default", + "column_break_6", + "fetch_from", + "fetch_if_empty", + "visibility_section", + "hidden", + "bold", + "allow_in_quick_entry", + "translatable", + "print_hide", + "print_hide_if_no_value", + "report_hide", + "column_break_28", + "depends_on", + "collapsible", + "collapsible_depends_on", + "hide_border", + "list__search_settings_section", + "in_list_view", + "in_standard_filter", + "in_preview", + "column_break_35", + "in_filter", + "in_global_search", + "permissions", + "read_only", + "allow_on_submit", + "ignore_user_permissions", + "allow_bulk_edit", + "column_break_13", + "permlevel", + "ignore_xss_filter", + "constraints_section", + "unique", + "no_copy", + "set_only_once", + "remember_last_selected_value", + "column_break_38", + "mandatory_depends_on", + "read_only_depends_on", + "display", + "print_width", + "width", + "max_height", + "columns", + "column_break_22", + "description", + "oldfieldname", + "oldfieldtype" + ], + "fields": [{ + "fieldname": "label_and_type", + "fieldtype": "Section Break" + }, + { + "bold": 1, + "fieldname": "label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Label", + "oldfieldname": "label", + "oldfieldtype": "Data", + "print_width": "163", + "search_index": 1, + "width": "163" + }, + { + "bold": 1, + "default": "Data", + "fieldname": "fieldtype", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Type", + "oldfieldname": "fieldtype", + "oldfieldtype": "Select", + "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRead Only\nRating\nSection Break\nSelect\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nSignature\nTab Break", + "reqd": 1, + "search_index": 1 + }, + { + "bold": 1, + "fieldname": "fieldname", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Name", + "oldfieldname": "fieldname", + "oldfieldtype": "Data", + "search_index": 1 + }, + { + "default": "0", + "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", + "print_width": "50px", + "width": "50px" + }, + { + "depends_on": "eval:in_list([\"Float\", \"Currency\", \"Percent\"], doc.fieldtype)", + "description": "Set non-standard precision for a Float or Currency field", + "fieldname": "precision", + "fieldtype": "Select", + "label": "Precision", + "options": "\n0\n1\n2\n3\n4\n5\n6\n7\n8\n9", + "print_hide": 1 + }, + { + "depends_on": "eval:in_list(['Data', 'Link', 'Dynamic Link', 'Password', 'Select', 'Read Only', 'Attach', 'Attach Image', 'Int'], doc.fieldtype)", + "fieldname": "length", + "fieldtype": "Int", + "label": "Length" + }, + { + "default": "0", + "fieldname": "search_index", + "fieldtype": "Check", + "label": "Index", + "oldfieldname": "search_index", + "oldfieldtype": "Check", + "print_width": "50px", + "width": "50px" + }, + { + "default": "0", + "fieldname": "in_list_view", + "fieldtype": "Check", + "label": "In List View", + "print_width": "70px", + "width": "70px" + }, + { + "default": "0", + "fieldname": "in_standard_filter", + "fieldtype": "Check", + "label": "In List Filter" + }, + { + "default": "0", + "depends_on": "eval:([\"Data\", \"Select\", \"Table\", \"Text\", \"Text Editor\", \"Link\", \"Small Text\", \"Long Text\", \"Read Only\", \"Heading\", \"Dynamic Link\"].indexOf(doc.fieldtype) !== -1)", + "fieldname": "in_global_search", + "fieldtype": "Check", + "label": "In Global Search" + }, + { + "default": "0", + "depends_on": "eval:!in_list(['Table', 'Table MultiSelect'], doc.fieldtype);", + "fieldname": "in_preview", + "fieldtype": "Check", + "label": "In Preview" + }, + { + "default": "0", + "fieldname": "allow_in_quick_entry", + "fieldtype": "Check", + "label": "Allow in Quick Entry" + }, + { + "default": "0", + "fieldname": "bold", + "fieldtype": "Check", + "label": "Bold" + }, + { + "default": "0", + "depends_on": "eval:['Data', 'Select', 'Text', 'Small Text', 'Text Editor'].includes(doc.fieldtype)", + "fieldname": "translatable", + "fieldtype": "Check", + "label": "Translatable" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype===\"Section Break\"", + "fieldname": "collapsible", + "fieldtype": "Check", + "label": "Collapsible", + "length": 255 + }, + { + "depends_on": "eval:doc.fieldtype==\"Section Break\" && doc.collapsible", + "fieldname": "collapsible_depends_on", + "fieldtype": "Code", + "label": "Collapsible Depends On (JS)", + "max_height": "3rem", + "options": "JS" + }, + { + "fieldname": "column_break_6", + "fieldtype": "Column Break" + }, + { + "description": "For Links, enter the DocType as range.\nFor Select, enter list of Options, each on a new line.", + "fieldname": "options", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "Options", + "oldfieldname": "options", + "oldfieldtype": "Text" + }, + { + "fieldname": "default", + "fieldtype": "Small Text", + "label": "Default", + "max_height": "3rem", + "oldfieldname": "default", + "oldfieldtype": "Text" + }, + { + "fieldname": "fetch_from", + "fieldtype": "Small Text", + "label": "Fetch From" + }, + { + "default": "0", + "fieldname": "fetch_if_empty", + "fieldtype": "Check", + "label": "Fetch only if value is not set" + }, + { + "fieldname": "permissions", + "fieldtype": "Section Break", + "label": "Permissions" + }, + { + "fieldname": "depends_on", + "fieldtype": "Code", + "label": "Display Depends On (JS)", + "length": 255, + "max_height": "3rem", + "oldfieldname": "depends_on", + "oldfieldtype": "Data", + "options": "JS" + }, + { + "default": "0", + "fieldname": "hidden", + "fieldtype": "Check", + "label": "Hidden", + "oldfieldname": "hidden", + "oldfieldtype": "Check", + "print_width": "50px", + "width": "50px" + }, + { + "default": "0", + "fieldname": "read_only", + "fieldtype": "Check", + "label": "Read Only", + "print_width": "50px", + "width": "50px" + }, + { + "default": "0", + "fieldname": "unique", + "fieldtype": "Check", + "label": "Unique" + }, + { + "default": "0", + "fieldname": "set_only_once", + "fieldtype": "Check", + "label": "Set only once" + }, + { + "default": "0", + "depends_on": "eval: doc.fieldtype == \"Table\"", + "fieldname": "allow_bulk_edit", + "fieldtype": "Check", + "label": "Allow Bulk Edit" + }, + { + "fieldname": "column_break_13", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "permlevel", + "fieldtype": "Int", + "label": "Perm Level", + "oldfieldname": "permlevel", + "oldfieldtype": "Int", + "print_width": "50px", + "width": "50px" + }, + { + "default": "0", + "fieldname": "ignore_user_permissions", + "fieldtype": "Check", + "label": "Ignore User Permissions" + }, + { + "default": "0", + "depends_on": "eval: parent.is_submittable", + "fieldname": "allow_on_submit", + "fieldtype": "Check", + "label": "Allow on Submit", + "oldfieldname": "allow_on_submit", + "oldfieldtype": "Check", + "print_width": "50px", + "width": "50px" + }, + { + "default": "0", + "fieldname": "report_hide", + "fieldtype": "Check", + "label": "Report Hide", + "oldfieldname": "report_hide", + "oldfieldtype": "Check", + "print_width": "50px", + "width": "50px" + }, + { + "default": "0", + "depends_on": "eval:(doc.fieldtype == 'Link')", + "fieldname": "remember_last_selected_value", + "fieldtype": "Check", + "label": "Remember Last Selected Value" + }, + { + "default": "0", + "description": "Don't HTML Encode HTML tags like <script> or just characters like < or >, as they could be intentionally used in this field", + "fieldname": "ignore_xss_filter", + "fieldtype": "Check", + "label": "Ignore XSS Filter" + }, + { + "fieldname": "display", + "fieldtype": "Section Break", + "label": "Display" + }, + { + "default": "0", + "fieldname": "in_filter", + "fieldtype": "Check", + "label": "In Filter", + "oldfieldname": "in_filter", + "oldfieldtype": "Check", + "print_width": "50px", + "width": "50px" + }, + { + "default": "0", + "fieldname": "no_copy", + "fieldtype": "Check", + "label": "No Copy", + "oldfieldname": "no_copy", + "oldfieldtype": "Check", + "print_width": "50px", + "width": "50px" + }, + { + "default": "0", + "fieldname": "print_hide", + "fieldtype": "Check", + "label": "Print Hide", + "oldfieldname": "print_hide", + "oldfieldtype": "Check", + "print_width": "50px", + "width": "50px" + }, + { + "default": "0", + "depends_on": "eval:[\"Int\", \"Float\", \"Currency\", \"Percent\"].indexOf(doc.fieldtype)!==-1", + "fieldname": "print_hide_if_no_value", + "fieldtype": "Check", + "label": "Print Hide If No Value" + }, + { + "fieldname": "print_width", + "fieldtype": "Data", + "label": "Print Width", + "length": 10 + }, + { + "fieldname": "width", + "fieldtype": "Data", + "label": "Width", + "length": 10, + "oldfieldname": "width", + "oldfieldtype": "Data", + "print_width": "50px", + "width": "50px" + }, + { + "description": "Number of columns for a field in a List View or a Grid (Total Columns should be less than 11)", + "fieldname": "columns", + "fieldtype": "Int", + "label": "Columns" + }, + { + "fieldname": "column_break_22", + "fieldtype": "Column Break" + }, + { + "fieldname": "description", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "Description", + "oldfieldname": "description", + "oldfieldtype": "Text", + "print_width": "300px", + "width": "300px" + }, + { + "fieldname": "oldfieldname", + "fieldtype": "Data", + "hidden": 1, + "oldfieldname": "oldfieldname", + "oldfieldtype": "Data" + }, + { + "fieldname": "oldfieldtype", + "fieldtype": "Data", + "hidden": 1, + "oldfieldname": "oldfieldtype", + "oldfieldtype": "Data" + }, + { + "fieldname": "mandatory_depends_on", + "fieldtype": "Code", + "label": "Mandatory Depends On (JS)", + "max_height": "3rem", + "options": "JS" + }, + { + "fieldname": "read_only_depends_on", + "fieldtype": "Code", + "label": "Read Only Depends On (JS)", + "max_height": "3rem", + "options": "JS" + }, + { + "fieldname": "column_break_38", + "fieldtype": "Column Break" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype=='Duration'", + "fieldname": "hide_days", + "fieldtype": "Check", + "label": "Hide Days" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype=='Duration'", + "fieldname": "hide_seconds", + "fieldtype": "Check", + "label": "Hide Seconds" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype=='Section Break'", + "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" + }, + { + "fieldname": "column_break_18", + "fieldtype": "Column Break" + }, + { + "fieldname": "defaults_section", + "fieldtype": "Section Break", + "label": "Defaults", + "max_height": "2rem" + }, + { + "fieldname": "visibility_section", + "fieldtype": "Section Break", + "label": "Visibility" + }, + { + "fieldname": "column_break_28", + "fieldtype": "Column Break" + }, + { + "fieldname": "constraints_section", + "fieldtype": "Section Break", + "label": "Constraints" + }, + { + "fieldname": "max_height", + "fieldtype": "Data", + "label": "Max Height", + "length": 10 + }, + { + "fieldname": "list__search_settings_section", + "fieldtype": "Section Break", + "label": "List / Search Settings" + }, + { + "fieldname": "column_break_35", + "fieldtype": "Column Break" + } + ], + "idx": 1, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-09-04 19:41:23.684094", + "modified_by": "Administrator", + "module": "Core", + "name": "DocField", + "naming_rule": "Random", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "ASC" } \ No newline at end of file diff --git a/frappe/core/doctype/docfield/docfield.py b/frappe/core/doctype/docfield/docfield.py index 175cba3c7c..4dd49631ae 100644 --- a/frappe/core/doctype/docfield/docfield.py +++ b/frappe/core/doctype/docfield/docfield.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/docperm/__init__.py b/frappe/core/doctype/docperm/__init__.py index 0e57cb68c3..eb5ba62e5c 100644 --- a/frappe/core/doctype/docperm/__init__.py +++ b/frappe/core/doctype/docperm/__init__.py @@ -1,3 +1,3 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE diff --git a/frappe/core/doctype/docperm/docperm.py b/frappe/core/doctype/docperm/docperm.py index 9732cde920..4751816dc5 100644 --- a/frappe/core/doctype/docperm/docperm.py +++ b/frappe/core/doctype/docperm/docperm.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe diff --git a/frappe/core/doctype/docshare/docshare.py b/frappe/core/doctype/docshare/docshare.py index 2d7b6b9e48..6320fba60b 100644 --- a/frappe/core/doctype/docshare/docshare.py +++ b/frappe/core/doctype/docshare/docshare.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/docshare/test_docshare.py b/frappe/core/doctype/docshare/test_docshare.py index 6551dabbea..cbdaa8ebaf 100644 --- a/frappe/core/doctype/docshare/test_docshare.py +++ b/frappe/core/doctype/docshare/test_docshare.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import frappe.share diff --git a/frappe/core/doctype/doctype/__init__.py b/frappe/core/doctype/doctype/__init__.py index 0e57cb68c3..eb5ba62e5c 100644 --- a/frappe/core/doctype/doctype/__init__.py +++ b/frappe/core/doctype/doctype/__init__.py @@ -1,3 +1,3 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE diff --git a/frappe/core/doctype/doctype/doctype.js b/frappe/core/doctype/doctype/doctype.js index b4d3fb9a89..262a6efd90 100644 --- a/frappe/core/doctype/doctype/doctype.js +++ b/frappe/core/doctype/doctype/doctype.js @@ -61,9 +61,161 @@ frappe.ui.form.on('DocType', { __('In Grid View') : __('In List View'); frm.events.autoname(frm); + frm.events.set_naming_rule_description(frm); + }, + + naming_rule: function(frm) { + // set the "autoname" property based on naming_rule + if (frm.doc.naming_rule && !frm.__from_autoname) { + + // flag to avoid recursion + frm.__from_naming_rule = true; + + if (frm.doc.naming_rule=='Set by user') { + frm.set_value('autoname', 'Prompt'); + } else if (frm.doc.naming_rule=='By fieldname') { + frm.set_value('autoname', 'field:'); + } else if (frm.doc.naming_rule=='By "Naming Series" field') { + frm.set_value('autoname', 'naming_series:'); + } else if (frm.doc.naming_rule=='Expression') { + frm.set_value('autoname', 'format:'); + } else if (frm.doc.naming_rule=='Expression (old style)') { + // pass + } else if (frm.doc.naming_rule=='Random') { + frm.set_value('autoname', 'hash'); + } + setTimeout(() =>frm.__from_naming_rule = false, 500); + + frm.events.set_naming_rule_description(frm); + } + + }, + + set_naming_rule_description(frm) { + let naming_rule_description = { + 'Set by user': '', + 'By fieldname': 'Format: field:[fieldname]. Valid fieldname must exist', + 'By "Naming Series" field': 'Format: naming_series:[fieldname]. Fieldname called naming_series must exist', + 'Expression': 'Format: format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####} - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.', + 'Expression (old style)': 'Format: EXAMPLE-.##### Series by prefix (separated by a dot)', + 'Random': '', + 'By script': '' + }; + + if (frm.doc.naming_rule) { + frm.get_field('autoname').set_description(naming_rule_description[frm.doc.naming_rule]); + } }, autoname: function(frm) { + // set naming_rule based on autoname (for old doctypes where its not been set) + if (frm.doc.autoname && !frm.doc.naming_rule && !frm.__from_naming_rule) { + // flag to avoid recursion + frm.__from_autoname = true; + if (frm.doc.autoname.toLowerCase() === 'prompt') { + frm.set_value('naming_rule', 'Set by user'); + } else if (frm.doc.autoname.startsWith('field:')) { + frm.set_value('naming_rule', 'By fieldname'); + } else if (frm.doc.autoname.startsWith('naming_series:')) { + frm.set_value('naming_rule', 'By "Naming Series" field'); + } else if (frm.doc.autoname.startsWith('format:')) { + frm.set_value('naming_rule', 'Expression'); + } else if (frm.doc.autoname.toLowerCase() === 'hash') { + frm.set_value('naming_rule', 'Random'); + } else { + frm.set_value('naming_rule', 'Expression (old style)'); + } + setTimeout(() => frm.__from_autoname = false, 500); + } + frm.set_df_property('fields', 'reqd', frm.doc.autoname !== 'Prompt'); } -}) +}); + +frappe.ui.form.on("DocField", { + form_render(frm, doctype, docname) { + // Render two select fields for Fetch From instead of Small Text for better UX + let field = frm.cur_grid.grid_form.fields_dict.fetch_from; + $(field.input_area).hide(); + + let $doctype_select = $(``); + let $wrapper = $('
'); + $wrapper.append($doctype_select, $field_select); + field.$input_wrapper.append($wrapper); + $doctype_select.wrap('
'); + $field_select.wrap('
'); + + let row = frappe.get_doc(doctype, docname); + let curr_value = { doctype: null, fieldname: null }; + if (row.fetch_from) { + let [doctype, fieldname] = row.fetch_from.split("."); + curr_value.doctype = doctype; + curr_value.fieldname = fieldname; + } + let curr_df_link_doctype = row.fieldtype == "Link" ? row.options : null; + + let doctypes = frm.doc.fields + .filter(df => df.fieldtype == "Link") + .filter(df => df.options && df.options != curr_df_link_doctype) + .map(df => ({ + label: `${df.options} (${df.fieldname})`, + value: df.fieldname + })); + $doctype_select.add_options([ + { label: __("Select DocType"), value: "", selected: true }, + ...doctypes + ]); + + $doctype_select.on("change", () => { + row.fetch_from = ""; + frm.dirty(); + update_fieldname_options(); + }); + + function update_fieldname_options() { + $field_select.find("option").remove(); + + let link_fieldname = $doctype_select.val(); + if (!link_fieldname) return; + let link_field = frm.doc.fields.find( + df => df.fieldname === link_fieldname + ); + let link_doctype = link_field.options; + frappe.model.with_doctype(link_doctype, () => { + let fields = frappe.meta + .get_docfields(link_doctype, null, { + fieldtype: ["not in", frappe.model.no_value_type] + }) + .map(df => ({ + label: `${df.label} (${df.fieldtype})`, + value: df.fieldname + })); + $field_select.add_options([ + { + label: __("Select Field"), + value: "", + selected: true, + disabled: true + }, + ...fields + ]); + + if (curr_value.fieldname) { + $field_select.val(curr_value.fieldname); + } + }); + } + + $field_select.on("change", () => { + let fetch_from = `${$doctype_select.val()}.${$field_select.val()}`; + row.fetch_from = fetch_from; + frm.dirty(); + }); + + if (curr_value.doctype) { + $doctype_select.val(curr_value.doctype); + update_fieldname_options(); + } + } +}); diff --git a/frappe/core/doctype/doctype/doctype.json b/frappe/core/doctype/doctype/doctype.json index 7f93d3130a..e18edc1512 100644 --- a/frappe/core/doctype/doctype/doctype.json +++ b/frappe/core/doctype/doctype/doctype.json @@ -1,665 +1,686 @@ { - "actions": [], - "allow_rename": 1, - "autoname": "Prompt", - "creation": "2013-02-18 13:36:19", - "description": "DocType is a Table / Form in the application.", - "doctype": "DocType", - "document_type": "Document", - "engine": "InnoDB", - "field_order": [ - "sb0", - "module", - "is_submittable", - "istable", - "issingle", - "is_tree", - "editable_grid", - "quick_entry", - "cb01", - "track_changes", - "track_seen", - "track_views", - "custom", - "beta", - "is_virtual", - "fields_section_break", - "fields", - "sb1", - "autoname", - "name_case", - "column_break_15", - "description", - "documentation", - "form_settings_section", - "image_field", - "timeline_field", - "nsm_parent_field", - "max_attachments", - "column_break_23", - "hide_toolbar", - "allow_copy", - "allow_rename", - "allow_import", - "allow_events_in_timeline", - "allow_auto_repeat", - "view_settings", - "title_field", - "search_fields", - "default_print_format", - "sort_field", - "sort_order", - "column_break_29", - "document_type", - "icon", - "color", - "show_preview_popup", - "show_name_in_global_search", - "email_settings_sb", - "default_email_template", - "column_break_51", - "email_append_to", - "sender_field", - "subject_field", - "sb2", - "permissions", - "restrict_to_domain", - "read_only", - "in_create", - "actions_section", - "actions", - "links_section", - "links", - "web_view", - "has_web_view", - "allow_guest_to_view", - "index_web_pages_for_search", - "route", - "is_published_field", - "advanced", - "engine" - ], - "fields": [ - { - "fieldname": "sb0", - "fieldtype": "Section Break", - "oldfieldtype": "Section Break" - }, - { - "fieldname": "module", - "fieldtype": "Link", - "in_list_view": 1, - "in_standard_filter": 1, - "label": "Module", - "oldfieldname": "module", - "oldfieldtype": "Link", - "options": "Module Def", - "reqd": 1, - "search_index": 1 - }, - { - "default": "0", - "depends_on": "eval:!doc.istable", - "description": "Once submitted, submittable documents cannot be changed. They can only be Cancelled and Amended.", - "fieldname": "is_submittable", - "fieldtype": "Check", - "label": "Is Submittable" - }, - { - "default": "0", - "description": "Child Tables are shown as a Grid in other DocTypes", - "fieldname": "istable", - "fieldtype": "Check", - "in_standard_filter": 1, - "label": "Is Child Table", - "oldfieldname": "istable", - "oldfieldtype": "Check" - }, - { - "default": "0", - "depends_on": "eval:!doc.istable", - "description": "Single Types have only one record no tables associated. Values are stored in tabSingles", - "fieldname": "issingle", - "fieldtype": "Check", - "in_standard_filter": 1, - "label": "Is Single", - "oldfieldname": "issingle", - "oldfieldtype": "Check", - "set_only_once": 1 - }, - { - "default": "1", - "depends_on": "istable", - "fieldname": "editable_grid", - "fieldtype": "Check", - "label": "Editable Grid" - }, - { - "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", - "fieldtype": "Check", - "label": "Quick Entry" - }, - { - "fieldname": "cb01", - "fieldtype": "Column Break" - }, - { - "default": "1", - "depends_on": "eval:!doc.istable", - "description": "If enabled, changes to the document are tracked and shown in timeline", - "fieldname": "track_changes", - "fieldtype": "Check", - "label": "Track Changes" - }, - { - "default": "0", - "depends_on": "eval:!doc.istable", - "description": "If enabled, the document is marked as seen, the first time a user opens it", - "fieldname": "track_seen", - "fieldtype": "Check", - "label": "Track Seen" - }, - { - "default": "0", - "depends_on": "eval:!doc.istable", - "description": "If enabled, document views are tracked, this can happen multiple times", - "fieldname": "track_views", - "fieldtype": "Check", - "label": "Track Views" - }, - { - "default": "0", - "fieldname": "custom", - "fieldtype": "Check", - "label": "Custom?" - }, - { - "default": "0", - "fieldname": "beta", - "fieldtype": "Check", - "label": "Beta" - }, - { - "fieldname": "fields_section_break", - "fieldtype": "Section Break", - "label": "Fields", - "oldfieldtype": "Section Break" - }, - { - "fieldname": "fields", - "fieldtype": "Table", - "label": "Fields", - "oldfieldname": "fields", - "oldfieldtype": "Table", - "options": "DocField" - }, - { - "fieldname": "sb1", - "fieldtype": "Section Break", - "label": "Naming" - }, - { - "description": "Naming Options:\n
  1. field:[fieldname] - By Field
  2. naming_series: - By Naming Series (field called naming_series must be present
  3. Prompt - Prompt user for a name
  4. [series] - Series by prefix (separated by a dot); for example PRE.#####
  5. \n
  6. format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####} - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.
", - "fieldname": "autoname", - "fieldtype": "Data", - "label": "Auto Name", - "oldfieldname": "autoname", - "oldfieldtype": "Data" - }, - { - "fieldname": "name_case", - "fieldtype": "Select", - "label": "Name Case", - "oldfieldname": "name_case", - "oldfieldtype": "Select", - "options": "\nTitle Case\nUPPER CASE" - }, - { - "fieldname": "column_break_15", - "fieldtype": "Column Break" - }, - { - "fieldname": "description", - "fieldtype": "Small Text", - "label": "Description", - "oldfieldname": "description", - "oldfieldtype": "Text" - }, - { - "collapsible": 1, - "fieldname": "form_settings_section", - "fieldtype": "Section Break", - "label": "Form Settings" - }, - { - "description": "Must be of type \"Attach Image\"", - "fieldname": "image_field", - "fieldtype": "Data", - "label": "Image Field" - }, - { - "depends_on": "eval:!doc.istable", - "description": "Comments and Communications will be associated with this linked document", - "fieldname": "timeline_field", - "fieldtype": "Data", - "label": "Timeline Field" - }, - { - "fieldname": "max_attachments", - "fieldtype": "Int", - "label": "Max Attachments", - "oldfieldname": "max_attachments", - "oldfieldtype": "Int" - }, - { - "fieldname": "column_break_23", - "fieldtype": "Column Break" - }, - { - "default": "0", - "fieldname": "hide_toolbar", - "fieldtype": "Check", - "label": "Hide Sidebar and Menu", - "oldfieldname": "hide_toolbar", - "oldfieldtype": "Check" - }, - { - "default": "0", - "fieldname": "allow_copy", - "fieldtype": "Check", - "label": "Hide Copy", - "oldfieldname": "allow_copy", - "oldfieldtype": "Check" - }, - { - "default": "0", - "fieldname": "allow_rename", - "fieldtype": "Check", - "label": "Allow Rename", - "oldfieldname": "allow_rename", - "oldfieldtype": "Check" - }, - { - "default": "0", - "fieldname": "allow_import", - "fieldtype": "Check", - "label": "Allow Import (via Data Import Tool)" - }, - { - "default": "0", - "fieldname": "allow_events_in_timeline", - "fieldtype": "Check", - "label": "Allow events in timeline" - }, - { - "default": "0", - "fieldname": "allow_auto_repeat", - "fieldtype": "Check", - "label": "Allow Auto Repeat" - }, - { - "collapsible": 1, - "fieldname": "view_settings", - "fieldtype": "Section Break", - "label": "View Settings" - }, - { - "depends_on": "eval:!doc.istable", - "fieldname": "title_field", - "fieldtype": "Data", - "label": "Title Field" - }, - { - "depends_on": "eval:!doc.istable", - "fieldname": "search_fields", - "fieldtype": "Data", - "label": "Search Fields", - "oldfieldname": "search_fields", - "oldfieldtype": "Data" - }, - { - "fieldname": "default_print_format", - "fieldtype": "Data", - "label": "Default Print Format" - }, - { - "default": "modified", - "depends_on": "eval:!doc.istable", - "fieldname": "sort_field", - "fieldtype": "Data", - "label": "Default Sort Field" - }, - { - "default": "DESC", - "depends_on": "eval:!doc.istable", - "fieldname": "sort_order", - "fieldtype": "Select", - "label": "Default Sort Order", - "options": "ASC\nDESC" - }, - { - "fieldname": "column_break_29", - "fieldtype": "Column Break" - }, - { - "fieldname": "document_type", - "fieldtype": "Select", - "label": "Show in Module Section", - "oldfieldname": "document_type", - "oldfieldtype": "Select", - "options": "\nDocument\nSetup\nSystem\nOther" - }, - { - "fieldname": "icon", - "fieldtype": "Data", - "label": "Icon" - }, - { - "fieldname": "color", - "fieldtype": "Data", - "label": "Color" - }, - { - "default": "0", - "fieldname": "show_preview_popup", - "fieldtype": "Check", - "label": "Show Preview Popup" - }, - { - "default": "0", - "fieldname": "show_name_in_global_search", - "fieldtype": "Check", - "label": "Make \"name\" searchable in Global Search" - }, - { - "depends_on": "eval:!doc.istable", - "fieldname": "sb2", - "fieldtype": "Section Break", - "label": "Permission Rules" - }, - { - "fieldname": "permissions", - "fieldtype": "Table", - "label": "Permissions", - "oldfieldname": "permissions", - "oldfieldtype": "Table", - "options": "DocPerm" - }, - { - "fieldname": "restrict_to_domain", - "fieldtype": "Link", - "label": "Restrict To Domain", - "options": "Domain" - }, - { - "default": "0", - "fieldname": "read_only", - "fieldtype": "Check", - "label": "User Cannot Search", - "oldfieldname": "read_only", - "oldfieldtype": "Check" - }, - { - "default": "0", - "fieldname": "in_create", - "fieldtype": "Check", - "label": "User Cannot Create", - "oldfieldname": "in_create", - "oldfieldtype": "Check" - }, - { - "depends_on": "eval:doc.custom===0", - "fieldname": "web_view", - "fieldtype": "Section Break", - "label": "Web View" - }, - { - "default": "0", - "fieldname": "has_web_view", - "fieldtype": "Check", - "label": "Has Web View" - }, - { - "default": "0", - "depends_on": "has_web_view", - "fieldname": "allow_guest_to_view", - "fieldtype": "Check", - "label": "Allow Guest to View" - }, - { - "depends_on": "eval:!doc.istable", - "fieldname": "route", - "fieldtype": "Data", - "label": "Route" - }, - { - "depends_on": "has_web_view", - "fieldname": "is_published_field", - "fieldtype": "Data", - "label": "Is Published Field" - }, - { - "collapsible": 1, - "fieldname": "advanced", - "fieldtype": "Section Break", - "hidden": 1, - "label": "Advanced" - }, - { - "default": "InnoDB", - "depends_on": "eval:!doc.issingle", - "fieldname": "engine", - "fieldtype": "Select", - "label": "Database Engine", - "options": "InnoDB\nMyISAM" - }, - { - "default": "0", - "description": "Tree structures are implemented using Nested Set", - "fieldname": "is_tree", - "fieldtype": "Check", - "label": "Is Tree" - }, - { - "depends_on": "is_tree", - "fieldname": "nsm_parent_field", - "fieldtype": "Data", - "label": "Parent Field (Tree)" - }, - { - "description": "URL for documentation or help", - "fieldname": "documentation", - "fieldtype": "Data", - "label": "Documentation Link" - }, - { - "collapsible": 1, - "collapsible_depends_on": "actions", - "fieldname": "actions_section", - "fieldtype": "Section Break", - "label": "Actions" - }, - { - "fieldname": "actions", - "fieldtype": "Table", - "label": "Actions", - "options": "DocType Action" - }, - { - "collapsible": 1, - "collapsible_depends_on": "links", - "fieldname": "links_section", - "fieldtype": "Section Break", - "label": "Linked Documents" - }, - { - "fieldname": "links", - "fieldtype": "Table", - "label": "Links", - "options": "DocType Link" - }, - { - "depends_on": "email_append_to", - "fieldname": "subject_field", - "fieldtype": "Data", - "label": "Subject Field" - }, - { - "depends_on": "email_append_to", - "fieldname": "sender_field", - "fieldtype": "Data", - "label": "Sender Field", - "mandatory_depends_on": "email_append_to" - }, - { - "default": "0", - "fieldname": "email_append_to", - "fieldtype": "Check", - "label": "Allow document creation via Email" - }, - { - "collapsible": 1, - "fieldname": "email_settings_sb", - "fieldtype": "Section Break", - "label": "Email Settings" - }, - { - "default": "1", - "fieldname": "index_web_pages_for_search", - "fieldtype": "Check", - "label": "Index Web Pages for Search" - }, - { - "default": "0", - "fieldname": "is_virtual", - "fieldtype": "Check", - "label": "Is Virtual" - }, - { - "fieldname": "default_email_template", - "fieldtype": "Link", - "label": "Default Email Template", - "options": "Email Template" - }, - { - "fieldname": "column_break_51", - "fieldtype": "Column Break" - } - ], - "icon": "fa fa-bolt", - "idx": 6, - "links": [ - { - "group": "Views", - "link_doctype": "Report", - "link_fieldname": "ref_doctype" - }, - { - "group": "Workflow", - "link_doctype": "Workflow", - "link_fieldname": "document_type" - }, - { - "group": "Workflow", - "link_doctype": "Notification", - "link_fieldname": "document_type" - }, - { - "group": "Customization", - "link_doctype": "Custom Field", - "link_fieldname": "dt" - }, - { - "group": "Customization", - "link_doctype": "Client Script", - "link_fieldname": "dt" - }, - { - "group": "Customization", - "link_doctype": "Server Script", - "link_fieldname": "reference_doctype" - }, - { - "group": "Workflow", - "link_doctype": "Webhook", - "link_fieldname": "webhook_doctype" - }, - { - "group": "Views", - "link_doctype": "Print Format", - "link_fieldname": "doc_type" - }, - { - "group": "Views", - "link_doctype": "Web Form", - "link_fieldname": "doc_type" - }, - { - "group": "Views", - "link_doctype": "Calendar View", - "link_fieldname": "reference_doctype" - }, - { - "group": "Views", - "link_doctype": "Kanban Board", - "link_fieldname": "reference_doctype" - }, - { - "group": "Workflow", - "link_doctype": "Onboarding Step", - "link_fieldname": "reference_document" - }, - { - "group": "Rules", - "link_doctype": "Auto Repeat", - "link_fieldname": "reference_doctype" - }, - { - "group": "Rules", - "link_doctype": "Assignment Rule", - "link_fieldname": "document_type" - }, - { - "group": "Rules", - "link_doctype": "Energy Point Rule", - "link_fieldname": "reference_doctype" - } - ], - "modified": "2021-04-16 12:26:41.031135", - "modified_by": "Administrator", - "module": "Core", - "name": "DocType", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "write": 1 - }, - { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "Administrator", - "share": 1, - "write": 1 - } - ], - "route": "doctype", - "search_fields": "module", - "show_name_in_global_search": 1, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1 + "actions": [], + "allow_rename": 1, + "autoname": "Prompt", + "creation": "2013-02-18 13:36:19", + "description": "DocType is a Table / Form in the application.", + "doctype": "DocType", + "document_type": "Document", + "engine": "InnoDB", + "field_order": [ + "sb0", + "module", + "is_submittable", + "istable", + "issingle", + "is_tree", + "editable_grid", + "quick_entry", + "cb01", + "track_changes", + "track_seen", + "track_views", + "custom", + "beta", + "is_virtual", + "fields_section_break", + "fields", + "sb1", + "naming_rule", + "autoname", + "name_case", + "allow_rename", + "column_break_15", + "description", + "documentation", + "form_settings_section", + "image_field", + "timeline_field", + "nsm_parent_field", + "max_attachments", + "column_break_23", + "hide_toolbar", + "allow_copy", + "allow_import", + "allow_events_in_timeline", + "allow_auto_repeat", + "view_settings", + "title_field", + "search_fields", + "default_print_format", + "sort_field", + "sort_order", + "column_break_29", + "document_type", + "icon", + "color", + "show_preview_popup", + "show_name_in_global_search", + "email_settings_sb", + "default_email_template", + "column_break_51", + "email_append_to", + "sender_field", + "subject_field", + "sb2", + "permissions", + "restrict_to_domain", + "read_only", + "in_create", + "actions_section", + "actions", + "links_section", + "links", + "web_view", + "has_web_view", + "allow_guest_to_view", + "index_web_pages_for_search", + "route", + "is_published_field", + "website_search_field", + "advanced", + "engine", + "migration_hash" + ], + "fields": [ + { + "fieldname": "sb0", + "fieldtype": "Section Break", + "oldfieldtype": "Section Break" + }, + { + "fieldname": "module", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Module", + "oldfieldname": "module", + "oldfieldtype": "Link", + "options": "Module Def", + "reqd": 1, + "search_index": 1 + }, + { + "default": "0", + "depends_on": "eval:!doc.istable", + "description": "Once submitted, submittable documents cannot be changed. They can only be Cancelled and Amended.", + "fieldname": "is_submittable", + "fieldtype": "Check", + "label": "Is Submittable" + }, + { + "default": "0", + "description": "Child Tables are shown as a Grid in other DocTypes", + "fieldname": "istable", + "fieldtype": "Check", + "in_standard_filter": 1, + "label": "Is Child Table", + "oldfieldname": "istable", + "oldfieldtype": "Check" + }, + { + "default": "0", + "depends_on": "eval:!doc.istable", + "description": "Single Types have only one record no tables associated. Values are stored in tabSingles", + "fieldname": "issingle", + "fieldtype": "Check", + "in_standard_filter": 1, + "label": "Is Single", + "oldfieldname": "issingle", + "oldfieldtype": "Check", + "set_only_once": 1 + }, + { + "default": "1", + "depends_on": "istable", + "fieldname": "editable_grid", + "fieldtype": "Check", + "label": "Editable Grid" + }, + { + "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", + "fieldtype": "Check", + "label": "Quick Entry" + }, + { + "fieldname": "cb01", + "fieldtype": "Column Break" + }, + { + "default": "0", + "depends_on": "eval:!doc.istable", + "description": "If enabled, changes to the document are tracked and shown in timeline", + "fieldname": "track_changes", + "fieldtype": "Check", + "label": "Track Changes" + }, + { + "default": "0", + "depends_on": "eval:!doc.istable", + "description": "If enabled, the document is marked as seen, the first time a user opens it", + "fieldname": "track_seen", + "fieldtype": "Check", + "label": "Track Seen" + }, + { + "default": "0", + "depends_on": "eval:!doc.istable", + "description": "If enabled, document views are tracked, this can happen multiple times", + "fieldname": "track_views", + "fieldtype": "Check", + "label": "Track Views" + }, + { + "default": "0", + "fieldname": "custom", + "fieldtype": "Check", + "label": "Custom?" + }, + { + "default": "0", + "fieldname": "beta", + "fieldtype": "Check", + "label": "Beta" + }, + { + "fieldname": "fields_section_break", + "fieldtype": "Section Break", + "label": "Fields", + "oldfieldtype": "Section Break" + }, + { + "fieldname": "fields", + "fieldtype": "Table", + "label": "Fields", + "oldfieldname": "fields", + "oldfieldtype": "Table", + "options": "DocField" + }, + { + "fieldname": "sb1", + "fieldtype": "Section Break", + "label": "Naming" + }, + { + "description": "Naming Options:\n
  1. field:[fieldname] - By Field
  2. naming_series: - By Naming Series (field called naming_series must be present
  3. Prompt - Prompt user for a name
  4. [series] - Series by prefix (separated by a dot); for example PRE.#####
  5. \n
  6. format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####} - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.
", + "fieldname": "autoname", + "fieldtype": "Data", + "label": "Auto Name", + "oldfieldname": "autoname", + "oldfieldtype": "Data" + }, + { + "fieldname": "name_case", + "fieldtype": "Select", + "label": "Name Case", + "oldfieldname": "name_case", + "oldfieldtype": "Select", + "options": "\nTitle Case\nUPPER CASE" + }, + { + "fieldname": "column_break_15", + "fieldtype": "Column Break" + }, + { + "fieldname": "description", + "fieldtype": "Small Text", + "label": "Description", + "oldfieldname": "description", + "oldfieldtype": "Text" + }, + { + "collapsible": 1, + "fieldname": "form_settings_section", + "fieldtype": "Section Break", + "label": "Form Settings" + }, + { + "description": "Must be of type \"Attach Image\"", + "fieldname": "image_field", + "fieldtype": "Data", + "label": "Image Field" + }, + { + "depends_on": "eval:!doc.istable", + "description": "Comments and Communications will be associated with this linked document", + "fieldname": "timeline_field", + "fieldtype": "Data", + "label": "Timeline Field" + }, + { + "fieldname": "max_attachments", + "fieldtype": "Int", + "label": "Max Attachments", + "oldfieldname": "max_attachments", + "oldfieldtype": "Int" + }, + { + "fieldname": "column_break_23", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "hide_toolbar", + "fieldtype": "Check", + "label": "Hide Sidebar and Menu", + "oldfieldname": "hide_toolbar", + "oldfieldtype": "Check" + }, + { + "default": "0", + "fieldname": "allow_copy", + "fieldtype": "Check", + "label": "Hide Copy", + "oldfieldname": "allow_copy", + "oldfieldtype": "Check" + }, + { + "default": "1", + "fieldname": "allow_rename", + "fieldtype": "Check", + "label": "Allow Rename", + "oldfieldname": "allow_rename", + "oldfieldtype": "Check" + }, + { + "default": "0", + "fieldname": "allow_import", + "fieldtype": "Check", + "label": "Allow Import (via Data Import Tool)" + }, + { + "default": "0", + "fieldname": "allow_events_in_timeline", + "fieldtype": "Check", + "label": "Allow events in timeline" + }, + { + "default": "0", + "fieldname": "allow_auto_repeat", + "fieldtype": "Check", + "label": "Allow Auto Repeat" + }, + { + "collapsible": 1, + "fieldname": "view_settings", + "fieldtype": "Section Break", + "label": "View Settings" + }, + { + "depends_on": "eval:!doc.istable", + "fieldname": "title_field", + "fieldtype": "Data", + "label": "Title Field" + }, + { + "depends_on": "eval:!doc.istable", + "fieldname": "search_fields", + "fieldtype": "Data", + "label": "Search Fields", + "oldfieldname": "search_fields", + "oldfieldtype": "Data" + }, + { + "fieldname": "default_print_format", + "fieldtype": "Data", + "label": "Default Print Format" + }, + { + "default": "modified", + "depends_on": "eval:!doc.istable", + "fieldname": "sort_field", + "fieldtype": "Data", + "label": "Default Sort Field" + }, + { + "default": "DESC", + "depends_on": "eval:!doc.istable", + "fieldname": "sort_order", + "fieldtype": "Select", + "label": "Default Sort Order", + "options": "ASC\nDESC" + }, + { + "fieldname": "column_break_29", + "fieldtype": "Column Break" + }, + { + "fieldname": "document_type", + "fieldtype": "Select", + "label": "Show in Module Section", + "oldfieldname": "document_type", + "oldfieldtype": "Select", + "options": "\nDocument\nSetup\nSystem\nOther" + }, + { + "fieldname": "icon", + "fieldtype": "Data", + "label": "Icon" + }, + { + "fieldname": "color", + "fieldtype": "Data", + "label": "Color" + }, + { + "default": "0", + "fieldname": "show_preview_popup", + "fieldtype": "Check", + "label": "Show Preview Popup" + }, + { + "default": "0", + "fieldname": "show_name_in_global_search", + "fieldtype": "Check", + "label": "Make \"name\" searchable in Global Search" + }, + { + "depends_on": "eval:!doc.istable", + "fieldname": "sb2", + "fieldtype": "Section Break", + "label": "Permission Rules" + }, + { + "fieldname": "permissions", + "fieldtype": "Table", + "label": "Permissions", + "oldfieldname": "permissions", + "oldfieldtype": "Table", + "options": "DocPerm" + }, + { + "fieldname": "restrict_to_domain", + "fieldtype": "Link", + "label": "Restrict To Domain", + "options": "Domain" + }, + { + "default": "0", + "fieldname": "read_only", + "fieldtype": "Check", + "label": "User Cannot Search", + "oldfieldname": "read_only", + "oldfieldtype": "Check" + }, + { + "default": "0", + "fieldname": "in_create", + "fieldtype": "Check", + "label": "User Cannot Create", + "oldfieldname": "in_create", + "oldfieldtype": "Check" + }, + { + "depends_on": "eval:doc.custom===0", + "fieldname": "web_view", + "fieldtype": "Section Break", + "label": "Web View" + }, + { + "default": "0", + "fieldname": "has_web_view", + "fieldtype": "Check", + "label": "Has Web View" + }, + { + "default": "0", + "depends_on": "has_web_view", + "fieldname": "allow_guest_to_view", + "fieldtype": "Check", + "label": "Allow Guest to View" + }, + { + "depends_on": "eval:!doc.istable", + "fieldname": "route", + "fieldtype": "Data", + "label": "Route" + }, + { + "depends_on": "has_web_view", + "fieldname": "is_published_field", + "fieldtype": "Data", + "label": "Is Published Field" + }, + { + "collapsible": 1, + "fieldname": "advanced", + "fieldtype": "Section Break", + "hidden": 1, + "label": "Advanced" + }, + { + "default": "InnoDB", + "depends_on": "eval:!doc.issingle", + "fieldname": "engine", + "fieldtype": "Select", + "label": "Database Engine", + "options": "InnoDB\nMyISAM" + }, + { + "default": "0", + "description": "Tree structures are implemented using Nested Set", + "fieldname": "is_tree", + "fieldtype": "Check", + "label": "Is Tree" + }, + { + "depends_on": "is_tree", + "fieldname": "nsm_parent_field", + "fieldtype": "Data", + "label": "Parent Field (Tree)" + }, + { + "description": "URL for documentation or help", + "fieldname": "documentation", + "fieldtype": "Data", + "label": "Documentation Link" + }, + { + "collapsible": 1, + "collapsible_depends_on": "actions", + "fieldname": "actions_section", + "fieldtype": "Section Break", + "label": "Actions" + }, + { + "fieldname": "actions", + "fieldtype": "Table", + "label": "Actions", + "options": "DocType Action" + }, + { + "collapsible": 1, + "collapsible_depends_on": "links", + "fieldname": "links_section", + "fieldtype": "Section Break", + "label": "Linked Documents" + }, + { + "fieldname": "links", + "fieldtype": "Table", + "label": "Links", + "options": "DocType Link" + }, + { + "depends_on": "email_append_to", + "fieldname": "subject_field", + "fieldtype": "Data", + "label": "Subject Field" + }, + { + "depends_on": "email_append_to", + "fieldname": "sender_field", + "fieldtype": "Data", + "label": "Sender Field", + "mandatory_depends_on": "email_append_to" + }, + { + "default": "0", + "fieldname": "email_append_to", + "fieldtype": "Check", + "label": "Allow document creation via Email" + }, + { + "collapsible": 1, + "fieldname": "email_settings_sb", + "fieldtype": "Section Break", + "label": "Email Settings" + }, + { + "default": "1", + "fieldname": "index_web_pages_for_search", + "fieldtype": "Check", + "label": "Index Web Pages for Search" + }, + { + "default": "0", + "fieldname": "is_virtual", + "fieldtype": "Check", + "label": "Is Virtual" + }, + { + "fieldname": "default_email_template", + "fieldtype": "Link", + "label": "Default Email Template", + "options": "Email Template" + }, + { + "fieldname": "column_break_51", + "fieldtype": "Column Break" + }, + { + "depends_on": "has_web_view", + "fieldname": "website_search_field", + "fieldtype": "Data", + "label": "Website Search Field" + }, + { + "fieldname": "naming_rule", + "fieldtype": "Select", + "label": "Naming Rule", + "length": 40, + "options": "\nSet by user\nBy fieldname\nBy \"Naming Series\" field\nExpression\nExpression (old style)\nRandom\nBy script" + }, + { + "fieldname": "migration_hash", + "fieldtype": "Data", + "hidden": 1 + } + ], + "icon": "fa fa-bolt", + "idx": 6, + "links": [ + { + "group": "Views", + "link_doctype": "Report", + "link_fieldname": "ref_doctype" + }, + { + "group": "Workflow", + "link_doctype": "Workflow", + "link_fieldname": "document_type" + }, + { + "group": "Workflow", + "link_doctype": "Notification", + "link_fieldname": "document_type" + }, + { + "group": "Customization", + "link_doctype": "Custom Field", + "link_fieldname": "dt" + }, + { + "group": "Customization", + "link_doctype": "Client Script", + "link_fieldname": "dt" + }, + { + "group": "Customization", + "link_doctype": "Server Script", + "link_fieldname": "reference_doctype" + }, + { + "group": "Workflow", + "link_doctype": "Webhook", + "link_fieldname": "webhook_doctype" + }, + { + "group": "Views", + "link_doctype": "Print Format", + "link_fieldname": "doc_type" + }, + { + "group": "Views", + "link_doctype": "Web Form", + "link_fieldname": "doc_type" + }, + { + "group": "Views", + "link_doctype": "Calendar View", + "link_fieldname": "reference_doctype" + }, + { + "group": "Views", + "link_doctype": "Kanban Board", + "link_fieldname": "reference_doctype" + }, + { + "group": "Workflow", + "link_doctype": "Onboarding Step", + "link_fieldname": "reference_document" + }, + { + "group": "Rules", + "link_doctype": "Auto Repeat", + "link_fieldname": "reference_doctype" + }, + { + "group": "Rules", + "link_doctype": "Assignment Rule", + "link_fieldname": "document_type" + }, + { + "group": "Rules", + "link_doctype": "Energy Point Rule", + "link_fieldname": "reference_doctype" + } + ], + "modified": "2021-10-29 11:39:13.233403", + "modified_by": "Administrator", + "module": "Core", + "name": "DocType", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Administrator", + "share": 1, + "write": 1 + } + ], + "route": "doctype", + "search_fields": "module", + "show_name_in_global_search": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 } diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index f801629329..738fb73a34 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE # imports - standard imports import re, copy, os, shutil @@ -8,7 +8,6 @@ from frappe.cache_manager import clear_user_cache, clear_controller_cache # imports - module imports import frappe -import frappe.website.render from frappe import _ from frappe.utils import now, cint from frappe.model import no_value_fields, default_fields, data_fieldtypes, table_fields, data_field_options @@ -23,6 +22,8 @@ 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 +from frappe.website.utils import clear_cache +from frappe.query_builder.functions import Concat class InvalidFieldNameError(frappe.ValidationError): pass class UniqueFieldnameError(frappe.ValidationError): pass @@ -86,10 +87,6 @@ class DocType(Document): if self.default_print_format and not self.custom: frappe.throw(_('Standard DocType cannot have default print format, use Customize Form')) - if frappe.conf.get('developer_mode'): - self.owner = 'Administrator' - self.modified_by = 'Administrator' - def validate_field_name_conflicts(self): """Check if field names dont conflict with controller properties and methods""" core_doctypes = [ @@ -176,7 +173,6 @@ class DocType(Document): if self.is_virtual and self.custom: frappe.throw(_("Not allowed to create custom Virtual DocType."), CannotCreateStandardDoctypeError) - if frappe.conf.get('developer_mode'): self.owner = 'Administrator' self.modified_by = 'Administrator' @@ -248,7 +244,7 @@ class DocType(Document): frappe.throw(_('Field "route" is mandatory for Web Views'), title='Missing Field') # clear website cache - frappe.website.render.clear_cache() + clear_cache() def change_modified_of_parent(self): """Change the timestamp of parent DocType if the current one is a child to clear caches.""" @@ -274,6 +270,8 @@ class DocType(Document): d.fieldname = d.fieldname + '_section' elif d.fieldtype=='Column Break': d.fieldname = d.fieldname + '_column' + elif d.fieldtype=='Tab Break': + d.fieldname = d.fieldname + '_tab' else: d.fieldname = d.fieldtype.lower().replace(" ","_") + "_" + str(d.idx) else: @@ -312,9 +310,7 @@ class DocType(Document): if allow_doctype_export: self.export_doc() self.make_controller_template() - - if self.has_web_view: - self.set_base_class_for_controller() + self.set_base_class_for_controller() # update index if not self.custom: @@ -352,23 +348,49 @@ class DocType(Document): now=now, doctype=self.name) def set_base_class_for_controller(self): - '''Updates the controller class to subclass from `WebsiteGenertor`, - if it is a subclass of `Document`''' - controller_path = frappe.get_module_path(frappe.scrub(self.module), - 'doctype', frappe.scrub(self.name), frappe.scrub(self.name) + '.py') + """If DocType.has_web_view has been changed, updates the controller class and import + from `WebsiteGenertor` to `Document` or viceversa""" - with open(controller_path, 'r') as f: + if not self.has_value_changed("has_web_view"): + return + + despaced_name = self.name.replace(" ", "_") + scrubbed_name = frappe.scrub(self.name) + scrubbed_module = frappe.scrub(self.module) + controller_path = frappe.get_module_path( + scrubbed_module, "doctype", scrubbed_name, f"{scrubbed_name}.py" + ) + + document_cls_tag = f"class {despaced_name}(Document)" + document_import_tag = "from frappe.model.document import Document" + website_generator_cls_tag = f"class {despaced_name}(WebsiteGenerator)" + website_generator_import_tag = "from frappe.website.generators.website_generator import WebsiteGenerator" + + with open(controller_path) as f: code = f.read() + updated_code = code - class_string = '\nclass {0}(Document)'.format(self.name.replace(' ', '')) - if '\nfrom frappe.model.document import Document' in code and class_string in code: - code = code.replace('from frappe.model.document import Document', - 'from frappe.website.website_generator import WebsiteGenerator') - code = code.replace('class {0}(Document)'.format(self.name.replace(' ', '')), - 'class {0}(WebsiteGenerator)'.format(self.name.replace(' ', ''))) + is_website_generator_class = all([ + website_generator_cls_tag in code, + website_generator_import_tag in code + ]) - with open(controller_path, 'w') as f: - f.write(code) + if self.has_web_view and not is_website_generator_class: + updated_code = updated_code.replace( + document_import_tag, website_generator_import_tag + ).replace( + document_cls_tag, website_generator_cls_tag + ) + elif not self.has_web_view and is_website_generator_class: + updated_code = updated_code.replace( + website_generator_import_tag, document_import_tag + ).replace( + website_generator_cls_tag, document_cls_tag + ) + + if updated_code != code: + with open(controller_path, "w") as f: + f.write(updated_code) def run_module_method(self, method): from frappe.modules import load_doctype_module @@ -396,10 +418,7 @@ class DocType(Document): frappe.db.sql("""update tabSingles set value=%s where doctype=%s and field='name' and value = %s""", (new, new, old)) else: - frappe.db.multisql({ - "mariadb": f"RENAME TABLE `tab{old}` TO `tab{new}`", - "postgres": f"ALTER TABLE `tab{old}` RENAME TO `tab{new}`" - }) + frappe.db.rename_table(old, new) frappe.db.commit() # Do not rename and move files and folders for custom doctype @@ -466,7 +485,7 @@ class DocType(Document): return # check if atleast 1 record exists - if not (frappe.db.table_exists(self.name) and frappe.db.sql("select name from `tab{}` limit 1".format(self.name))): + if not (frappe.db.table_exists(self.name) and frappe.get_all(self.name, fields=["name"], limit=1, as_list=True)): return existing_property_setter = frappe.db.get_value("Property Setter", {"doc_type": self.name, @@ -496,6 +515,9 @@ class DocType(Document): # retain order of 'fields' table and change order in 'field_order' docdict["field_order"] = [f.fieldname for f in self.fields] + if self.custom: + return + path = get_file_path(self.module, "DocType", self.name) if os.path.exists(path): try: @@ -550,11 +572,6 @@ class DocType(Document): from frappe.modules.export_file import export_to_files export_to_files(record_list=[['DocType', self.name]], create_init=True) - def import_doc(self): - """Import from standard folder `[module]/doctype/[name]/[name].json`.""" - from frappe.modules.import_module import import_from_files - import_from_files(record_list=[[self.module, 'doctype', self.name]]) - def make_controller_template(self): """Make boilerplate controller template.""" make_boilerplate("controller._py", self) @@ -574,17 +591,17 @@ class DocType(Document): def make_amendable(self): """If is_submittable is set, add amended_from docfields.""" if self.is_submittable: - if not frappe.db.sql("""select name from tabDocField - where fieldname = 'amended_from' and parent = %s""", self.name): - self.append("fields", { - "label": "Amended From", - "fieldtype": "Link", - "fieldname": "amended_from", - "options": self.name, - "read_only": 1, - "print_hide": 1, - "no_copy": 1 - }) + docfield_exists = frappe.get_all("DocField", filters={"fieldname": "amended_from", "parent": self.name}, pluck="name", limit=1) + if not docfield_exists: + self.append("fields", { + "label": "Amended From", + "fieldtype": "Link", + "fieldname": "amended_from", + "options": self.name, + "read_only": 1, + "print_hide": 1, + "no_copy": 1 + }) def make_repeatable(self): """If allow_auto_repeat is set, add auto_repeat custom field.""" @@ -709,12 +726,13 @@ def validate_series(dt, autoname=None, name=None): and (not autoname.startswith('format:')): prefix = autoname.split('.')[0] - used_in = frappe.db.sql(""" - SELECT `name` - FROM `tabDocType` - WHERE `autoname` LIKE CONCAT(%s, '.%%') - AND `name`!=%s - """, (prefix, name)) + doctype = frappe.qb.DocType("DocType") + used_in = (frappe.qb + .from_(doctype) + .select(doctype.name) + .where(doctype.autoname.like(Concat(prefix,".%"))) + .where(doctype.name != name) + ).run() if used_in: frappe.throw(_("Series {0} already used in {1}").format(prefix, used_in[0][0])) @@ -727,9 +745,22 @@ def validate_links_table_fieldnames(meta): 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)) + message = _("Document Links 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")) + if link.is_child_table and not meta.get_field(link.table_fieldname): + message = _("Document Links Row #{0}: Could not find field {1} in {2} DocType").format(index+1, frappe.bold(link.table_fieldname), frappe.bold(meta.name)) + frappe.throw(message, frappe.ValidationError, _("Invalid Table Fieldname")) + + if link.is_child_table: + if not link.parent_doctype: + message = _("Document Links Row #{0}: Parent DocType is mandatory for internal links").format(index+1) + frappe.throw(message, frappe.ValidationError, _("Parent Missing")) + + if not link.table_fieldname: + message = _("Document Links Row #{0}: Table Fieldname is mandatory for internal links").format(index+1) + frappe.throw(message, frappe.ValidationError, _("Table Fieldname Missing")) + def validate_fields_for_doctype(doctype): meta = frappe.get_meta(doctype, cached=False) validate_links_table_fieldnames(meta) @@ -932,6 +963,16 @@ def validate_fields(meta): if meta.is_published_field not in fieldname_list: frappe.throw(_("Is Published Field must be a valid fieldname"), InvalidFieldNameError) + def check_website_search_field(meta): + if not meta.website_search_field: + return + + if meta.website_search_field not in fieldname_list: + frappe.throw(_("Website Search Field must be a valid fieldname"), InvalidFieldNameError) + + if "title" not in fieldname_list: + frappe.throw(_('Field "title" is mandatory if "Website Search Field" is set.'), title=_("Missing Field")) + def check_timeline_field(meta): if not meta.timeline_field: return @@ -1012,6 +1053,9 @@ def validate_fields(meta): frappe.throw(_('Option {0} for field {1} is not a child table') .format(frappe.bold(doctype), frappe.bold(docfield.fieldname)), title=_("Invalid Option")) + def check_max_height(docfield): + if getattr(docfield, 'max_height', None) and (docfield.max_height[-2:] not in ('px', 'em')): + frappe.throw('Max for {} height must be in px, em, rem'.format(frappe.bold(docfield.fieldname))) fields = meta.get("fields") fieldname_list = [d.fieldname for d in fields] @@ -1045,12 +1089,14 @@ def validate_fields(meta): scrub_options_in_select(d) scrub_fetch_from(d) validate_data_field_type(d) + check_max_height(d) check_fold(fields) check_search_fields(meta, fields) check_title_field(meta) check_timeline_field(meta) check_is_published_field(meta) + check_website_search_field(meta) check_sort_field(meta) check_image_field(meta) @@ -1200,8 +1246,14 @@ def make_module_and_roles(doc, perm_fieldname="permissions"): if ("tabModule Def" in frappe.db.get_tables() and not frappe.db.exists("Module Def", doc.module)): m = frappe.get_doc({"doctype": "Module Def", "module_name": doc.module}) - m.app_name = frappe.local.module_app[frappe.scrub(doc.module)] + if frappe.scrub(doc.module) in frappe.local.module_app: + m.app_name = frappe.local.module_app[frappe.scrub(doc.module)] + else: + m.app_name = 'frappe' m.flags.ignore_mandatory = m.flags.ignore_permissions = True + if frappe.flags.package: + m.package = frappe.flags.package.name + m.custom = 1 m.insert() default_roles = ["Administrator", "Guest", "All"] diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py index 1e1a01a685..4362a52c34 100644 --- a/frappe/core/doctype/doctype/test_doctype.py +++ b/frappe/core/doctype/doctype/test_doctype.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest from frappe.core.doctype.doctype.doctype import (UniqueFieldnameError, @@ -348,6 +348,7 @@ class TestDocType(unittest.TestCase): dump_docs = json.dumps(docs.get('docs')) cancel_all_linked_docs(dump_docs) data_link_doc.cancel() + data_doc.name = '{}-CANC-0'.format(data_doc.name) data_doc.load_from_db() self.assertEqual(data_link_doc.docstatus, 2) self.assertEqual(data_doc.docstatus, 2) @@ -371,7 +372,7 @@ class TestDocType(unittest.TestCase): for data in link_doc.get('permissions'): data.submit = 1 data.cancel = 1 - link_doc.insert() + link_doc.insert(ignore_if_duplicate=True) #create first parent doctype test_doc_1 = new_doctype('Test Doctype 1') @@ -386,7 +387,7 @@ class TestDocType(unittest.TestCase): for data in test_doc_1.get('permissions'): data.submit = 1 data.cancel = 1 - test_doc_1.insert() + test_doc_1.insert(ignore_if_duplicate=True) #crete second parent doctype doc = new_doctype('Test Doctype 2') @@ -401,7 +402,7 @@ class TestDocType(unittest.TestCase): for data in link_doc.get('permissions'): data.submit = 1 data.cancel = 1 - doc.insert() + doc.insert(ignore_if_duplicate=True) # create doctype data data_link_doc_1 = frappe.new_doc('Test Linked Doctype 1') @@ -432,6 +433,7 @@ class TestDocType(unittest.TestCase): # checking that doc for Test Doctype 2 is not canceled self.assertRaises(frappe.LinkExistsError, data_link_doc_1.cancel) + data_doc_2.name = '{}-CANC-0'.format(data_doc_2.name) data_doc.load_from_db() data_doc_2.load_from_db() self.assertEqual(data_link_doc_1.docstatus, 2) diff --git a/frappe/core/doctype/doctype_action/doctype_action.py b/frappe/core/doctype/doctype_action/doctype_action.py index 203b06ec1b..807d1bf0b1 100644 --- a/frappe/core/doctype/doctype_action/doctype_action.py +++ b/frappe/core/doctype/doctype_action/doctype_action.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/doctype_link/doctype_link.json b/frappe/core/doctype/doctype_link/doctype_link.json index 0453894467..4baec6746d 100644 --- a/frappe/core/doctype/doctype_link/doctype_link.json +++ b/frappe/core/doctype/doctype_link/doctype_link.json @@ -7,8 +7,11 @@ "field_order": [ "link_doctype", "link_fieldname", + "parent_doctype", + "table_fieldname", "group", "hidden", + "is_child_table", "custom" ], "fields": [ @@ -45,12 +48,33 @@ "fieldtype": "Check", "hidden": 1, "label": "Custom" + }, + { + "depends_on": "is_child_table", + "fieldname": "parent_doctype", + "fieldtype": "Link", + "label": "Parent DocType", + "mandatory_depends_on": "is_child_table", + "options": "DocType" + }, + { + "default": "0", + "fetch_from": "link_doctype.istable", + "fieldname": "is_child_table", + "fieldtype": "Check", + "label": "Is Child Table", + "read_only": 1 + }, + { + "fieldname": "table_fieldname", + "fieldtype": "Data", + "label": "Table Fieldname" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-09-24 14:19:25.189511", + "modified": "2021-07-31 15:23:12.237491", "modified_by": "Administrator", "module": "Core", "name": "DocType Link", diff --git a/frappe/core/doctype/doctype_link/doctype_link.py b/frappe/core/doctype/doctype_link/doctype_link.py index 07e0efdace..ca2c4efa16 100644 --- a/frappe/core/doctype/doctype_link/doctype_link.py +++ b/frappe/core/doctype/doctype_link/doctype_link.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document 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 4a88e3be6e..4e6f3f3fd1 100644 --- a/frappe/core/doctype/document_naming_rule/document_naming_rule.json +++ b/frappe/core/doctype/document_naming_rule/document_naming_rule.json @@ -41,6 +41,7 @@ "fieldname": "counter", "fieldtype": "Int", "label": "Counter", + "no_copy": 1, "read_only": 1 }, { @@ -79,7 +80,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2020-11-04 14:38:14.836056", + "modified": "2021-09-13 20:07:47.617615", "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 10099bd19a..8013f9df6f 100644 --- a/frappe/core/doctype/document_naming_rule/document_naming_rule.py +++ b/frappe/core/doctype/document_naming_rule/document_naming_rule.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/document_naming_rule/test_document_naming_rule.py b/frappe/core/doctype/document_naming_rule/test_document_naming_rule.py index 2206d173d7..50f1386758 100644 --- a/frappe/core/doctype/document_naming_rule/test_document_naming_rule.py +++ b/frappe/core/doctype/document_naming_rule/test_document_naming_rule.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/core/doctype/document_naming_rule_condition/document_naming_rule_condition.py b/frappe/core/doctype/document_naming_rule_condition/document_naming_rule_condition.py index dfca052d95..4706492cea 100644 --- a/frappe/core/doctype/document_naming_rule_condition/document_naming_rule_condition.py +++ b/frappe/core/doctype/document_naming_rule_condition/document_naming_rule_condition.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/document_naming_rule_condition/test_document_naming_rule_condition.py b/frappe/core/doctype/document_naming_rule_condition/test_document_naming_rule_condition.py index 643e963bd7..3d0565234c 100644 --- a/frappe/core/doctype/document_naming_rule_condition/test_document_naming_rule_condition.py +++ b/frappe/core/doctype/document_naming_rule_condition/test_document_naming_rule_condition.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE # import frappe import unittest diff --git a/frappe/core/doctype/domain/domain.py b/frappe/core/doctype/domain/domain.py index bbd20f3b70..ebd6e3ac9e 100644 --- a/frappe/core/doctype/domain/domain.py +++ b/frappe/core/doctype/domain/domain.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe diff --git a/frappe/core/doctype/domain/test_domain.py b/frappe/core/doctype/domain/test_domain.py index c2686a7566..d7924ebc90 100644 --- a/frappe/core/doctype/domain/test_domain.py +++ b/frappe/core/doctype/domain/test_domain.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/core/doctype/domain_settings/domain_settings.py b/frappe/core/doctype/domain_settings/domain_settings.py index 7ad0aeff21..276411c2ab 100644 --- a/frappe/core/doctype/domain_settings/domain_settings.py +++ b/frappe/core/doctype/domain_settings/domain_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document @@ -34,7 +34,7 @@ class DomainSettings(Document): all_domains = list((frappe.get_hooks('domains') or {})) def remove_role(role): - frappe.db.sql('delete from `tabHas Role` where role=%s', role) + frappe.db.delete("Has Role", {"role": role}) frappe.set_value('Role', role, 'disabled', 1) for domain in all_domains: diff --git a/frappe/core/doctype/dynamic_link/dynamic_link.py b/frappe/core/doctype/dynamic_link/dynamic_link.py index a7adb9ae72..c0502824c6 100644 --- a/frappe/core/doctype/dynamic_link/dynamic_link.py +++ b/frappe/core/doctype/dynamic_link/dynamic_link.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/error_log/error_log.json b/frappe/core/doctype/error_log/error_log.json index cdc7a63001..35ca3ceeef 100644 --- a/frappe/core/doctype/error_log/error_log.json +++ b/frappe/core/doctype/error_log/error_log.json @@ -112,7 +112,7 @@ "issingle": 0, "istable": 0, "max_attachments": 0, - "modified": "2017-03-14 12:21:44.292471", + "modified": "2021-10-25 12:21:44.292471", "modified_by": "Administrator", "module": "Core", "name": "Error Log", @@ -144,6 +144,5 @@ "read_only_onload": 0, "show_name_in_global_search": 0, "sort_order": "ASC", - "track_changes": 1, "track_seen": 0 -} \ No newline at end of file +} diff --git a/frappe/core/doctype/error_log/error_log.py b/frappe/core/doctype/error_log/error_log.py index 8223238c57..39c307520f 100644 --- a/frappe/core/doctype/error_log/error_log.py +++ b/frappe/core/doctype/error_log/error_log.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document @@ -20,4 +20,4 @@ def set_old_logs_as_seen(): def clear_error_logs(): '''Flush all Error Logs''' frappe.only_for('System Manager') - frappe.db.sql('''DELETE FROM `tabError Log`''') \ No newline at end of file + frappe.db.truncate("Error Log") diff --git a/frappe/core/doctype/error_log/test_error_log.py b/frappe/core/doctype/error_log/test_error_log.py index d7444ab2a7..54a41cd4a9 100644 --- a/frappe/core/doctype/error_log/test_error_log.py +++ b/frappe/core/doctype/error_log/test_error_log.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/core/doctype/error_snapshot/error_snapshot.json b/frappe/core/doctype/error_snapshot/error_snapshot.json index ea7a86d4f6..1333fe0d5b 100644 --- a/frappe/core/doctype/error_snapshot/error_snapshot.json +++ b/frappe/core/doctype/error_snapshot/error_snapshot.json @@ -359,7 +359,7 @@ "issingle": 0, "istable": 0, "max_attachments": 0, - "modified": "2016-12-29 14:40:38.619106", + "modified": "2021-10-25 14:40:38.619106", "modified_by": "Administrator", "module": "Core", "name": "Error Snapshot", @@ -394,6 +394,5 @@ "sort_field": "timestamp", "sort_order": "DESC", "title_field": "evalue", - "track_changes": 1, "track_seen": 0 -} \ No newline at end of file +} diff --git a/frappe/core/doctype/error_snapshot/error_snapshot.py b/frappe/core/doctype/error_snapshot/error_snapshot.py index 247a796a6b..85143b5aa6 100644 --- a/frappe/core/doctype/error_snapshot/error_snapshot.py +++ b/frappe/core/doctype/error_snapshot/error_snapshot.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/error_snapshot/test_error_snapshot.py b/frappe/core/doctype/error_snapshot/test_error_snapshot.py index 135136294a..86928db9cc 100644 --- a/frappe/core/doctype/error_snapshot/test_error_snapshot.py +++ b/frappe/core/doctype/error_snapshot/test_error_snapshot.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/chat/doctype/__init__.py b/frappe/core/doctype/feedback/__init__.py similarity index 100% rename from frappe/chat/doctype/__init__.py rename to frappe/core/doctype/feedback/__init__.py diff --git a/frappe/core/doctype/feedback/feedback.js b/frappe/core/doctype/feedback/feedback.js new file mode 100644 index 0000000000..131f0e19d8 --- /dev/null +++ b/frappe/core/doctype/feedback/feedback.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Feedback', { + // refresh: function(frm) { + + // } +}); diff --git a/frappe/core/doctype/feedback/feedback.json b/frappe/core/doctype/feedback/feedback.json new file mode 100644 index 0000000000..b77e7a6677 --- /dev/null +++ b/frappe/core/doctype/feedback/feedback.json @@ -0,0 +1,87 @@ +{ + "actions": [], + "creation": "2021-06-03 19:02:55.328423", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "reference_doctype", + "reference_name", + "column_break_3", + "rating", + "ip_address", + "section_break_6", + "feedback" + ], + "fields": [ + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fieldname": "rating", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Rating", + "precision": "1", + "reqd": 1 + }, + { + "fieldname": "section_break_6", + "fieldtype": "Section Break" + }, + { + "fieldname": "feedback", + "fieldtype": "Small Text", + "label": "Feedback", + "reqd": 1 + }, + { + "fieldname": "reference_doctype", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Reference Document Type", + "options": "\nBlog Post" + }, + { + "fieldname": "reference_name", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "label": "Reference Name", + "options": "reference_doctype", + "reqd": 1 + }, + { + "fieldname": "ip_address", + "fieldtype": "Data", + "hidden": 1, + "label": "IP Address", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-06-23 12:45:42.045696", + "modified_by": "Administrator", + "module": "Core", + "name": "Feedback", + "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", + "title_field": "reference_name", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/core/doctype/feedback/feedback.py b/frappe/core/doctype/feedback/feedback.py new file mode 100644 index 0000000000..3704ee66e0 --- /dev/null +++ b/frappe/core/doctype/feedback/feedback.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies and contributors +# License: MIT. See LICENSE + +# import frappe +from frappe.model.document import Document + +class Feedback(Document): + pass diff --git a/frappe/core/doctype/feedback/test_feedback.py b/frappe/core/doctype/feedback/test_feedback.py new file mode 100644 index 0000000000..f3cf8dfe6b --- /dev/null +++ b/frappe/core/doctype/feedback/test_feedback.py @@ -0,0 +1,41 @@ +# Copyright (c) 2021, Frappe Technologies and Contributors +# License: MIT. See LICENSE + +import frappe +import unittest + +class TestFeedback(unittest.TestCase): + def tearDown(self): + frappe.form_dict.reference_doctype = None + frappe.form_dict.reference_name = None + frappe.form_dict.rating = None + frappe.form_dict.feedback = None + frappe.local.request_ip = None + + def test_feedback_creation_updation(self): + from frappe.website.doctype.blog_post.test_blog_post import make_test_blog + test_blog = make_test_blog() + + frappe.db.delete("Feedback", {"reference_doctype": "Blog Post"}) + + from frappe.templates.includes.feedback.feedback import add_feedback, update_feedback + + frappe.form_dict.reference_doctype = 'Blog Post' + frappe.form_dict.reference_name = test_blog.name + frappe.form_dict.rating = 5 + frappe.form_dict.feedback = 'New feedback' + frappe.local.request_ip = '127.0.0.1' + + feedback = add_feedback() + + self.assertEqual(feedback.feedback, 'New feedback') + self.assertEqual(feedback.rating, 5) + + updated_feedback = update_feedback('Blog Post', test_blog.name, 6, 'Updated feedback') + + self.assertEqual(updated_feedback.feedback, 'Updated feedback') + self.assertEqual(updated_feedback.rating, 6) + + frappe.db.delete("Feedback", {"reference_doctype": "Blog Post"}) + + test_blog.delete() \ No newline at end of file diff --git a/frappe/core/doctype/file/file.js b/frappe/core/doctype/file/file.js index 6d77cb91ad..d40328d3cd 100644 --- a/frappe/core/doctype/file/file.js +++ b/frappe/core/doctype/file/file.js @@ -23,6 +23,18 @@ frappe.ui.form.on("File", "refresh", function(frm) { wrapper.empty(); } + var is_raster_image = (/\.(gif|jpg|jpeg|tiff|png)$/i).test(frm.doc.file_url); + var is_optimizable = !frm.doc.is_folder && is_raster_image && frm.doc.file_size > 0; + + if (is_optimizable) { + frm.add_custom_button(__("Optimize"), function() { + frappe.show_alert(__("Optimizing image...")); + frm.call("optimize_file").then(() => { + frappe.show_alert(__("Image optimized")); + }); + }); + } + if(frm.doc.file_name && frm.doc.file_name.split('.').splice(-1)[0]==='zip') { frm.add_custom_button(__('Unzip'), function() { frappe.call({ diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index b4bfe1d21b..4df9ef3132 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE """ record of files @@ -21,14 +21,14 @@ import zipfile import requests import requests.exceptions from PIL import Image, ImageFile, ImageOps -from io import StringIO +from io import BytesIO from urllib.parse import quote, unquote import frappe -from frappe import _, conf +from frappe import _, conf, safe_decode 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 +from frappe.utils.image import strip_exif_data, optimize_image class MaxFileSizeReachedError(frappe.ValidationError): pass @@ -254,11 +254,11 @@ class File(Document): return file_name = self.file_url.split('/')[-1] try: - with open(get_files_path(file_name, is_private=self.is_private), "rb") as f: + file_path = get_files_path(file_name, is_private=self.is_private) + with open(file_path, "rb") as f: self.content_hash = get_content_hash(f.read()) except IOError: - frappe.msgprint(_("File {0} does not exist").format(self.file_url)) - raise + frappe.throw(_("File {0} does not exist").format(file_path)) def on_trash(self): if self.is_home_folder or self.is_attachments_folder: @@ -270,16 +270,12 @@ class File(Document): def make_thumbnail(self, set_as_thumbnail=True, width=300, height=300, suffix="small", crop=False): if self.file_url: - if self.file_url.startswith("/files"): - try: + try: + if self.file_url.startswith(("/files", "/private/files")): image, filename, extn = get_local_image(self.file_url) - except IOError: - return - - else: - try: + else: image, filename, extn = get_web_image(self.file_url) - except (requests.exceptions.HTTPError, requests.exceptions.SSLError, IOError, TypeError): + except (requests.exceptions.HTTPError, requests.exceptions.SSLError, IOError, TypeError): return size = width, height @@ -289,16 +285,13 @@ class File(Document): image.thumbnail(size, Image.ANTIALIAS) thumbnail_url = filename + "_" + suffix + "." + extn - path = os.path.abspath(frappe.get_site_path("public", thumbnail_url.lstrip("/"))) try: image.save(path) - if set_as_thumbnail: self.db_set("thumbnail_url", thumbnail_url) - self.db_set("thumbnail_url", thumbnail_url) except IOError: frappe.msgprint(_("Unable to write file format for {0}").format(path)) return @@ -321,17 +314,23 @@ class File(Document): self.delete_file_data_content(only_thumbnail=True) def on_rollback(self): - self.flags.on_rollback = True - self.on_trash() + # if original_content flag is set, this rollback should revert the file to its original state + if self.flags.original_content: + file_path = self.get_full_path() + with open(file_path, "wb+") as f: + f.write(self.flags.original_content) + + # following condition is only executed when an insert has been rolledback + else: + self.flags.on_rollback = True + self.on_trash() def unzip(self): '''Unzip current file and replace it by its children''' - if not ".zip" in self.file_name: - frappe.msgprint(_("Not a zip file")) - return + if not self.file_url.endswith(".zip"): + frappe.throw(_("{0} is not a zip file").format(self.file_name)) - zip_path = frappe.get_site_path(self.file_url.strip('/')) - base_url = os.path.dirname(self.file_url) + zip_path = self.get_full_path() files = [] with zipfile.ZipFile(zip_path) as z: @@ -359,10 +358,6 @@ class File(Document): return files - def get_file_url(self): - data = frappe.db.get_value("File", self.file_data_name, ["file_name", "file_url"], as_dict=True) - return data.file_url or data.file_name - def exists_on_disk(self): exists = os.path.exists(self.get_full_path()) return exists @@ -431,47 +426,6 @@ class File(Document): return get_files_path(self.file_name, is_private=self.is_private) - def get_file_doc(self): - '''returns File object (Document) from given parameters or form_dict''' - r = frappe.form_dict - - if self.file_url is None: self.file_url = r.file_url - if self.file_name is None: self.file_name = r.file_name - if self.attached_to_doctype is None: self.attached_to_doctype = r.doctype - if self.attached_to_name is None: self.attached_to_name = r.docname - if self.attached_to_field is None: self.attached_to_field = r.docfield - if self.folder is None: self.folder = r.folder - if self.is_private is None: self.is_private = r.is_private - - if r.filedata: - file_doc = self.save_uploaded() - - elif r.file_url: - file_doc = self.save() - - return file_doc - - - def save_uploaded(self): - self.content = self.get_uploaded_content() - if self.content: - return self.save() - else: - raise Exception - - def get_uploaded_content(self): - # should not be unicode when reading a file, hence using frappe.form - if 'filedata' in frappe.form_dict: - if "," in frappe.form_dict.filedata: - frappe.form_dict.filedata = frappe.form_dict.filedata.rsplit(",", 1)[1] - frappe.uploaded_content = base64.b64decode(frappe.form_dict.filedata) - return frappe.uploaded_content - elif self.content: - return self.content - frappe.msgprint(_('No file attached')) - return None - - def save_file(self, content=None, decode=False, ignore_existing_file_check=False): file_exists = False self.content = content @@ -539,14 +493,6 @@ class File(Document): 'file_url': self.file_url } - def get_file_data_from_hash(self): - for name in frappe.db.sql_list("select name from `tabFile` where content_hash=%s and is_private=%s", - (self.content_hash, self.is_private)): - b = frappe.get_doc('File', name) - return {k: b.get(k) for k in frappe.get_hooks()['write_file_keys']} - return False - - def check_max_file_size(self): max_file_size = get_max_file_size() file_size = len(self.content) @@ -594,6 +540,35 @@ class File(Document): if self.file_url: self.is_private = cint(self.file_url.startswith('/private')) + @frappe.whitelist() + def optimize_file(self): + if self.is_folder: + raise TypeError('Folders cannot be optimized') + + content_type = mimetypes.guess_type(self.file_name)[0] + is_local_image = content_type.startswith('image/') and self.file_size > 0 + is_svg = content_type == 'image/svg+xml' + + if not is_local_image: + raise NotImplementedError('Only local image files can be optimized') + + if is_svg: + raise TypeError('Optimization of SVG images is not supported') + + content = self.get_content() + file_path = self.get_full_path() + optimized_content = optimize_image(content, content_type) + + with open(file_path, 'wb+') as f: + f.write(optimized_content) + + self.file_size = len(optimized_content) + self.content_hash = get_content_hash(optimized_content) + # if rolledback, revert back to original + self.flags.original_content = content + frappe.local.rollback_observers.append(self) + self.save() + def on_doctype_update(): frappe.db.add_index("File", ["attached_to_doctype", "attached_to_name"]) @@ -621,7 +596,8 @@ def create_new_folder(file_name, folder): file.file_name = file_name file.is_folder = 1 file.folder = folder - file.insert() + file.insert(ignore_if_duplicate=True) + return file @frappe.whitelist() def move_file(file_list, new_parent, old_parent): @@ -672,7 +648,7 @@ def get_local_image(file_url): try: image = Image.open(file_path) except IOError: - frappe.msgprint(_("Unable to read file format for {0}").format(file_url), raise_exception=True) + frappe.throw(_("Unable to read file format for {0}").format(file_url)) content = None @@ -703,7 +679,10 @@ def get_web_image(file_url): frappe.msgprint(_("Unable to read file format for {0}").format(file_url)) raise - image = Image.open(StringIO(frappe.safe_decode(r.content))) + try: + image = Image.open(BytesIO(r.content)) + except Exception as e: + frappe.msgprint(_("Image link '{0}' is not valid").format(file_url), raise_exception=e) try: filename, extn = file_url.rsplit("/", 1)[1].rsplit(".", 1) @@ -737,48 +716,12 @@ def delete_file(path): os.remove(path) -def remove_file(fid=None, attached_to_doctype=None, attached_to_name=None, from_delete=False, delete_permanently=False): - """Remove file and File entry""" - file_name = None - if not (attached_to_doctype and attached_to_name): - attached = frappe.db.get_value("File", fid, - ["attached_to_doctype", "attached_to_name", "file_name"]) - if attached: - attached_to_doctype, attached_to_name, file_name = attached - - ignore_permissions, comment = False, None - if attached_to_doctype and attached_to_name and not from_delete: - doc = frappe.get_doc(attached_to_doctype, attached_to_name) - ignore_permissions = doc.has_permission("write") or False - if frappe.flags.in_web_form: - ignore_permissions = True - if not file_name: - file_name = frappe.db.get_value("File", fid, "file_name") - comment = doc.add_comment("Attachment Removed", _("Removed {0}").format(file_name)) - frappe.delete_doc("File", fid, ignore_permissions=ignore_permissions, delete_permanently=delete_permanently) - - return comment def get_max_file_size(): return cint(conf.get('max_file_size')) or 10485760 -def remove_all(dt, dn, from_delete=False, delete_permanently=False): - """remove all files in a transaction""" - try: - for fid in frappe.db.sql_list("""select name from `tabFile` where - attached_to_doctype=%s and attached_to_name=%s""", (dt, dn)): - if from_delete: - # If deleting a doc, directly delete files - frappe.delete_doc("File", fid, ignore_permissions=True, delete_permanently=delete_permanently) - else: - # Removes file and adds a comment in the document it is attached to - remove_file(fid=fid, attached_to_doctype=dt, attached_to_name=dn, - from_delete=from_delete, delete_permanently=delete_permanently) - except Exception as e: - if e.args[0]!=1054: raise # (temp till for patched) - def has_permission(doc, ptype=None, user=None): has_access = False @@ -824,6 +767,7 @@ def remove_file_by_url(file_url, doctype=None, name=None): fid = frappe.db.get_value("File", {"file_url": file_url}) if fid: + from frappe.utils.file_manager import remove_file return remove_file(fid=fid) @@ -869,22 +813,28 @@ def extract_images_from_doc(doc, fieldname): doc.set(fieldname, content) -def extract_images_from_html(doc, content): +def extract_images_from_html(doc, content, is_private=False): frappe.flags.has_dataurl = False def _save_file(match): data = match.group(1) data = data.split("data:")[1] headers, content = data.split(",") + mtype = headers.split(";")[0] + + if isinstance(content, str): + content = content.encode("utf-8") + if b"," in content: + content = content.split(b",")[1] + content = base64.b64decode(content) + + content = optimize_image(content, mtype) if "filename=" in headers: filename = headers.split("filename=")[-1] + filename = safe_decode(filename).split(";")[0] - # decode filename - if not isinstance(filename, str): - filename = str(filename, 'utf-8') else: - mtype = headers.split(";")[0] filename = get_random_filename(content_type=mtype) doctype = doc.parenttype if doc.parent else doc.doctype @@ -896,7 +846,8 @@ def extract_images_from_html(doc, content): "attached_to_doctype": doctype, "attached_to_name": name, "content": content, - "decode": True + "decode": False, + "is_private": is_private }) _file.save(ignore_permissions=True) file_url = _file.file_url @@ -911,12 +862,9 @@ def extract_images_from_html(doc, content): return content -def get_random_filename(extn=None, content_type=None): - if extn: - if not extn.startswith("."): - extn = "." + extn - - elif content_type: +def get_random_filename(content_type=None): + extn = None + if content_type: extn = mimetypes.guess_extension(content_type) return random_string(7) + (extn or "") @@ -927,7 +875,7 @@ def unzip_file(name): '''Unzip the given file and make file records for each of the extracted files''' file_obj = frappe.get_doc('File', name) files = file_obj.unzip() - return len(files) + return files @frappe.whitelist() @@ -952,13 +900,6 @@ def get_attached_images(doctype, names): return out -@frappe.whitelist() -def validate_filename(filename): - from frappe.utils import now_datetime - timestamp = now_datetime().strftime(" %Y-%m-%d %H:%M:%S") - fname = get_file_name(filename, timestamp) - return fname - @frappe.whitelist() def get_files_in_folder(folder, start=0, page_length=20): start = cint(start) diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index 649010c468..9a758b53f5 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -1,12 +1,13 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt +# License: MIT. See LICENSE import base64 +import json import frappe import os import unittest from frappe import _ -from frappe.core.doctype.file.file import move_file, get_files_in_folder +from frappe.core.doctype.file.file import get_attached_images, move_file, get_files_in_folder, unzip_file from frappe.utils import get_files_path # test_records = frappe.get_test_records('File') @@ -203,10 +204,14 @@ class TestFile(unittest.TestCase): def delete_test_data(self): - for f in frappe.db.sql('''select name, file_name from tabFile where - is_home_folder = 0 and is_attachments_folder = 0 order by creation desc'''): - frappe.delete_doc("File", f[0]) - + test_file_data = frappe.db.get_all( + "File", + pluck="name", + filters={"is_home_folder": 0, "is_attachments_folder": 0}, + order_by="creation desc", + ) + for f in test_file_data: + frappe.delete_doc("File", f) def upload_file(self): _file = frappe.get_doc({ @@ -365,6 +370,81 @@ class TestFile(unittest.TestCase): file1.file_url = '/private/files/parent_dir2.txt' file1.save() + def test_file_url_validation(self): + test_file = frappe.get_doc({ + "doctype": "File", + "file_name": 'logo', + "file_url": 'https://frappe.io/files/frappe.png' + }) + + self.assertIsNone(test_file.validate()) + + # bad path + test_file.file_url = "/usr/bin/man" + self.assertRaisesRegex(frappe.exceptions.ValidationError, "URL must start with http:// or https://", test_file.validate) + + test_file.file_url = None + test_file.file_name = "/usr/bin/man" + self.assertRaisesRegex(frappe.exceptions.ValidationError, "There is some problem with the file url", test_file.validate) + + test_file.file_url = None + test_file.file_name = "_file" + self.assertRaisesRegex(IOError, "does not exist", test_file.validate) + + test_file.file_url = None + test_file.file_name = "/private/files/_file" + self.assertRaisesRegex(IOError, "does not exist", test_file.validate) + + def test_make_thumbnail(self): + # test web image + test_file = frappe.get_doc({ + "doctype": "File", + "file_name": 'logo', + "file_url": frappe.utils.get_url('/_test/assets/image.jpg'), + }).insert(ignore_permissions=True) + + test_file.make_thumbnail() + self.assertEquals(test_file.thumbnail_url, '/files/image_small.jpg') + + # test local image + test_file.db_set('thumbnail_url', None) + test_file.reload() + test_file.file_url = "/files/image_small.jpg" + test_file.make_thumbnail(suffix="xs", crop=True) + self.assertEquals(test_file.thumbnail_url, '/files/image_small_xs.jpg') + + frappe.clear_messages() + test_file.db_set('thumbnail_url', None) + test_file.reload() + test_file.file_url = frappe.utils.get_url('unknown.jpg') + test_file.make_thumbnail(suffix="xs") + self.assertEqual(json.loads(frappe.message_log[0]), {"message": f"File '{frappe.utils.get_url('unknown.jpg')}' not found"}) + self.assertEquals(test_file.thumbnail_url, None) + + def test_file_unzip(self): + file_path = frappe.get_app_path('frappe', 'www/_test/assets/file.zip') + public_file_path = frappe.get_site_path('public', 'files') + try: + import shutil + shutil.copy(file_path, public_file_path) + except Exception: + pass + + test_file = frappe.get_doc({ + "doctype": "File", + "file_url": '/files/file.zip', + }).insert(ignore_permissions=True) + + self.assertListEqual([file.file_name for file in unzip_file(test_file.name)], + ['css_asset.css', 'image.jpg', 'js_asset.min.js']) + + test_file = frappe.get_doc({ + "doctype": "File", + "file_url": frappe.utils.get_url('/_test/assets/image.jpg'), + }).insert(ignore_permissions=True) + self.assertRaisesRegex(frappe.exceptions.ValidationError, 'not a zip file', test_file.unzip) + + class TestAttachment(unittest.TestCase): test_doctype = 'Test For Attachment' @@ -469,3 +549,93 @@ class TestAttachmentsAccess(unittest.TestCase): frappe.set_user('Administrator') frappe.db.rollback() + + +class TestFileUtils(unittest.TestCase): + def test_extract_images_from_doc(self): + # with filename in data URI + todo = frappe.get_doc({ + "doctype": "ToDo", + "description": 'Test ' + }).insert() + self.assertTrue(frappe.db.exists("File", {"attached_to_name": todo.name})) + self.assertIn('', todo.description) + self.assertListEqual(get_attached_images('ToDo', [todo.name])[todo.name], ['/files/pix.png']) + + # without filename in data URI + todo = frappe.get_doc({ + "doctype": "ToDo", + "description": 'Test ' + }).insert() + filename = frappe.db.exists("File", {"attached_to_name": todo.name}) + self.assertIn(f' Error Logs ') } - if frappe.db.sql_list("select name from `tabError Log` where seen = 0 limit 1"): + if frappe.get_all("Error Log", filters={"seen": 0}, limit=1): log_settings = frappe.get_cached_doc('Log Settings') if log_settings.users_to_notify: diff --git a/frappe/core/doctype/log_settings/test_log_settings.py b/frappe/core/doctype/log_settings/test_log_settings.py index 8e0c9c3f23..40287948fd 100644 --- a/frappe/core/doctype/log_settings/test_log_settings.py +++ b/frappe/core/doctype/log_settings/test_log_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE # import frappe import unittest diff --git a/frappe/core/doctype/module_def/__init__.py b/frappe/core/doctype/module_def/__init__.py index 0e57cb68c3..eb5ba62e5c 100644 --- a/frappe/core/doctype/module_def/__init__.py +++ b/frappe/core/doctype/module_def/__init__.py @@ -1,3 +1,3 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE diff --git a/frappe/core/doctype/module_def/module_def.js b/frappe/core/doctype/module_def/module_def.js index c7a6cf85f9..73d2d6562c 100644 --- a/frappe/core/doctype/module_def/module_def.js +++ b/frappe/core/doctype/module_def/module_def.js @@ -5,6 +5,9 @@ frappe.ui.form.on('Module Def', { refresh: function(frm) { frappe.xcall('frappe.core.doctype.module_def.module_def.get_installed_apps').then(r => { frm.set_df_property('app_name', 'options', JSON.parse(r)); + if (!frm.doc.app_name) { + frm.set_value('app_name', 'frappe'); + } }); } }); diff --git a/frappe/core/doctype/module_def/module_def.json b/frappe/core/doctype/module_def/module_def.json index 4de046bbb6..7ddc55fce5 100644 --- a/frappe/core/doctype/module_def/module_def.json +++ b/frappe/core/doctype/module_def/module_def.json @@ -8,6 +8,7 @@ "field_order": [ "module_name", "custom", + "package", "app_name", "restrict_to_domain" ], @@ -23,6 +24,7 @@ "unique": 1 }, { + "depends_on": "eval:!doc.custom", "fieldname": "app_name", "fieldtype": "Select", "in_list_view": 1, @@ -41,24 +43,84 @@ "fieldname": "custom", "fieldtype": "Check", "label": "Custom" + }, + { + "depends_on": "custom", + "fieldname": "package", + "fieldtype": "Link", + "label": "Package", + "options": "Package" } ], "icon": "fa fa-sitemap", "idx": 1, "links": [ { + "group": "DocType", "link_doctype": "DocType", "link_fieldname": "module" }, { + "group": "DocType", + "link_doctype": "Client Script", + "link_fieldname": "module" + }, + { + "group": "DocType", + "link_doctype": "Server Script", + "link_fieldname": "module" + }, + { + "group": "Website", + "link_doctype": "Web Page", + "link_fieldname": "module" + }, + { + "group": "Website", + "link_doctype": "Web Template", + "link_fieldname": "module" + }, + { + "group": "Website", + "link_doctype": "Website Theme", + "link_fieldname": "module" + }, + { + "group": "Website", + "link_doctype": "Web Form", + "link_fieldname": "module" + }, + { + "group": "Customization", "link_doctype": "Workspace", "link_fieldname": "module" + }, + { + "group": "Customization", + "link_doctype": "Custom Field", + "link_fieldname": "module" + }, + { + "group": "Customization", + "link_doctype": "Property Setter", + "link_fieldname": "module" + }, + { + "group": "Customization", + "link_doctype": "Print Format", + "link_fieldname": "module" + }, + { + "group": "Customization", + "link_doctype": "Notification", + "link_fieldname": "module" } ], - "modified": "2021-06-02 13:04:53.118716", + "modified": "2021-09-05 21:58:40.253909", "modified_by": "Administrator", "module": "Core", "name": "Module Def", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { diff --git a/frappe/core/doctype/module_def/module_def.py b/frappe/core/doctype/module_def/module_def.py index 68025c83bb..6b420430b8 100644 --- a/frappe/core/doctype/module_def/module_def.py +++ b/frappe/core/doctype/module_def/module_def.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe, os, json diff --git a/frappe/core/doctype/module_def/test_module_def.py b/frappe/core/doctype/module_def/test_module_def.py index 3a3ceb4b57..69a114d765 100644 --- a/frappe/core/doctype/module_def/test_module_def.py +++ b/frappe/core/doctype/module_def/test_module_def.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/core/doctype/module_profile/module_profile.py b/frappe/core/doctype/module_profile/module_profile.py index 373e5078d0..930c3879b6 100644 --- a/frappe/core/doctype/module_profile/module_profile.py +++ b/frappe/core/doctype/module_profile/module_profile.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE from frappe.model.document import Document diff --git a/frappe/core/doctype/module_profile/test_module_profile.py b/frappe/core/doctype/module_profile/test_module_profile.py index e0d9c13371..e676767db6 100644 --- a/frappe/core/doctype/module_profile/test_module_profile.py +++ b/frappe/core/doctype/module_profile/test_module_profile.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/core/doctype/navbar_item/navbar_item.py b/frappe/core/doctype/navbar_item/navbar_item.py index a8fa611374..d4952a75f2 100644 --- a/frappe/core/doctype/navbar_item/navbar_item.py +++ b/frappe/core/doctype/navbar_item/navbar_item.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/navbar_item/test_navbar_item.py b/frappe/core/doctype/navbar_item/test_navbar_item.py index 85852a45e8..bb4b2a837a 100644 --- a/frappe/core/doctype/navbar_item/test_navbar_item.py +++ b/frappe/core/doctype/navbar_item/test_navbar_item.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE # import frappe import unittest diff --git a/frappe/core/doctype/navbar_settings/navbar_settings.py b/frappe/core/doctype/navbar_settings/navbar_settings.py index 60aec67a00..46eb5c3e7a 100644 --- a/frappe/core/doctype/navbar_settings/navbar_settings.py +++ b/frappe/core/doctype/navbar_settings/navbar_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document @@ -22,7 +22,6 @@ 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(allow_guest=True) def get_app_logo(): app_logo = frappe.db.get_single_value('Navbar Settings', 'app_logo', cache=True) if not app_logo: diff --git a/frappe/core/doctype/navbar_settings/test_navbar_settings.py b/frappe/core/doctype/navbar_settings/test_navbar_settings.py index 4d1ee72815..01497d9035 100644 --- a/frappe/core/doctype/navbar_settings/test_navbar_settings.py +++ b/frappe/core/doctype/navbar_settings/test_navbar_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE # import frappe import unittest diff --git a/frappe/chat/doctype/chat_message/__init__.py b/frappe/core/doctype/package/__init__.py similarity index 100% rename from frappe/chat/doctype/chat_message/__init__.py rename to frappe/core/doctype/package/__init__.py diff --git a/frappe/core/doctype/package/licenses/GNU Affero General Public License.md b/frappe/core/doctype/package/licenses/GNU Affero General Public License.md new file mode 100644 index 0000000000..c7f159aed8 --- /dev/null +++ b/frappe/core/doctype/package/licenses/GNU Affero General Public License.md @@ -0,0 +1,614 @@ +### GNU AFFERO GENERAL PUBLIC LICENSE + +Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +### Preamble + +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains +free software for all its users. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + +A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + +The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + +An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing +under this license. + +The precise terms and conditions for copying, distribution and +modification follow. + +### TERMS AND CONDITIONS + +#### 0. Definitions. + +"This License" refers to version 3 of the GNU Affero General Public +License. + +"Copyright" also means copyright-like laws that apply to other kinds +of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of +an exact copy. The resulting work is called a "modified version" of +the earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based +on the Program. + +To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user +through a computer network, with no transfer of a copy, is not +conveying. + +An interactive user interface displays "Appropriate Legal Notices" to +the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +#### 1. Source Code. + +The "source code" for a work means the preferred form of the work for +making modifications to it. "Object code" means any non-source form of +a work. + +A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can +regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same +work. + +#### 2. Basic Permissions. + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, +without conditions so long as your license otherwise remains in force. +You may convey covered works to others for the sole purpose of having +them make modifications exclusively for you, or provide you with +facilities for running those works, provided that you comply with the +terms of this License in conveying all material for which you do not +control copyright. Those thus making or running the covered works for +you must do so exclusively on your behalf, under your direction and +control, on terms that prohibit them from making any copies of your +copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes +it unnecessary. + +#### 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such +circumvention is effected by exercising rights under this License with +respect to the covered work, and you disclaim any intention to limit +operation or modification of the work as a means of enforcing, against +the work's users, your or third parties' legal rights to forbid +circumvention of technological measures. + +#### 4. Conveying Verbatim Copies. + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +#### 5. Conveying Modified Source Versions. + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these +conditions: + +- a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. +- b) The work must carry prominent notices stating that it is + released under this License and any conditions added under + section 7. This requirement modifies the requirement in section 4 + to "keep intact all notices". +- c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. +- d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +#### 6. Conveying Non-Source Forms. + +You may convey a covered work in object code form under the terms of +sections 4 and 5, provided that you also convey the machine-readable +Corresponding Source under the terms of this License, in one of these +ways: + +- a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. +- b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the Corresponding + Source from a network server at no charge. +- c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. +- d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. +- e) Convey the object code using peer-to-peer transmission, + provided you inform other peers where the object code and + Corresponding Source of the work are being offered to the general + public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, +family, or household purposes, or (2) anything designed or sold for +incorporation into a dwelling. In determining whether a product is a +consumer product, doubtful cases shall be resolved in favor of +coverage. For a particular product received by a particular user, +"normally used" refers to a typical or common use of that class of +product, regardless of the status of the particular user or of the way +in which the particular user actually uses, or expects or is expected +to use, the product. A product is a consumer product regardless of +whether the product has substantial commercial, industrial or +non-consumer uses, unless such uses represent the only significant +mode of use of the product. + +"Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to +install and execute modified versions of a covered work in that User +Product from a modified version of its Corresponding Source. The +information must suffice to ensure that the continued functioning of +the modified object code is in no case prevented or interfered with +solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or +updates for a work that has been modified or installed by the +recipient, or for the User Product in which it has been modified or +installed. Access to a network may be denied when the modification +itself materially and adversely affects the operation of the network +or violates the rules and protocols for communication across the +network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +#### 7. Additional Terms. + +"Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders +of that material) supplement the terms of this License with terms: + +- a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or +- b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or +- c) Prohibiting misrepresentation of the origin of that material, + or requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or +- d) Limiting the use for publicity purposes of names of licensors + or authors of the material; or +- e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or +- f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions + of it) with contractual assumptions of liability to the recipient, + for any liability that these contractual assumptions directly + impose on those licensors and authors. + +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; the +above requirements apply either way. + +#### 8. Termination. + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your license +from a particular copyright holder is reinstated (a) provisionally, +unless and until the copyright holder explicitly and finally +terminates your license, and (b) permanently, if the copyright holder +fails to notify you of the violation by some reasonable means prior to +60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +#### 9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or run +a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +#### 10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +#### 11. Patents. + +A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned +or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is "discriminatory" if it does not include within the +scope of its coverage, prohibits the exercise of, or is conditioned on +the non-exercise of one or more of the rights that are specifically +granted under this License. You may not convey a covered work if you +are a party to an arrangement with a third party that is in the +business of distributing software, under which you make payment to the +third party based on the extent of your activity of conveying the +work, and under which the third party grants, to any of the parties +who would receive the covered work from you, a discriminatory patent +license (a) in connection with copies of the covered work conveyed by +you (or copies made from those copies), or (b) primarily for and in +connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +#### 12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under +this License and any other pertinent obligations, then as a +consequence you may not convey it at all. For example, if you agree to +terms that obligate you to collect a royalty for further conveying +from those to whom you convey the Program, the only way you could +satisfy both those terms and this License would be to refrain entirely +from conveying the Program. + +#### 13. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your +version supports such interaction) an opportunity to receive the +Corresponding Source of your version by providing access to the +Corresponding Source from a network server at no charge, through some +standard or customary means of facilitating copying of software. This +Corresponding Source shall include the Corresponding Source for any +work covered by version 3 of the GNU General Public License that is +incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + +#### 14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions +of the GNU Affero General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever +published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions +of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +#### 15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT +WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND +PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE +DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR +CORRECTION. + +#### 16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR +CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT +NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR +LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM +TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER +PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +#### 17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. diff --git a/frappe/core/doctype/package/licenses/GNU General Public License.md b/frappe/core/doctype/package/licenses/GNU General Public License.md new file mode 100644 index 0000000000..c4580f2eb6 --- /dev/null +++ b/frappe/core/doctype/package/licenses/GNU General Public License.md @@ -0,0 +1,617 @@ +### GNU GENERAL PUBLIC LICENSE + +Version 3, 29 June 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +### Preamble + +The GNU General Public License is a free, copyleft license for +software and other kinds of works. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom +to share and change all versions of a program--to make sure it remains +free software for all its users. We, the Free Software Foundation, use +the GNU General Public License for most of our software; it applies +also to any other work released this way by its authors. You can apply +it to your programs, too. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you +have certain responsibilities if you distribute copies of the +software, or if you modify it: responsibilities to respect the freedom +of others. + +For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + +Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + +For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + +Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the +manufacturer can do so. This is fundamentally incompatible with the +aim of protecting users' freedom to change the software. The +systematic pattern of such abuse occurs in the area of products for +individuals to use, which is precisely where it is most unacceptable. +Therefore, we have designed this version of the GPL to prohibit the +practice for those products. If such problems arise substantially in +other domains, we stand ready to extend this provision to those +domains in future versions of the GPL, as needed to protect the +freedom of users. + +Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish +to avoid the special danger that patents applied to a free program +could make it effectively proprietary. To prevent this, the GPL +assures that patents cannot be used to render the program non-free. + +The precise terms and conditions for copying, distribution and +modification follow. + +### TERMS AND CONDITIONS + +#### 0. Definitions. + +"This License" refers to version 3 of the GNU General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds +of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of +an exact copy. The resulting work is called a "modified version" of +the earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based +on the Program. + +To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user +through a computer network, with no transfer of a copy, is not +conveying. + +An interactive user interface displays "Appropriate Legal Notices" to +the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +#### 1. Source Code. + +The "source code" for a work means the preferred form of the work for +making modifications to it. "Object code" means any non-source form of +a work. + +A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can +regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same +work. + +#### 2. Basic Permissions. + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, +without conditions so long as your license otherwise remains in force. +You may convey covered works to others for the sole purpose of having +them make modifications exclusively for you, or provide you with +facilities for running those works, provided that you comply with the +terms of this License in conveying all material for which you do not +control copyright. Those thus making or running the covered works for +you must do so exclusively on your behalf, under your direction and +control, on terms that prohibit them from making any copies of your +copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes +it unnecessary. + +#### 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such +circumvention is effected by exercising rights under this License with +respect to the covered work, and you disclaim any intention to limit +operation or modification of the work as a means of enforcing, against +the work's users, your or third parties' legal rights to forbid +circumvention of technological measures. + +#### 4. Conveying Verbatim Copies. + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +#### 5. Conveying Modified Source Versions. + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these +conditions: + +- a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. +- b) The work must carry prominent notices stating that it is + released under this License and any conditions added under + section 7. This requirement modifies the requirement in section 4 + to "keep intact all notices". +- c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. +- d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +#### 6. Conveying Non-Source Forms. + +You may convey a covered work in object code form under the terms of +sections 4 and 5, provided that you also convey the machine-readable +Corresponding Source under the terms of this License, in one of these +ways: + +- a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. +- b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the Corresponding + Source from a network server at no charge. +- c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. +- d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. +- e) Convey the object code using peer-to-peer transmission, + provided you inform other peers where the object code and + Corresponding Source of the work are being offered to the general + public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, +family, or household purposes, or (2) anything designed or sold for +incorporation into a dwelling. In determining whether a product is a +consumer product, doubtful cases shall be resolved in favor of +coverage. For a particular product received by a particular user, +"normally used" refers to a typical or common use of that class of +product, regardless of the status of the particular user or of the way +in which the particular user actually uses, or expects or is expected +to use, the product. A product is a consumer product regardless of +whether the product has substantial commercial, industrial or +non-consumer uses, unless such uses represent the only significant +mode of use of the product. + +"Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to +install and execute modified versions of a covered work in that User +Product from a modified version of its Corresponding Source. The +information must suffice to ensure that the continued functioning of +the modified object code is in no case prevented or interfered with +solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or +updates for a work that has been modified or installed by the +recipient, or for the User Product in which it has been modified or +installed. Access to a network may be denied when the modification +itself materially and adversely affects the operation of the network +or violates the rules and protocols for communication across the +network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +#### 7. Additional Terms. + +"Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders +of that material) supplement the terms of this License with terms: + +- a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or +- b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or +- c) Prohibiting misrepresentation of the origin of that material, + or requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or +- d) Limiting the use for publicity purposes of names of licensors + or authors of the material; or +- e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or +- f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions + of it) with contractual assumptions of liability to the recipient, + for any liability that these contractual assumptions directly + impose on those licensors and authors. + +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; the +above requirements apply either way. + +#### 8. Termination. + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your license +from a particular copyright holder is reinstated (a) provisionally, +unless and until the copyright holder explicitly and finally +terminates your license, and (b) permanently, if the copyright holder +fails to notify you of the violation by some reasonable means prior to +60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +#### 9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or run +a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +#### 10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +#### 11. Patents. + +A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned +or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is "discriminatory" if it does not include within the +scope of its coverage, prohibits the exercise of, or is conditioned on +the non-exercise of one or more of the rights that are specifically +granted under this License. You may not convey a covered work if you +are a party to an arrangement with a third party that is in the +business of distributing software, under which you make payment to the +third party based on the extent of your activity of conveying the +work, and under which the third party grants, to any of the parties +who would receive the covered work from you, a discriminatory patent +license (a) in connection with copies of the covered work conveyed by +you (or copies made from those copies), or (b) primarily for and in +connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +#### 12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under +this License and any other pertinent obligations, then as a +consequence you may not convey it at all. For example, if you agree to +terms that obligate you to collect a royalty for further conveying +from those to whom you convey the Program, the only way you could +satisfy both those terms and this License would be to refrain entirely +from conveying the Program. + +#### 13. Use with the GNU Affero General Public License. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + +#### 14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions +of the GNU General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in +detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or +of any later version published by the Free Software Foundation. If the +Program does not specify a version number of the GNU General Public +License, you may choose any version ever published by the Free +Software Foundation. + +If the Program specifies that a proxy can decide which future versions +of the GNU General Public License can be used, that proxy's public +statement of acceptance of a version permanently authorizes you to +choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +#### 15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT +WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND +PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE +DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR +CORRECTION. + +#### 16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR +CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT +NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR +LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM +TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER +PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +#### 17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. diff --git a/frappe/core/doctype/package/licenses/MIT License.md b/frappe/core/doctype/package/licenses/MIT License.md new file mode 100644 index 0000000000..c038ee76ae --- /dev/null +++ b/frappe/core/doctype/package/licenses/MIT License.md @@ -0,0 +1,17 @@ +### MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of this +software and associated documentation files (the "Software"), to deal in the Software +without restriction, including without limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies +or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/frappe/core/doctype/package/package.js b/frappe/core/doctype/package/package.js new file mode 100644 index 0000000000..90e2eed1e3 --- /dev/null +++ b/frappe/core/doctype/package/package.js @@ -0,0 +1,17 @@ +// Copyright (c) 2021, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Package', { + validate: function(frm) { + if (!frm.doc.package_name) { + frm.set_value('package_name', frm.doc.name.toLowerCase().replace(' ', '-')); + } + }, + + license_type: function(frm) { + frappe.call('frappe.core.doctype.package.package.get_license_text', + {'license_type': frm.doc.license_type}).then(r => { + frm.set_value('license', r.message); + }); + } +}); diff --git a/frappe/core/doctype/package/package.json b/frappe/core/doctype/package/package.json new file mode 100644 index 0000000000..285e17a5bb --- /dev/null +++ b/frappe/core/doctype/package/package.json @@ -0,0 +1,76 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "Prompt", + "creation": "2021-09-04 11:54:35.155687", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "package_name", + "readme", + "license_type", + "license" + ], + "fields": [ + { + "fieldname": "readme", + "fieldtype": "Markdown Editor", + "label": "Readme" + }, + { + "fieldname": "package_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Package Name", + "reqd": 1 + }, + { + "fieldname": "license_type", + "fieldtype": "Select", + "label": "License Type", + "options": "\nMIT License\nGNU General Public License\nGNU Affero General Public License" + }, + { + "fieldname": "license", + "fieldtype": "Markdown Editor", + "label": "License" + } + ], + "index_web_pages_for_search": 1, + "links": [ + { + "group": "Modules", + "link_doctype": "Module Def", + "link_fieldname": "package" + }, + { + "group": "Release", + "link_doctype": "Package Release", + "link_fieldname": "package" + } + ], + "modified": "2021-09-05 13:15:01.130982", + "modified_by": "Administrator", + "module": "Core", + "name": "Package", + "naming_rule": "Set by user", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/core/doctype/package/package.py b/frappe/core/doctype/package/package.py new file mode 100644 index 0000000000..aa9735c061 --- /dev/null +++ b/frappe/core/doctype/package/package.py @@ -0,0 +1,18 @@ +# Copyright (c) 2021, Frappe Technologies and contributors +# For license information, please see license.txt + +import frappe +import os +from frappe.model.document import Document + +class Package(Document): + def validate(self): + if not self.package_name: + self.package_name = self.name.lower().replace(' ', '-') + +@frappe.whitelist() +def get_license_text(license_type): + with open(os.path.join(os.path.dirname(__file__), 'licenses', + license_type + '.md'), 'r') as textfile: + return textfile.read() + diff --git a/frappe/core/doctype/package/test_package.py b/frappe/core/doctype/package/test_package.py new file mode 100644 index 0000000000..3fb8d48274 --- /dev/null +++ b/frappe/core/doctype/package/test_package.py @@ -0,0 +1,89 @@ +# Copyright (c) 2021, Frappe Technologies and Contributors +# See license.txt + +import frappe +import os +import json +import unittest + +class TestPackage(unittest.TestCase): + def test_package_release(self): + make_test_package() + make_test_module() + make_test_doctype() + make_test_server_script() + make_test_web_page() + + # make release + frappe.get_doc(dict( + doctype = 'Package Release', + package = 'Test Package', + publish = 1 + )).insert() + + self.assertTrue(os.path.exists(frappe.get_site_path('packages', 'test-package'))) + self.assertTrue(os.path.exists(frappe.get_site_path('packages', 'test-package', 'test_module_for_package'))) + self.assertTrue(os.path.exists(frappe.get_site_path('packages', 'test-package', 'test_module_for_package', 'doctype', 'test_doctype_for_package'))) + with open(frappe.get_site_path('packages', 'test-package', 'test_module_for_package', + 'doctype', 'test_doctype_for_package', 'test_doctype_for_package.json')) as f: + doctype = json.loads(f.read()) + self.assertEqual(doctype['doctype'], 'DocType') + self.assertEqual(doctype['name'], 'Test DocType for Package') + self.assertEqual(doctype['fields'][0]['fieldname'], 'test_field') + + +def make_test_package(): + if not frappe.db.exists('Package', 'Test Package'): + frappe.get_doc(dict( + doctype = 'Package', + name = 'Test Package', + package_name = 'test-package', + readme = '# Test Package' + )).insert() + +def make_test_module(): + if not frappe.db.exists('Module Def', 'Test Module for Package'): + frappe.get_doc(dict( + doctype = 'Module Def', + module_name = 'Test Module for Package', + custom = 1, + app_name = 'frappe', + package = 'Test Package' + )).insert() + +def make_test_doctype(): + if not frappe.db.exists('DocType', 'Test DocType for Package'): + frappe.get_doc(dict( + doctype = 'DocType', + name = 'Test DocType for Package', + custom = 1, + module = 'Test Module for Package', + autoname = 'Prompt', + fields = [dict( + fieldname = 'test_field', + fieldtype = 'Data', + label = 'Test Field' + )] + )).insert() + +def make_test_server_script(): + if not frappe.db.exists('Server Script', 'Test Script for Package'): + frappe.get_doc(dict( + doctype = 'Server Script', + name = 'Test Script for Package', + module = 'Test Module for Package', + script_type = 'DocType Event', + reference_doctype = 'Test DocType for Package', + doctype_event = 'Before Save', + script = 'frappe.msgprint("Test")' + )).insert() + +def make_test_web_page(): + if not frappe.db.exists('Web Page', 'test-web-page-for-package'): + frappe.get_doc(dict( + doctype = "Web Page", + module = 'Test Module for Package', + main_section = "Some content", + published = 1, + title = "Test Web Page for Package" + )).insert() diff --git a/frappe/chat/doctype/chat_profile/__init__.py b/frappe/core/doctype/package_import/__init__.py similarity index 100% rename from frappe/chat/doctype/chat_profile/__init__.py rename to frappe/core/doctype/package_import/__init__.py diff --git a/frappe/core/doctype/package_import/package_import.js b/frappe/core/doctype/package_import/package_import.js new file mode 100644 index 0000000000..c01a6266cc --- /dev/null +++ b/frappe/core/doctype/package_import/package_import.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Package Import', { + // refresh: function(frm) { + + // } +}); diff --git a/frappe/core/doctype/package_import/package_import.json b/frappe/core/doctype/package_import/package_import.json new file mode 100644 index 0000000000..f3c6168f8d --- /dev/null +++ b/frappe/core/doctype/package_import/package_import.json @@ -0,0 +1,65 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "format:Package Import at {creation}", + "creation": "2021-09-05 16:36:46.680094", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "attach_package", + "activate", + "force", + "log" + ], + "fields": [ + { + "fieldname": "attach_package", + "fieldtype": "Attach", + "label": "Attach Package" + }, + { + "default": "0", + "fieldname": "activate", + "fieldtype": "Check", + "label": "Activate" + }, + { + "fieldname": "log", + "fieldtype": "Code", + "label": "Log", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "force", + "fieldtype": "Check", + "label": "Force" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-09-05 21:30:04.796090", + "modified_by": "Administrator", + "module": "Core", + "name": "Package Import", + "naming_rule": "Expression", + "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/package_import/package_import.py b/frappe/core/doctype/package_import/package_import.py new file mode 100644 index 0000000000..f4a2d666dd --- /dev/null +++ b/frappe/core/doctype/package_import/package_import.py @@ -0,0 +1,58 @@ +# Copyright (c) 2021, Frappe Technologies and contributors +# For license information, please see license.txt + +import frappe +import os +import json +import subprocess +from frappe.model.document import Document +from frappe.desk.form.load import get_attachments +from frappe.model.sync import get_doc_files +from frappe.modules.import_file import import_file_by_path, import_doc + +class PackageImport(Document): + def validate(self): + if self.activate: + self.import_package() + + def import_package(self): + attachment = get_attachments(self.doctype, self.name) + + if not attachment: + frappe.throw(frappe._('Please attach the package')) + + attachment = attachment[0] + + # get package_name from file (package_name-0.0.0.tar.gz) + package_name = attachment.file_name.split('.')[0].rsplit('-', 1)[0] + if not os.path.exists(frappe.get_site_path('packages')): + os.makedirs(frappe.get_site_path('packages')) + + # extract + subprocess.check_output(['tar', 'xzf', + frappe.get_site_path(attachment.file_url.strip('/')), '-C', + frappe.get_site_path('packages')]) + + package_path = frappe.get_site_path('packages', package_name) + + # import Package + with open(os.path.join(package_path, package_name + '.json'), 'r') as packagefile: + doc_dict = json.loads(packagefile.read()) + + frappe.flags.package = import_doc(doc_dict) + + # collect modules + files = [] + log = [] + for module in os.listdir(package_path): + module_path = os.path.join(package_path, module) + if os.path.isdir(module_path): + get_doc_files(files, module_path) + + # import files + for file in files: + import_file_by_path(file, force=self.force, ignore_version=True, + for_sync=True) + log.append('Imported {}'.format(file)) + + self.log = '\n'.join(log) diff --git a/frappe/core/doctype/package_import/test_package_import.py b/frappe/core/doctype/package_import/test_package_import.py new file mode 100644 index 0000000000..04628fed93 --- /dev/null +++ b/frappe/core/doctype/package_import/test_package_import.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies and Contributors +# See license.txt + +# import frappe +import unittest + +class TestPackageImport(unittest.TestCase): + pass diff --git a/frappe/chat/doctype/chat_room/__init__.py b/frappe/core/doctype/package_release/__init__.py similarity index 100% rename from frappe/chat/doctype/chat_room/__init__.py rename to frappe/core/doctype/package_release/__init__.py diff --git a/frappe/core/doctype/package_release/package_release.js b/frappe/core/doctype/package_release/package_release.js new file mode 100644 index 0000000000..9eabe36839 --- /dev/null +++ b/frappe/core/doctype/package_release/package_release.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Package Release', { + // refresh: function(frm) { + + // } +}); diff --git a/frappe/core/doctype/package_release/package_release.json b/frappe/core/doctype/package_release/package_release.json new file mode 100644 index 0000000000..b651d699c4 --- /dev/null +++ b/frappe/core/doctype/package_release/package_release.json @@ -0,0 +1,95 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2021-09-05 12:59:01.932327", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "package", + "publish", + "path", + "column_break_3", + "major", + "minor", + "patch", + "section_break_7", + "release_notes" + ], + "fields": [ + { + "fieldname": "package", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Package", + "options": "Package", + "reqd": 1 + }, + { + "fieldname": "major", + "fieldtype": "Int", + "label": "Major" + }, + { + "fieldname": "minor", + "fieldtype": "Int", + "label": "Minor" + }, + { + "fieldname": "patch", + "fieldtype": "Int", + "label": "Patch", + "no_copy": 1 + }, + { + "fieldname": "path", + "fieldtype": "Small Text", + "label": "Path", + "read_only": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_7", + "fieldtype": "Section Break" + }, + { + "fieldname": "release_notes", + "fieldtype": "Markdown Editor", + "label": "Release Notes" + }, + { + "default": "0", + "fieldname": "publish", + "fieldtype": "Check", + "label": "Publish" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-09-05 16:04:32.860988", + "modified_by": "Administrator", + "module": "Core", + "name": "Package Release", + "naming_rule": "By script", + "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/package_release/package_release.py b/frappe/core/doctype/package_release/package_release.py new file mode 100644 index 0000000000..d23ae917c4 --- /dev/null +++ b/frappe/core/doctype/package_release/package_release.py @@ -0,0 +1,92 @@ +# Copyright (c) 2021, Frappe Technologies and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document +from frappe.modules.export_file import export_doc +import os +import subprocess +from frappe.query_builder.functions import Max + + +class PackageRelease(Document): + def set_version(self): + # set the next patch release by default + doctype = frappe.qb.DocType("Package Release") + if not self.major: + self.major = frappe.qb.from_(doctype) \ + .where(doctype.package == self.package) \ + .select(Max(doctype.minor)).run()[0][0] or 0 + + if not self.minor: + self.minor = frappe.qb.from_(doctype) \ + .where(doctype.package == self.package) \ + .select(Max("minor")).run()[0][0] or 0 + if not self.patch: + value = frappe.qb.from_(doctype) \ + .where(doctype.package == self.package) \ + .select(Max("patch")).run()[0][0] or 0 + self.patch = value + 1 + + def autoname(self): + self.set_version() + self.name = '{}-{}.{}.{}'.format( + frappe.db.get_value('Package', self.package, 'package_name'), + self.major, self.minor, self.patch) + + def validate(self): + if self.publish: + self.export_files() + + def export_files(self): + '''Export all the documents in this package to site/packages folder''' + package = frappe.get_doc('Package', self.package) + + self.export_modules() + self.export_package_files(package) + self.make_tarfile(package) + + def export_modules(self): + for m in frappe.db.get_all('Module Def', dict(package=self.package)): + module = frappe.get_doc('Module Def', m.name) + for l in module.meta.links: + if l.link_doctype == 'Module Def': + continue + # all documents of the type in the module + for d in frappe.get_all(l.link_doctype, dict(module=m.name)): + export_doc(frappe.get_doc(l.link_doctype, d.name)) + + def export_package_files(self, package): + # write readme + with open(frappe.get_site_path('packages', package.package_name, 'README.md'), 'w') as readme: + readme.write(package.readme) + + # write license + if package.license: + with open(frappe.get_site_path('packages', package.package_name, 'LICENSE.md'), 'w') as license: + license.write(package.license) + + # write package.json as `frappe_package.json` + with open(frappe.get_site_path('packages', package.package_name, package.package_name + '.json'), 'w') as packagefile: + packagefile.write(frappe.as_json(package.as_dict(no_nulls=True))) + + def make_tarfile(self, package): + # make tarfile + filename = '{}.tar.gz'.format(self.name) + subprocess.check_output(['tar', 'czf', filename, package.package_name], + cwd=frappe.get_site_path('packages')) + + # move file + subprocess.check_output(['mv', frappe.get_site_path('packages', filename), + frappe.get_site_path('public', 'files')]) + + # make attachment + file = frappe.get_doc(dict( + doctype = 'File', + file_url = '/' + os.path.join('files', filename), + attached_to_doctype = self.doctype, + attached_to_name = self.name + )) + + file.flags.ignore_duplicate_entry_error = True + file.insert() diff --git a/frappe/core/doctype/package_release/test_package_release.py b/frappe/core/doctype/package_release/test_package_release.py new file mode 100644 index 0000000000..6a15e8625b --- /dev/null +++ b/frappe/core/doctype/package_release/test_package_release.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies and Contributors +# See license.txt + +# import frappe +import unittest + +class TestPackageRelease(unittest.TestCase): + pass diff --git a/frappe/core/doctype/page/__init__.py b/frappe/core/doctype/page/__init__.py index 0e57cb68c3..eb5ba62e5c 100644 --- a/frappe/core/doctype/page/__init__.py +++ b/frappe/core/doctype/page/__init__.py @@ -1,3 +1,3 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE diff --git a/frappe/core/doctype/page/page.py b/frappe/core/doctype/page/page.py index 0ba0e309dd..894e180bb1 100644 --- a/frappe/core/doctype/page/page.py +++ b/frappe/core/doctype/page/page.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe import os @@ -109,6 +109,7 @@ class Page(Document): if os.path.exists(fpath): with open(fpath, 'r') as f: self.script = render_include(f.read()) + self.script += f"\n\n//# sourceURL={page_name}.js" # css fpath = os.path.join(path, page_name + '.css') diff --git a/frappe/core/doctype/page/test_page.py b/frappe/core/doctype/page/test_page.py index 18b4aea2c8..7db32497a8 100644 --- a/frappe/core/doctype/page/test_page.py +++ b/frappe/core/doctype/page/test_page.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/core/doctype/patch_log/__init__.py b/frappe/core/doctype/patch_log/__init__.py index 0e57cb68c3..eb5ba62e5c 100644 --- a/frappe/core/doctype/patch_log/__init__.py +++ b/frappe/core/doctype/patch_log/__init__.py @@ -1,3 +1,3 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE diff --git a/frappe/core/doctype/patch_log/patch_log.py b/frappe/core/doctype/patch_log/patch_log.py index cc66955eb8..9a5da24e37 100644 --- a/frappe/core/doctype/patch_log/patch_log.py +++ b/frappe/core/doctype/patch_log/patch_log.py @@ -1,7 +1,7 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe diff --git a/frappe/core/doctype/patch_log/test_patch_log.py b/frappe/core/doctype/patch_log/test_patch_log.py index d0690ecee0..df1ca16b22 100644 --- a/frappe/core/doctype/patch_log/test_patch_log.py +++ b/frappe/core/doctype/patch_log/test_patch_log.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/core/doctype/payment_gateway/payment_gateway.py b/frappe/core/doctype/payment_gateway/payment_gateway.py index 1459635b01..d0fa550ea1 100644 --- a/frappe/core/doctype/payment_gateway/payment_gateway.py +++ b/frappe/core/doctype/payment_gateway/payment_gateway.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/payment_gateway/test_payment_gateway.py b/frappe/core/doctype/payment_gateway/test_payment_gateway.py index 66f899bd27..e2ad081cfa 100644 --- a/frappe/core/doctype/payment_gateway/test_payment_gateway.py +++ b/frappe/core/doctype/payment_gateway/test_payment_gateway.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/core/doctype/prepared_report/prepared_report.py b/frappe/core/doctype/prepared_report/prepared_report.py index c68bb6a4f1..2d1b026572 100644 --- a/frappe/core/doctype/prepared_report/prepared_report.py +++ b/frappe/core/doctype/prepared_report/prepared_report.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2018, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import json @@ -11,8 +11,6 @@ from frappe.desk.query_report import generate_report_result from frappe.model.document import Document from frappe.utils import gzip_compress, gzip_decompress from frappe.utils.background_jobs import enqueue -from frappe.core.doctype.file.file import remove_all - class PreparedReport(Document): def before_insert(self): diff --git a/frappe/core/doctype/prepared_report/test_prepared_report.py b/frappe/core/doctype/prepared_report/test_prepared_report.py index ef324dd01a..5b12990f64 100644 --- a/frappe/core/doctype/prepared_report/test_prepared_report.py +++ b/frappe/core/doctype/prepared_report/test_prepared_report.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2018, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest import json diff --git a/frappe/core/doctype/report/__init__.py b/frappe/core/doctype/report/__init__.py index 0e57cb68c3..eb5ba62e5c 100644 --- a/frappe/core/doctype/report/__init__.py +++ b/frappe/core/doctype/report/__init__.py @@ -1,3 +1,3 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE diff --git a/frappe/core/doctype/report/boilerplate/controller.py b/frappe/core/doctype/report/boilerplate/controller.py index b8e9cb7467..ccf732a405 100644 --- a/frappe/core/doctype/report/boilerplate/controller.py +++ b/frappe/core/doctype/report/boilerplate/controller.py @@ -1,5 +1,5 @@ # Copyright (c) 2013, {app_publisher} and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe diff --git a/frappe/core/doctype/report/report.py b/frappe/core/doctype/report/report.py index a5c61fa436..be0346d869 100644 --- a/frappe/core/doctype/report/report.py +++ b/frappe/core/doctype/report/report.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe import json, datetime from frappe import _, scrub @@ -105,7 +105,7 @@ class Report(Document): if not self.query.lower().startswith("select"): frappe.throw(_("Query must be a SELECT"), title=_('Report Document Error')) - result = [list(t) for t in frappe.db.sql(self.query, filters, debug=True)] + result = [list(t) for t in frappe.db.sql(self.query, filters)] columns = self.get_columns() or [cstr(c[0]) for c in frappe.db.get_description()] return [columns, result] diff --git a/frappe/core/doctype/report/test_report.py b/frappe/core/doctype/report/test_report.py index 9d0c0b9af0..36e3b09254 100644 --- a/frappe/core/doctype/report/test_report.py +++ b/frappe/core/doctype/report/test_report.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe, json, os import unittest @@ -82,9 +82,11 @@ class TestReport(unittest.TestCase): def test_report_permissions(self): frappe.set_user('test@example.com') - frappe.db.sql("""delete from `tabHas Role` where parent = %s - and role = 'Test Has Role'""", frappe.session.user, auto_commit=1) - + frappe.db.delete("Has Role", { + "parent": frappe.session.user, + "role": "Test Has Role" + }) + frappe.db.commit() if not frappe.db.exists('Role', 'Test Has Role'): role = frappe.get_doc({ 'doctype': 'Role', diff --git a/frappe/core/doctype/report_column/report_column.py b/frappe/core/doctype/report_column/report_column.py index f9078d820d..3b2c1e130b 100644 --- a/frappe/core/doctype/report_column/report_column.py +++ b/frappe/core/doctype/report_column/report_column.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/report_filter/report_filter.py b/frappe/core/doctype/report_filter/report_filter.py index ccdcc0eb6f..b325985308 100644 --- a/frappe/core/doctype/report_filter/report_filter.py +++ b/frappe/core/doctype/report_filter/report_filter.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/role/__init__.py b/frappe/core/doctype/role/__init__.py index 0e57cb68c3..eb5ba62e5c 100644 --- a/frappe/core/doctype/role/__init__.py +++ b/frappe/core/doctype/role/__init__.py @@ -1,3 +1,3 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE 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 index 375ea02e0e..dc17526047 100644 --- a/frappe/core/doctype/role/patches/v13_set_default_desk_properties.py +++ b/frappe/core/doctype/role/patches/v13_set_default_desk_properties.py @@ -2,9 +2,10 @@ import frappe from ..role import desk_properties def execute(): + frappe.reload_doctype('user') 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 + role_doc.save() diff --git a/frappe/core/doctype/role/role.json b/frappe/core/doctype/role/role.json index 0135cbf9e8..ba82e023a9 100644 --- a/frappe/core/doctype/role/role.json +++ b/frappe/core/doctype/role/role.json @@ -17,7 +17,6 @@ "navigation_settings_section", "search_bar", "notifications", - "chat", "list_settings_section", "list_sidebar", "bulk_actions", @@ -85,12 +84,6 @@ "fieldtype": "Check", "label": "Search Bar" }, - { - "default": "1", - "fieldname": "chat", - "fieldtype": "Check", - "label": "Chat" - }, { "fieldname": "list_settings_section", "fieldtype": "Section Break", @@ -155,10 +148,11 @@ "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2021-01-27 10:35:37.638350", + "modified": "2021-10-08 14:06:55.729364", "modified_by": "Administrator", "module": "Core", "name": "Role", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { diff --git a/frappe/core/doctype/role/role.py b/frappe/core/doctype/role/role.py index 02482c75ca..98d2d72fc2 100644 --- a/frappe/core/doctype/role/role.py +++ b/frappe/core/doctype/role/role.py @@ -1,11 +1,11 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document -desk_properties = ("search_bar", "notifications", "chat", "list_sidebar", +desk_properties = ("search_bar", "notifications", "list_sidebar", "bulk_actions", "view_switcher", "form_sidebar", "timeline", "dashboard") class Role(Document): @@ -38,7 +38,7 @@ class Role(Document): self.set(key, 0) def remove_roles(self): - frappe.db.sql("delete from `tabHas Role` where role = %s", self.name) + frappe.db.delete("Has Role", {"role": self.name}) frappe.clear_cache() def on_update(self): @@ -82,4 +82,4 @@ def role_query(doctype, txt, searchfield, start, page_len, filters): report_filters.extend(filters) return frappe.get_all('Role', limit_start=start, limit_page_length=page_len, - filters=report_filters, as_list=1) \ No newline at end of file + filters=report_filters, as_list=1) diff --git a/frappe/core/doctype/role/test_role.py b/frappe/core/doctype/role/test_role.py index 471f6cac43..1671f9a9c8 100644 --- a/frappe/core/doctype/role/test_role.py +++ b/frappe/core/doctype/role/test_role.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/core/doctype/role_permission_for_page_and_report/role_permission_for_page_and_report.py b/frappe/core/doctype/role_permission_for_page_and_report/role_permission_for_page_and_report.py index 59f34a1483..cd9a6dc0fa 100644 --- a/frappe/core/doctype/role_permission_for_page_and_report/role_permission_for_page_and_report.py +++ b/frappe/core/doctype/role_permission_for_page_and_report/role_permission_for_page_and_report.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.core.doctype.report.report import is_prepared_report_disabled diff --git a/frappe/core/doctype/role_profile/role_profile.py b/frappe/core/doctype/role_profile/role_profile.py index 0f58da5b5e..cb0a43d68f 100644 --- a/frappe/core/doctype/role_profile/role_profile.py +++ b/frappe/core/doctype/role_profile/role_profile.py @@ -1,8 +1,9 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE from frappe.model.document import Document +import frappe class RoleProfile(Document): def autoname(self): @@ -11,5 +12,9 @@ class RoleProfile(Document): def on_update(self): """ Changes in role_profile reflected across all its user """ - from frappe.core.doctype.user.user import update_roles - update_roles(self.name) + users = frappe.get_all('User', filters={'role_profile_name': self.name}) + roles = [role.role for role in self.roles] + for d in users: + user = frappe.get_doc('User', d) + user.set('roles', []) + user.add_roles(*roles) diff --git a/frappe/core/doctype/role_profile/test_role_profile.py b/frappe/core/doctype/role_profile/test_role_profile.py index 53e0a1b043..b208a186de 100644 --- a/frappe/core/doctype/role_profile/test_role_profile.py +++ b/frappe/core/doctype/role_profile/test_role_profile.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest @@ -8,6 +8,7 @@ test_dependencies = ['Role'] class TestRoleProfile(unittest.TestCase): def test_make_new_role_profile(self): + frappe.delete_doc_if_exists('Role Profile', 'Test 1', force=1) new_role_profile = frappe.get_doc(dict(doctype='Role Profile', role_profile='Test 1')).insert() self.assertEqual(new_role_profile.role_profile, 'Test 1') @@ -19,7 +20,25 @@ class TestRoleProfile(unittest.TestCase): new_role_profile.save() self.assertEqual(new_role_profile.roles[0].role, '_Test Role 2') + # user with a role profile + random_user = frappe.mock("email") + random_user_name = frappe.mock("name") + + random_user = frappe.get_doc({ + "doctype": "User", + "email": random_user, + "enabled": 1, + "first_name": random_user_name, + "new_password": "Eastern_43A1W", + "role_profile_name": 'Test 1' + }).insert(ignore_permissions=True, ignore_if_duplicate=True) + self.assertListEqual([role.role for role in random_user.roles], [role.role for role in new_role_profile.roles]) + # clear roles new_role_profile.roles = [] new_role_profile.save() self.assertEqual(new_role_profile.roles, []) + + # user roles with the role profile should also be updated + random_user.reload() + self.assertListEqual(random_user.roles, []) \ No newline at end of file diff --git a/frappe/core/doctype/scheduled_job_log/scheduled_job_log.json b/frappe/core/doctype/scheduled_job_log/scheduled_job_log.json index f86a4c8884..396b32bdf9 100644 --- a/frappe/core/doctype/scheduled_job_log/scheduled_job_log.json +++ b/frappe/core/doctype/scheduled_job_log/scheduled_job_log.json @@ -38,7 +38,7 @@ } ], "links": [], - "modified": "2020-01-22 00:00:00.000000", + "modified": "2021-10-25 00:00:00.000000", "modified_by": "Administrator", "module": "Core", "name": "Scheduled Job Log", @@ -59,6 +59,5 @@ ], "quick_entry": 1, "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1 + "sort_order": "DESC" } diff --git a/frappe/core/doctype/scheduled_job_log/scheduled_job_log.py b/frappe/core/doctype/scheduled_job_log/scheduled_job_log.py index 7f54a3b6ae..bd5c15bc31 100644 --- a/frappe/core/doctype/scheduled_job_log/scheduled_job_log.py +++ b/frappe/core/doctype/scheduled_job_log/scheduled_job_log.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/scheduled_job_log/test_scheduled_job_log.py b/frappe/core/doctype/scheduled_job_log/test_scheduled_job_log.py index 85471d0d71..9957f6c34c 100644 --- a/frappe/core/doctype/scheduled_job_log/test_scheduled_job_log.py +++ b/frappe/core/doctype/scheduled_job_log/test_scheduled_job_log.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE # import frappe import unittest 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 59089d12ad..1a795bab82 100644 --- a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py +++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# Copyright (c) 2021, Frappe Technologies and contributors +# License: MIT. See LICENSE import json from datetime import datetime @@ -110,7 +109,7 @@ class ScheduledJobType(Document): return 'long' if ('Long' in self.frequency) else 'default' def on_trash(self): - frappe.db.sql('delete from `tabScheduled Job Log` where scheduled_job_type=%s', self.name) + frappe.db.delete("Scheduled Job Log", {"scheduled_job_type": self.name}) @frappe.whitelist() 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 a071cfe9a9..dc3353b176 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 @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest from frappe.utils import get_datetime diff --git a/frappe/core/doctype/server_script/server_script.json b/frappe/core/doctype/server_script/server_script.json index b7e49673f8..520c0008c5 100644 --- a/frappe/core/doctype/server_script/server_script.json +++ b/frappe/core/doctype/server_script/server_script.json @@ -13,6 +13,7 @@ "api_method", "allow_guest", "column_break_3", + "module", "disabled", "section_break_8", "script", @@ -93,6 +94,12 @@ "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" + }, + { + "fieldname": "module", + "fieldtype": "Link", + "label": "Module (for export)", + "options": "Module Def" } ], "index_web_pages_for_search": 1, @@ -102,7 +109,7 @@ "link_fieldname": "server_script" } ], - "modified": "2021-02-18 12:36:19.803425", + "modified": "2021-09-04 12:02:43.671240", "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 d26fe5a188..5b1aab1241 100644 --- a/frappe/core/doctype/server_script/server_script.py +++ b/frappe/core/doctype/server_script/server_script.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import ast from types import FunctionType, MethodType, ModuleType @@ -15,7 +15,6 @@ from frappe import _ class ServerScript(Document): def validate(self): frappe.only_for("Script Manager", True) - self.validate_script() self.sync_scheduled_jobs() self.clear_scheduled_events() @@ -28,6 +27,11 @@ class ServerScript(Document): for job in self.scheduled_jobs: frappe.delete_doc("Scheduled Job Type", job.name) + def get_code_fields(self): + return { + 'script': 'py' + } + @property def scheduled_jobs(self) -> List[Dict[str, str]]: return frappe.get_all( @@ -36,10 +40,6 @@ class ServerScript(Document): 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 @@ -94,7 +94,7 @@ class ServerScript(Document): Args: doc (Document): Executes script with for a certain document's events """ - safe_exec(self.script, _locals={"doc": doc}) + safe_exec(self.script, _locals={"doc": doc}, restrict_commit_rollback=True) def execute_scheduled_method(self): """Specific to Scheduled Jobs via Server Scripts diff --git a/frappe/core/doctype/server_script/test_server_script.py b/frappe/core/doctype/server_script/test_server_script.py index c39fcfa0d0..3c091fec0b 100644 --- a/frappe/core/doctype/server_script/test_server_script.py +++ b/frappe/core/doctype/server_script/test_server_script.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest import requests @@ -59,6 +59,16 @@ conditions = '1 = 1' reference_doctype = 'Note', script = ''' frappe.method_that_doesnt_exist("do some magic") +''' + ), + dict( + name='test_todo_commit', + script_type = 'DocType Event', + doctype_event = 'Before Save', + reference_doctype = 'ToDo', + disabled = 1, + script = ''' +frappe.db.commit() ''' ) ] @@ -102,10 +112,30 @@ class TestServerScript(unittest.TestCase): self.assertEqual(frappe.get_doc('Server Script', 'test_return_value').execute_method(), 'hello') def test_permission_query(self): - self.assertTrue('where (1 = 1)' in frappe.db.get_list('ToDo', return_query=1)) + self.assertTrue('where (1 = 1)' in frappe.db.get_list('ToDo', run=False)) self.assertTrue(isinstance(frappe.db.get_list('ToDo'), list)) def test_attribute_error(self): """Raise AttributeError if method not found in Namespace""" note = frappe.get_doc({"doctype": "Note", "title": "Test Note: Server Script"}) self.assertRaises(AttributeError, note.insert) + + def test_syntax_validation(self): + server_script = scripts[0] + server_script["script"] = "js || code.?" + + with self.assertRaises(frappe.ValidationError) as se: + frappe.get_doc(doctype="Server Script", **server_script).insert() + + self.assertTrue("invalid python code" in str(se.exception).lower(), + msg="Python code validation not working") + + def test_commit_in_doctype_event(self): + server_script = frappe.get_doc('Server Script', 'test_todo_commit') + server_script.disabled = 0 + server_script.save() + + self.assertRaises(AttributeError, frappe.get_doc(dict(doctype='ToDo', description='test me')).insert) + + server_script.disabled = 1 + server_script.save() diff --git a/frappe/core/doctype/session_default/session_default.py b/frappe/core/doctype/session_default/session_default.py index 70ff103111..9470a1bb38 100644 --- a/frappe/core/doctype/session_default/session_default.py +++ b/frappe/core/doctype/session_default/session_default.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/session_default_settings/session_default_settings.py b/frappe/core/doctype/session_default_settings/session_default_settings.py index 25f7522c86..52c917223e 100644 --- a/frappe/core/doctype/session_default_settings/session_default_settings.py +++ b/frappe/core/doctype/session_default_settings/session_default_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe import _ diff --git a/frappe/core/doctype/session_default_settings/test_session_default_settings.py b/frappe/core/doctype/session_default_settings/test_session_default_settings.py index 7d20015b66..7a7e971aed 100644 --- a/frappe/core/doctype/session_default_settings/test_session_default_settings.py +++ b/frappe/core/doctype/session_default_settings/test_session_default_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest from frappe.core.doctype.session_default_settings.session_default_settings import set_session_default_values, clear_session_defaults diff --git a/frappe/core/doctype/sms_parameter/sms_parameter.py b/frappe/core/doctype/sms_parameter/sms_parameter.py index d1fb1c53db..fb8466eac6 100644 --- a/frappe/core/doctype/sms_parameter/sms_parameter.py +++ b/frappe/core/doctype/sms_parameter/sms_parameter.py @@ -1,5 +1,5 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# License: MIT. See LICENSE import frappe diff --git a/frappe/core/doctype/sms_settings/sms_settings.json b/frappe/core/doctype/sms_settings/sms_settings.json index 073fb88bc7..d29949af45 100755 --- a/frappe/core/doctype/sms_settings/sms_settings.json +++ b/frappe/core/doctype/sms_settings/sms_settings.json @@ -1,238 +1,80 @@ { - "allow_copy": 1, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2013-01-10 16:34:24", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "editable_grid": 0, + "actions": [], + "allow_copy": 1, + "creation": "2013-01-10 16:34:24", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "sms_gateway_url", + "message_parameter", + "receiver_parameter", + "static_parameters_section", + "parameters", + "use_post" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "description": "Eg. smsgateway.com/api/send_sms.cgi", - "fieldname": "sms_gateway_url", - "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": "SMS Gateway URL", - "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": 0, - "set_only_once": 0, - "unique": 0 - }, + "description": "Eg. smsgateway.com/api/send_sms.cgi", + "fieldname": "sms_gateway_url", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "SMS Gateway URL", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "description": "Enter url parameter for message", - "fieldname": "message_parameter", - "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": "Message Parameter", - "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": 0, - "set_only_once": 0, - "unique": 0 - }, + "description": "Enter url parameter for message", + "fieldname": "message_parameter", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Message Parameter", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "description": "Enter url parameter for receiver nos", - "fieldname": "receiver_parameter", - "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": "Receiver Parameter", - "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": 0, - "set_only_once": 0, - "unique": 0 - }, + "description": "Enter url parameter for receiver nos", + "fieldname": "receiver_parameter", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Receiver Parameter", + "reqd": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "static_parameters_section", - "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, - "unique": 0, + "fieldname": "static_parameters_section", + "fieldtype": "Column Break", "width": "50%" - }, + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "description": "Enter static url parameters here (Eg. sender=ERPNext, username=ERPNext, password=1234 etc.)", - "fieldname": "parameters", - "fieldtype": "Table", - "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": "Static Parameters", - "length": 0, - "no_copy": 0, - "options": "SMS Parameter", - "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": "Enter static url parameters here (Eg. sender=ERPNext, username=ERPNext, password=1234 etc.)", + "fieldname": "parameters", + "fieldtype": "Table", + "label": "Static Parameters", + "options": "SMS Parameter" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "use_post", - "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": "Use POST", - "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, - "unique": 0 + "default": "0", + "fieldname": "use_post", + "fieldtype": "Check", + "label": "Use POST" } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "icon": "fa fa-cog", - "idx": 1, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 1, - "istable": 0, - "max_attachments": 0, - "modified": "2021-03-02 18:06:00.868688", - "modified_by": "Administrator", - "module": "Core", - "name": "SMS Settings", - "owner": "Administrator", + ], + "icon": "fa fa-cog", + "idx": 1, + "issingle": 1, + "links": [], + "modified": "2021-09-21 19:45:26.809793", + "modified_by": "Administrator", + "module": "Core", + "name": "SMS Settings", + "owner": "Administrator", "permissions": [ { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 1, - "delete": 0, - "email": 0, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 0, - "read": 1, - "report": 0, - "role": "System Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "create": 1, + "read": 1, + "role": "System Manager", + "share": 1, "write": 1 } - ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "track_changes": 1, - "track_seen": 0 -} + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/core/doctype/sms_settings/sms_settings.py b/frappe/core/doctype/sms_settings/sms_settings.py index 58a0ff08f6..f15ba7e4f6 100644 --- a/frappe/core/doctype/sms_settings/sms_settings.py +++ b/frappe/core/doctype/sms_settings/sms_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# License: MIT. See LICENSE import frappe diff --git a/frappe/core/doctype/sms_settings/test_sms_settings.py b/frappe/core/doctype/sms_settings/test_sms_settings.py index 862f5e3965..b3be912f9e 100644 --- a/frappe/core/doctype/sms_settings/test_sms_settings.py +++ b/frappe/core/doctype/sms_settings/test_sms_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/core/doctype/success_action/success_action.py b/frappe/core/doctype/success_action/success_action.py index 4ebd3d250b..afb3a87485 100644 --- a/frappe/core/doctype/success_action/success_action.py +++ b/frappe/core/doctype/success_action/success_action.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2018, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE from frappe.model.document import Document diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index 4b53983702..82e88d2477 100644 --- a/frappe/core/doctype/system_settings/system_settings.json +++ b/frappe/core/doctype/system_settings/system_settings.json @@ -23,6 +23,7 @@ "currency_precision", "sec_backup_limit", "backup_limit", + "encrypt_backup", "background_workers", "enable_scheduler", "dormant_days", @@ -65,9 +66,7 @@ "attach_view_link", "prepared_report_section", "enable_prepared_report_auto_deletion", - "prepared_report_expiry_period", - "chat", - "enable_chat" + "prepared_report_expiry_period" ], "fields": [ { @@ -381,18 +380,6 @@ "fieldtype": "Check", "label": "Hide footer in auto email reports" }, - { - "collapsible": 1, - "fieldname": "chat", - "fieldtype": "Section Break", - "label": "Chat" - }, - { - "default": "1", - "fieldname": "enable_chat", - "fieldtype": "Check", - "label": "Enable Chat" - }, { "fieldname": "column_break_21", "fieldtype": "Column Break" @@ -469,12 +456,18 @@ "fieldname": "strip_exif_metadata_from_uploaded_images", "fieldtype": "Check", "label": "Strip EXIF tags from uploaded images" + }, + { + "default": "0", + "fieldname": "encrypt_backup", + "fieldtype": "Check", + "label": "Encrypt Backups" } ], "icon": "fa fa-cog", "issingle": 1, "links": [], - "modified": "2021-03-30 11:47:47.330437", + "modified": "2021-10-21 19:24:15.232430", "modified_by": "Administrator", "module": "Core", "name": "System Settings", @@ -492,4 +485,4 @@ "sort_field": "modified", "sort_order": "ASC", "track_changes": 1 -} +} \ No newline at end of file diff --git a/frappe/core/doctype/system_settings/system_settings.py b/frappe/core/doctype/system_settings/system_settings.py index 466914569f..1ae8e9e79e 100644 --- a/frappe/core/doctype/system_settings/system_settings.py +++ b/frappe/core/doctype/system_settings/system_settings.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe import _ diff --git a/frappe/core/doctype/system_settings/test_system_settings.py b/frappe/core/doctype/system_settings/test_system_settings.py index a65c602abe..f95e26b793 100644 --- a/frappe/core/doctype/system_settings/test_system_settings.py +++ b/frappe/core/doctype/system_settings/test_system_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/core/doctype/test/test.py b/frappe/core/doctype/test/test.py index 98e36e6a30..4cb088c117 100644 --- a/frappe/core/doctype/test/test.py +++ b/frappe/core/doctype/test/test.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2021, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/test/test_test.py b/frappe/core/doctype/test/test_test.py index d8ca975d63..d8508b8651 100644 --- a/frappe/core/doctype/test/test_test.py +++ b/frappe/core/doctype/test/test_test.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2021, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE # import frappe import unittest diff --git a/frappe/core/doctype/transaction_log/test_transaction_log.py b/frappe/core/doctype/transaction_log/test_transaction_log.py index 0d9b9353d0..c332a82f65 100644 --- a/frappe/core/doctype/transaction_log/test_transaction_log.py +++ b/frappe/core/doctype/transaction_log/test_transaction_log.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2018, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest import hashlib diff --git a/frappe/core/doctype/transaction_log/transaction_log.py b/frappe/core/doctype/transaction_log/transaction_log.py index 58d0b3d176..6dc4340277 100644 --- a/frappe/core/doctype/transaction_log/transaction_log.py +++ b/frappe/core/doctype/transaction_log/transaction_log.py @@ -1,12 +1,13 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2018, Frappe Technologies and contributors -# For license information, please see license.txt +# Copyright (c) 2021, Frappe Technologies and contributors +# License: MIT. See LICENSE + +import hashlib import frappe from frappe import _ from frappe.model.document import Document +from frappe.query_builder import DocType from frappe.utils import cint, now_datetime -import hashlib class TransactionLog(Document): def before_insert(self): @@ -14,10 +15,9 @@ class TransactionLog(Document): self.row_index = index self.timestamp = now_datetime() if index != 1: - prev_hash = frappe.db.sql( - "SELECT `chaining_hash` FROM `tabTransaction Log` WHERE `row_index` = '{0}'".format(index - 1)) + prev_hash = frappe.get_all("Transaction Log", filters={"row_index":str(index-1)}, pluck="chaining_hash", limit=1) if prev_hash: - self.previous_hash = prev_hash[0][0] + self.previous_hash = prev_hash[0] else: self.previous_hash = "Indexing broken" else: @@ -45,10 +45,14 @@ class TransactionLog(Document): def get_current_index(): - current = frappe.db.sql("""SELECT `current` - FROM `tabSeries` - WHERE `name` = 'TRANSACTLOG' - FOR UPDATE""") + series = DocType("Series") + current = ( + frappe.qb.from_(series) + .where(series.name == "TRANSACTLOG") + .for_update() + .select("current") + ).run() + if current and current[0][0] is not None: current = current[0][0] diff --git a/frappe/core/doctype/translation/test_translation.py b/frappe/core/doctype/translation/test_translation.py index ae1293b38f..982d9bf976 100644 --- a/frappe/core/doctype/translation/test_translation.py +++ b/frappe/core/doctype/translation/test_translation.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest @@ -8,7 +8,7 @@ from frappe import _ class TestTranslation(unittest.TestCase): def setUp(self): - frappe.db.sql('delete from tabTranslation') + frappe.db.delete("Translation") def tearDown(self): frappe.local.lang = 'en' diff --git a/frappe/core/doctype/translation/translation.py b/frappe/core/doctype/translation/translation.py index b1f4642791..a01552903c 100644 --- a/frappe/core/doctype/translation/translation.py +++ b/frappe/core/doctype/translation/translation.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/user/test_records.json b/frappe/core/doctype/user/test_records.json index f9033d4660..21fe3ff69d 100644 --- a/frappe/core/doctype/user/test_records.json +++ b/frappe/core/doctype/user/test_records.json @@ -70,5 +70,19 @@ "role": "System Manager" } ] - } + }, + { + "doctype": "User", + "email": "testpassword@example.com", + "enabled": 1, + "first_name": "_Test", + "new_password": "Eastern_43A1W", + "roles": [ + { + "doctype": "Has Role", + "parentfield": "roles", + "role": "System Manager" + } + ] + } ] diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py index 392128834d..e47846958a 100644 --- a/frappe/core/doctype/user/test_user.py +++ b/frappe/core/doctype/user/test_user.py @@ -1,16 +1,18 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt -import frappe, unittest, uuid +# License: MIT. See LICENSE +import json +import unittest +from unittest.mock import patch -from frappe.model.delete_doc import delete_doc -from frappe.utils.data import today, add_to_date -from frappe import _dict -from frappe.utils import get_url -from frappe.core.doctype.user.user import get_total_users -from frappe.core.doctype.user.user import MaxUsersReachedError, test_password_strength -from frappe.core.doctype.user.user import extract_mentions +import frappe +import frappe.exceptions +from frappe.core.doctype.user.user import (extract_mentions, reset_password, + sign_up, test_password_strength, update_password, verify_password) from frappe.frappeclient import FrappeClient +from frappe.model.delete_doc import delete_doc +from frappe.utils import get_url +user_module = frappe.core.doctype.user.user test_records = frappe.get_test_records('User') class TestUser(unittest.TestCase): @@ -23,7 +25,7 @@ class TestUser(unittest.TestCase): def test_user_type(self): new_user = frappe.get_doc(dict(doctype='User', email='test-for-type@example.com', - first_name='Tester')).insert() + first_name='Tester')).insert(ignore_if_duplicate=True) self.assertEqual(new_user.user_type, 'Website User') # social login userid for frappe @@ -52,7 +54,7 @@ class TestUser(unittest.TestCase): def test_delete(self): frappe.get_doc("User", "test@example.com").add_roles("_Test Role 2") self.assertRaises(frappe.LinkExistsError, delete_doc, "Role", "_Test Role 2") - frappe.db.sql("""delete from `tabHas Role` where role='_Test Role 2'""") + frappe.db.delete("Has Role", {"role": "_Test Role 2"}) delete_doc("Role","_Test Role 2") if frappe.db.exists("User", "_test@example.com"): @@ -119,40 +121,9 @@ class TestUser(unittest.TestCase): # 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): - # from frappe.installer import update_site_config - # clear_limit('users') - # - # # allow one session - # user = frappe.get_doc('User', 'test@example.com') - # user.simultaneous_sessions = 1 - # user.new_password = 'Eastern_43A1W' - # user.save() - # - # def test_request(conn): - # value = conn.get_value('User', 'first_name', {'name': 'test@example.com'}) - # self.assertTrue('first_name' in value) - # - # from frappe.frappeclient import FrappeClient - # update_site_config('deny_multiple_sessions', 0) - # - # conn1 = FrappeClient(get_url(), "test@example.com", "Eastern_43A1W", verify=False) - # test_request(conn1) - # - # conn2 = FrappeClient(get_url(), "test@example.com", "Eastern_43A1W", verify=False) - # test_request(conn2) - # - # update_site_config('deny_multiple_sessions', 1) - # conn3 = FrappeClient(get_url(), "test@example.com", "Eastern_43A1W", verify=False) - # test_request(conn3) - # - # # first connection should fail - # test_request(conn1) - - def test_delete_user(self): new_user = frappe.get_doc(dict(doctype='User', email='test-for-delete@example.com', - first_name='Tester Delete User')).insert() + first_name='Tester Delete User')).insert(ignore_if_duplicate=True) self.assertEqual(new_user.user_type, 'Website User') # role with desk access @@ -174,7 +145,7 @@ class TestUser(unittest.TestCase): self.assertFalse(frappe.db.exists('User', new_user.name)) def test_password_strength(self): - # Test Password without Password Strenth Policy + # Test Password without Password Strength Policy frappe.db.set_value("System Settings", "System Settings", "enable_password_policy", 0) # password policy is disabled, test_password_strength should be ignored @@ -193,6 +164,17 @@ class TestUser(unittest.TestCase): result = test_password_strength("Eastern_43A1W") self.assertEqual(result['feedback']['password_policy_validation_passed'], True) + + # test password strength while saving user with new password + user = frappe.get_doc("User", "test@example.com") + frappe.flags.in_test = False + user.new_password = "password" + self.assertRaisesRegex(frappe.exceptions.ValidationError, "Invalid Password", user.save) + user.reload() + user.new_password = "Eastern_43A1W" + user.save() + frappe.flags.in_test = True + def test_comment_mentions(self): comment = ''' @@ -227,6 +209,7 @@ class TestUser(unittest.TestCase): self.assertEqual(extract_mentions(comment)[0], "test_user@example.com") self.assertEqual(extract_mentions(comment)[1], "test.again@example1.com") + frappe.delete_doc("User Group", "Team") doc = frappe.get_doc({ 'doctype': 'User Group', 'name': 'Team', @@ -236,14 +219,18 @@ class TestUser(unittest.TestCase): 'user': 'test1@example.com' }] }) - doc.insert(ignore_if_duplicate=True) + + doc.insert() comment = '''
Testing comment for @Team - + and + + @Unknown Team + please check
''' @@ -267,32 +254,125 @@ class TestUser(unittest.TestCase): self.assertEqual(res1.status_code, 200) self.assertEqual(res2.status_code, 417) - # def test_user_rollback(self): - # """ - # FIXME: This is failing with PR #12693 as Rollback can't happen if notifications sent on user creation. - # Make sure that notifications disabled. - # """ - # 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 + def test_user_rename(self): + old_name = "test_user_rename@example.com" + new_name = "test_user_rename_new@example.com" + user = frappe.get_doc({ + "doctype": "User", + "email": old_name, + "enabled": 1, + "first_name": "_Test", + "new_password": "Eastern_43A1W", + "roles": [ + { + "doctype": "Has Role", + "parentfield": "roles", + "role": "System Manager" + }] + }).insert(ignore_permissions=True, ignore_if_duplicate=True) - # # Check user has been added - # self.assertIsNotNone(frappe.db.get("User", {"email": email})) + frappe.rename_doc('User', user.name, new_name) + self.assertTrue(frappe.db.exists("Notification Settings", new_name)) + + frappe.delete_doc("User", new_name) + + def test_signup(self): + import frappe.website.utils + random_user = frappe.mock('email') + random_user_name = frappe.mock('name') + # disabled signup + with patch.object(user_module, "is_signup_disabled", return_value=True): + self.assertRaisesRegex(frappe.exceptions.ValidationError, "Sign Up is disabled", + sign_up, random_user, random_user_name, "/signup") + + self.assertTupleEqual(sign_up(random_user, random_user_name, "/welcome"), (1, "Please check your email for verification")) + self.assertEqual(frappe.cache().hget('redirect_after_login', random_user), "/welcome") + + # re-register + self.assertTupleEqual(sign_up(random_user, random_user_name, "/welcome"), (0, "Already Registered")) + + # disabled user + user = frappe.get_doc("User", random_user) + user.enabled = 0 + user.save() + + self.assertTupleEqual(sign_up(random_user, random_user_name, "/welcome"), (0, "Registered but disabled")) + + # throttle user creation + with patch.object(user_module.frappe.db, "get_creation_count", return_value=301): + self.assertRaisesRegex(frappe.exceptions.ValidationError, "Throttled", + sign_up, frappe.mock('email'), random_user_name, "/signup") + + + def test_reset_password(self): + from frappe.auth import CookieManager, LoginManager + from frappe.utils import set_request + old_password = "Eastern_43A1W" + new_password = "easy_password" + + set_request(path="/random") + frappe.local.cookie_manager = CookieManager() + frappe.local.login_manager = LoginManager() + + frappe.set_user("testpassword@example.com") + test_user = frappe.get_doc("User", "testpassword@example.com") + test_user.reset_password() + self.assertEqual(update_password(new_password, key=test_user.reset_password_key), "/app") + self.assertEqual(update_password(new_password, key="wrong_key"), "The Link specified has either been used before or Invalid") + + # password verification should fail with old password + self.assertRaises(frappe.exceptions.AuthenticationError, verify_password, old_password) + verify_password(new_password) + + # reset password + update_password(old_password, old_password=new_password) + + self.assertRaisesRegex(frappe.exceptions.ValidationError, "Invalid key type", update_password, "test", 1, ['like', '%']) + + password_strength_response = { + "feedback": { + "password_policy_validation_passed": False, + "suggestions": ["Fix password"] + } + } + + # password strength failure test + with patch.object(user_module, "test_password_strength", return_value=password_strength_response): + self.assertRaisesRegex(frappe.exceptions.ValidationError, "Fix password", update_password, new_password, 0, test_user.reset_password_key) + + + # test redirect URL for website users + frappe.set_user("test2@example.com") + self.assertEqual(update_password(new_password, old_password=old_password), "/") + # reset password + update_password(old_password, old_password=new_password) + + # test API endpoint + with patch.object(user_module.frappe, 'sendmail') as sendmail: + frappe.clear_messages() + test_user = frappe.get_doc("User", "test2@example.com") + self.assertEqual(reset_password(user="test2@example.com"), None) + test_user.reload() + self.assertEqual(update_password(new_password, key=test_user.reset_password_key), "/") + update_password(old_password, old_password=new_password) + self.assertEqual(json.loads(frappe.message_log[0]), {"message": "Password reset instructions have been sent to your email"}) + sendmail.assert_called_once() + self.assertEqual(sendmail.call_args[1]["recipients"], "test2@example.com") + + self.assertEqual(reset_password(user="test2@example.com"), None) + self.assertEqual(reset_password(user="Administrator"), "not allowed") + self.assertEqual(reset_password(user="random"), "not found") + + def test_user_onload_modules(self): + from frappe.config import get_modules_from_all_apps + from frappe.desk.form.load import getdoc + frappe.response.docs = [] + getdoc("User", "Administrator") + doc = frappe.response.docs[0] + self.assertListEqual(doc.get("__onload").get('all_modules', []), + [m.get("module_name") for m in get_modules_from_all_apps()]) - # # 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) - frappe.db.sql("DELETE FROM `tabContact Email` WHERE `email_id`= %s", user) + frappe.db.delete("Contact", {"email_id": user}) + frappe.db.delete("Contact Email", {"email_id": user}) diff --git a/frappe/core/doctype/user/user.js b/frappe/core/doctype/user/user.js index 819684cdfe..48dc2d1672 100644 --- a/frappe/core/doctype/user/user.js +++ b/frappe/core/doctype/user/user.js @@ -171,7 +171,7 @@ frappe.ui.form.on('User', { frm.add_custom_button(__("Reset OTP Secret"), function() { frappe.call({ - method: "frappe.core.doctype.user.user.reset_otp_secret", + method: "frappe.twofactor.reset_otp_secret", args: { "user": frm.doc.name } @@ -268,6 +268,7 @@ frappe.ui.form.on('User', { callback: function(r) { if (r.message) { frappe.msgprint(__("Save API Secret: {0}", [r.message.api_secret])); + frm.reload_doc(); } } }); diff --git a/frappe/core/doctype/user/user.json b/frappe/core/doctype/user/user.json index 1d5f89897d..ea31e76a57 100644 --- a/frappe/core/doctype/user/user.json +++ b/frappe/core/doctype/user/user.json @@ -202,7 +202,8 @@ "fieldname": "role_profile_name", "fieldtype": "Link", "label": "Role Profile", - "options": "Role Profile" + "options": "Role Profile", + "permlevel": 1 }, { "fieldname": "roles_html", @@ -554,20 +555,22 @@ "collapsible": 1, "fieldname": "api_access", "fieldtype": "Section Break", - "label": "Api Access" + "label": "API Access" }, { - "description": "API Key cannot be regenerated", + "description": "API Key cannot be regenerated", "fieldname": "api_key", "fieldtype": "Data", "label": "API Key", + "permlevel": 1, "read_only": 1, "unique": 1 }, { "fieldname": "generate_keys", "fieldtype": "Button", - "label": "Generate Keys" + "label": "Generate Keys", + "permlevel": 1 }, { "fieldname": "column_break_65", @@ -577,6 +580,7 @@ "fieldname": "api_secret", "fieldtype": "Password", "label": "API Secret", + "permlevel": 1, "read_only": 1 }, { @@ -613,11 +617,6 @@ "link_doctype": "Contact", "link_fieldname": "user" }, - { - "group": "Profile", - "link_doctype": "Chat Profile", - "link_fieldname": "user" - }, { "group": "Profile", "link_doctype": "Blogger", @@ -670,7 +669,7 @@ } ], "max_attachments": 5, - "modified": "2021-02-02 16:11:06.037543", + "modified": "2021-10-27 17:17:16.098457", "modified_by": "Administrator", "module": "Core", "name": "User", @@ -705,4 +704,4 @@ "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 cf2b045c6d..fd19f4d82e 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE from bs4 import BeautifulSoup import frappe import frappe.share @@ -13,19 +13,14 @@ from frappe.utils.password import update_password as _update_password, check_pas from frappe.desk.notifications import clear_notifications from frappe.desk.doctype.notification_settings.notification_settings import create_notification_settings, toggle_notifications from frappe.utils.user import get_system_managers -from frappe.website.utils import is_signup_enabled +from frappe.website.utils import is_signup_disabled from frappe.rate_limiter import rate_limit -from frappe.utils.background_jobs import enqueue from frappe.core.doctype.user_type.user_type import user_linked_with_permission_on_doctype +from frappe.query_builder import DocType STANDARD_USERS = ("Guest", "Administrator") - -class MaxUsersReachedError(frappe.ValidationError): - pass - - class User(Document): __new_password = None @@ -53,10 +48,9 @@ class User(Document): def after_insert(self): create_notification_settings(self.name) frappe.cache().delete_key('users_for_mentions') + frappe.cache().delete_key('enabled_users') def validate(self): - self.check_demo() - # clear new password self.__new_password = self.new_password self.new_password = "" @@ -130,14 +124,13 @@ class User(Document): if self.has_value_changed('allow_in_mentions') or self.has_value_changed('user_type'): frappe.cache().delete_key('users_for_mentions') + if self.has_value_changed('enabled'): + frappe.cache().delete_key('enabled_users') + def has_website_permission(self, ptype, user, verbose=False): """Returns true if current user is the session user""" return self.name == frappe.session.user - def check_demo(self): - if frappe.session.user == 'demo@erpnext.com': - frappe.throw(_('Cannot change user details in demo. Please signup for a new account at https://erpnext.com'), title=_('Not Allowed')) - def set_full_name(self): self.full_name = " ".join(filter(None, [self.first_name, self.last_name])) @@ -365,27 +358,31 @@ class User(Document): frappe.local.login_manager.logout(user=self.name) # delete todos - frappe.db.sql("""DELETE FROM `tabToDo` WHERE `owner`=%s""", (self.name,)) + frappe.db.delete("ToDo", {"owner": self.name}) frappe.db.sql("""UPDATE `tabToDo` SET `assigned_by`=NULL WHERE `assigned_by`=%s""", (self.name,)) # delete events - frappe.db.sql("""delete from `tabEvent` where owner=%s - and event_type='Private'""", (self.name,)) + frappe.db.delete("Event", {"owner": self.name, "event_type": "Private"}) # delete shares - frappe.db.sql("""delete from `tabDocShare` where user=%s""", self.name) - + frappe.db.delete("DocShare", {"user": self.name}) # delete messages - frappe.db.sql("""delete from `tabCommunication` - where communication_type in ('Chat', 'Notification') - and reference_doctype='User' - and (reference_name=%s or owner=%s)""", (self.name, self.name)) - + table = DocType("Communication") + frappe.db.delete( + table, + filters=( + (table.communication_type.isin(["Chat", "Notification"])) + & (table.reference_doctype == "User") + & ((table.reference_name == self.name) | table.owner == self.name) + ), + run=False, + ) # unlink contact - frappe.db.sql("""update `tabContact` - set `user`=null - where `user`=%s""", (self.name)) + table = DocType("Contact") + frappe.qb.update(table).where( + table.user == self.name + ).set(table.user, None).run() # delete notification settings frappe.delete_doc("Notification Settings", self.name, ignore_permissions=True) @@ -393,9 +390,10 @@ class User(Document): if self.get('allow_in_mentions'): frappe.cache().delete_key('users_for_mentions') + frappe.cache().delete_key('enabled_users') + def before_rename(self, old_name, new_name, merge=False): - self.check_demo() frappe.clear_cache(user=old_name) self.validate_rename(old_name, new_name) @@ -424,16 +422,14 @@ class User(Document): WHERE `%s` = %s""" % (tab, field, '%s', field, '%s'), (new_name, old_name)) - if frappe.db.exists("Chat Profile", old_name): - frappe.rename_doc("Chat Profile", old_name, new_name, force=True, show_alert=False) - if frappe.db.exists("Notification Settings", old_name): frappe.rename_doc("Notification Settings", old_name, new_name, force=True, show_alert=False) # set email - frappe.db.sql("""UPDATE `tabUser` - SET email = %s - WHERE name = %s""", (new_name, new_name)) + table = DocType("User") + frappe.qb.update(table).where( + table.name == new_name + ).set("email", new_name).run() def append_roles(self, *roles): """Add roles to user""" @@ -721,115 +717,33 @@ def get_email_awaiting(user): where parent = %(user)s""",{"user":user}) return False -@frappe.whitelist(allow_guest=False) -def set_email_password(email_account, user, password): - account = frappe.get_doc("Email Account", email_account) - if account.awaiting_password: - account.awaiting_password = 0 - account.password = password - try: - account.save(ignore_permissions=True) - except Exception: - frappe.db.rollback() - return False - - return True - -def setup_user_email_inbox(email_account, awaiting_password, email_id, enable_outgoing): - """ setup email inbox for user """ - def add_user_email(user): - user = frappe.get_doc("User", user) - row = user.append("user_emails", {}) - - row.email_id = email_id - row.email_account = email_account - row.awaiting_password = awaiting_password or 0 - row.enable_outgoing = enable_outgoing or 0 - - user.save(ignore_permissions=True) - - udpate_user_email_settings = False - if not all([email_account, email_id]): - return - - user_names = frappe.db.get_values("User", { "email": email_id }, as_dict=True) - if not user_names: - return - - for user in user_names: - user_name = user.get("name") - - # check if inbox is alreay configured - user_inbox = frappe.db.get_value("User Email", { - "email_account": email_account, - "parent": user_name - }, ["name"]) or None - - if not user_inbox: - add_user_email(user_name) - else: - # update awaiting password for email account - udpate_user_email_settings = True - - if udpate_user_email_settings: - frappe.db.sql("""UPDATE `tabUser Email` SET awaiting_password = %(awaiting_password)s, - enable_outgoing = %(enable_outgoing)s WHERE email_account = %(email_account)s""", { - "email_account": email_account, - "enable_outgoing": enable_outgoing, - "awaiting_password": awaiting_password or 0 - }) - else: - users = " and ".join([frappe.bold(user.get("name")) for user in user_names]) - frappe.msgprint(_("Enabled email inbox for user {0}").format(users)) - - ask_pass_update() - -def remove_user_email_inbox(email_account): - """ remove user email inbox settings if email account is deleted """ - if not email_account: - return - - users = frappe.get_all("User Email", filters={ - "email_account": email_account - }, fields=["parent as name"]) - - for user in users: - doc = frappe.get_doc("User", user.get("name")) - to_remove = [ row for row in doc.user_emails if row.email_account == email_account ] - [ doc.remove(row) for row in to_remove ] - - doc.save(ignore_permissions=True) - def ask_pass_update(): # update the sys defaults as to awaiting users from frappe.utils import set_default - users = frappe.db.sql("""SELECT DISTINCT(parent) as user FROM `tabUser Email` - WHERE awaiting_password = 1""", as_dict=True) + doctype = DocType("User Email") + users = frappe.qb.from_(doctype).where(doctype.awaiting_password == 1).select( + doctype.parent.as_("user") + ).distinct().run(as_dict=True) password_list = [ user.get("user") for user in users ] set_default("email_user_password", u','.join(password_list)) def _get_user_for_update_password(key, old_password): # verify old password + result = frappe._dict() if key: - user = frappe.db.get_value("User", {"reset_password_key": key}) - if not user: - return { - 'message': _("The Link specified has either been used before or Invalid") - } + result.user = frappe.db.get_value("User", {"reset_password_key": key}) + if not result.user: + result.message = _("The Link specified has either been used before or Invalid") elif old_password: # verify old password frappe.local.login_manager.check_password(frappe.session.user, old_password) user = frappe.session.user + result.user = user - else: - return - - return { - 'user': user - } + return result def reset_user_data(user): user_doc = frappe.get_doc("User", user) @@ -846,19 +760,17 @@ def verify_password(password): @frappe.whitelist(allow_guest=True) def sign_up(email, full_name, redirect_to): - if not is_signup_enabled(): + if is_signup_disabled(): frappe.throw(_('Sign Up is disabled'), title='Not Allowed') user = frappe.db.get("User", {"email": email}) if user: - if user.disabled: - return 0, _("Registered but disabled") - else: + if user.enabled: return 0, _("Already Registered") + else: + return 0, _("Registered but disabled") else: - if frappe.db.sql("""select count(*) from tabUser where - HOUR(TIMEDIFF(CURRENT_TIMESTAMP, TIMESTAMP(modified)))=1""")[0][0] > 300: - + if frappe.db.get_creation_count('User', 60) > 300: frappe.respond_as_web_page(_('Temporarily Disabled'), _('Too many users signed up recently, so the registration is disabled. Please try back in an hour'), http_status_code=429) @@ -890,7 +802,7 @@ def sign_up(email, full_name, redirect_to): return 2, _("Please ask your administrator to verify your sign-up") @frappe.whitelist(allow_guest=True) -@rate_limit(key='user', limit=get_password_reset_limit, seconds = 24*60*60, methods=['POST']) +@rate_limit(limit=get_password_reset_limit, seconds = 24*60*60, methods=['POST']) def reset_password(user): if user=="Administrator": return 'not allowed' @@ -1051,91 +963,6 @@ def update_gravatar(name): if gravatar: frappe.db.set_value('User', name, 'user_image', gravatar) -@frappe.whitelist(allow_guest=True) -def send_token_via_sms(tmp_id,phone_no=None,user=None): - try: - from frappe.core.doctype.sms_settings.sms_settings import send_request - except: - return False - - if not frappe.cache().ttl(tmp_id + '_token'): - return False - ss = frappe.get_doc('SMS Settings', 'SMS Settings') - if not ss.sms_gateway_url: - return False - - token = frappe.cache().get(tmp_id + '_token') - args = {ss.message_parameter: 'verification code is {}'.format(token)} - - for d in ss.get("parameters"): - args[d.parameter] = d.value - - if user: - user_phone = frappe.db.get_value('User', user, ['phone','mobile_no'], as_dict=1) - usr_phone = user_phone.mobile_no or user_phone.phone - if not usr_phone: - return False - else: - if phone_no: - usr_phone = phone_no - else: - return False - - args[ss.receiver_parameter] = usr_phone - status = send_request(ss.sms_gateway_url, args, use_post=ss.use_post) - - if 200 <= status < 300: - frappe.cache().delete(tmp_id + '_token') - return True - else: - return False - -@frappe.whitelist(allow_guest=True) -def send_token_via_email(tmp_id,token=None): - import pyotp - - user = frappe.cache().get(tmp_id + '_user') - count = token or frappe.cache().get(tmp_id + '_token') - - if ((not user) or (user == 'None') or (not count)): - return False - user_email = frappe.db.get_value('User',user, 'email') - if not user_email: - return False - - otpsecret = frappe.cache().get(tmp_id + '_otp_secret') - hotp = pyotp.HOTP(otpsecret) - - frappe.sendmail( - recipients=user_email, - sender=None, - subject="Verification Code", - template="verification_code", - args=dict(code=hotp.at(int(count))), - delayed=False, - retry=3 - ) - - return True - -@frappe.whitelist(allow_guest=True) -def reset_otp_secret(user): - otp_issuer = frappe.db.get_value('System Settings', 'System Settings', 'otp_issuer_name') - user_email = frappe.db.get_value('User',user, 'email') - if frappe.session.user in ["Administrator", user] : - frappe.defaults.clear_default(user + '_otplogin') - frappe.defaults.clear_default(user + '_otpsecret') - email_args = { - 'recipients':user_email, 'sender':None, 'subject':'OTP Secret Reset - {}'.format(otp_issuer or "Frappe Framework"), - 'message':'

Your OTP secret on {} has been reset. If you did not perform this reset and did not request it, please contact your System Administrator immediately.

'.format(otp_issuer or "Frappe Framework"), - 'delayed':False, - 'retry':3 - } - enqueue(method=frappe.sendmail, queue='short', timeout=300, event=None, is_async=True, job_name=None, now=False, **email_args) - return frappe.msgprint(_("OTP Secret has been reset. Re-registration will be required on next login.")) - else: - return frappe.throw(_("OTP secret can only be reset by the Administrator.")) - def throttle_user_creation(): if frappe.flags.in_import: return @@ -1153,15 +980,6 @@ 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) - roles = [role.role for role in role_profile.roles] - for d in users: - user = frappe.get_doc('User', d) - user.set('roles', []) - user.add_roles(*roles) - def create_contact(user, ignore_links=False, ignore_mandatory=False): from frappe.contacts.doctype.contact.contact import get_contact_name if user.name in ["Administrator", "Guest"]: return @@ -1220,20 +1038,27 @@ def generate_keys(user): :param user: str """ - if "System Manager" in frappe.get_roles(): - user_details = frappe.get_doc("User", user) - api_secret = frappe.generate_hash(length=15) - # if api key is not set generate api key - if not user_details.api_key: - api_key = frappe.generate_hash(length=15) - user_details.api_key = api_key - user_details.api_secret = api_secret - user_details.save() + frappe.only_for("System Manager") + user_details = frappe.get_doc("User", user) + api_secret = frappe.generate_hash(length=15) + # if api key is not set generate api key + if not user_details.api_key: + api_key = frappe.generate_hash(length=15) + user_details.api_key = api_key + user_details.api_secret = api_secret + user_details.save() + + return {"api_secret": api_secret} - 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 get_enabled_users(): + def _get_enabled_users(): + enabled_users = frappe.get_all("User", filters={"enabled": "1"}, pluck="name") + return enabled_users + + return frappe.cache().get_value("enabled_users", _get_enabled_users) diff --git a/frappe/core/doctype/user_document_type/user_document_type.py b/frappe/core/doctype/user_document_type/user_document_type.py index 48dbf87b3d..a14d735e6a 100644 --- a/frappe/core/doctype/user_document_type/user_document_type.py +++ b/frappe/core/doctype/user_document_type/user_document_type.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2021, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/user_email/user_email.py b/frappe/core/doctype/user_email/user_email.py index 729aa03444..daad083577 100644 --- a/frappe/core/doctype/user_email/user_email.py +++ b/frappe/core/doctype/user_email/user_email.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/user_group/test_user_group.py b/frappe/core/doctype/user_group/test_user_group.py index 2f89d032e1..b5d642ae9c 100644 --- a/frappe/core/doctype/user_group/test_user_group.py +++ b/frappe/core/doctype/user_group/test_user_group.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2021, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE # import frappe import unittest diff --git a/frappe/core/doctype/user_group/user_group.py b/frappe/core/doctype/user_group/user_group.py index 178775d407..05ff71e353 100644 --- a/frappe/core/doctype/user_group/user_group.py +++ b/frappe/core/doctype/user_group/user_group.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2021, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/user_group_member/test_user_group_member.py b/frappe/core/doctype/user_group_member/test_user_group_member.py index 8dbaed9e65..6d4650a3d0 100644 --- a/frappe/core/doctype/user_group_member/test_user_group_member.py +++ b/frappe/core/doctype/user_group_member/test_user_group_member.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2021, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE # import frappe import unittest diff --git a/frappe/core/doctype/user_group_member/user_group_member.py b/frappe/core/doctype/user_group_member/user_group_member.py index f85ddc3209..69718d8d91 100644 --- a/frappe/core/doctype/user_group_member/user_group_member.py +++ b/frappe/core/doctype/user_group_member/user_group_member.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2021, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/user_permission/test_user_permission.py b/frappe/core/doctype/user_permission/test_user_permission.py index 1a442b53e7..cf905c2ce2 100644 --- a/frappe/core/doctype/user_permission/test_user_permission.py +++ b/frappe/core/doctype/user_permission/test_user_permission.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2017, Frappe Technologies and Contributors -# See license.txt +# Copyright (c) 2021, Frappe Technologies and Contributors +# See LICENSE from frappe.core.doctype.user_permission.user_permission import add_user_permissions, remove_applicable from frappe.permissions import has_user_permission from frappe.core.doctype.doctype.test_doctype import new_doctype @@ -10,11 +9,14 @@ 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', - 'nested_doc_user@example.com')""") + test_users = ( + "test_bulk_creation_update@example.com", + "test_user_perm1@example.com", + "nested_doc_user@example.com", + ) + frappe.db.delete("User Permission", { + "user": ("in", test_users) + }) frappe.delete_doc_if_exists("DocType", "Person") frappe.db.sql_ddl("DROP TABLE IF EXISTS `tabPerson`") frappe.delete_doc_if_exists("DocType", "Doc A") @@ -71,7 +73,7 @@ class TestUserPermission(unittest.TestCase): def test_for_applicable_on_update_from_apply_to_all(self): ''' Update User Permission from all to some applicable Doctypes''' user = create_user('test_bulk_creation_update@example.com') - param = get_params(user,'User', user.name, applicable = ["Chat Room", "Chat Message"]) + param = get_params(user,'User', user.name, applicable = ["Comment", "Contact"]) # Initially create User Permission document with apply_to_all checked is_created = add_user_permissions(get_params(user, 'User', user.name)) @@ -82,8 +84,8 @@ class TestUserPermission(unittest.TestCase): frappe.db.commit() removed_apply_to_all = frappe.db.exists("User Permission", get_exists_param(user)) - is_created_applicable_first = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Chat Room")) - is_created_applicable_second = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Chat Message")) + is_created_applicable_first = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Comment")) + is_created_applicable_second = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Contact")) # Check that apply_to_all is removed self.assertIsNone(removed_apply_to_all) @@ -99,14 +101,14 @@ class TestUserPermission(unittest.TestCase): param = get_params(user, 'User', user.name) # create User permissions that with applicable - is_created = add_user_permissions(get_params(user, 'User', user.name, applicable = ["Chat Room", "Chat Message"])) + is_created = add_user_permissions(get_params(user, 'User', user.name, applicable = ["Comment", "Contact"])) self.assertEqual(is_created, 1) is_created = add_user_permissions(param) is_created_apply_to_all = frappe.db.exists("User Permission", get_exists_param(user)) - removed_applicable_first = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Chat Room")) - removed_applicable_second = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Chat Message")) + removed_applicable_first = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Comment")) + removed_applicable_second = frappe.db.exists("User Permission", get_exists_param(user, applicable = "Contact")) # To check that a User permission with apply_to_all exists self.assertIsNotNone(is_created_apply_to_all) diff --git a/frappe/core/doctype/user_permission/user_permission.py b/frappe/core/doctype/user_permission/user_permission.py index 4aa5797c7f..1366ace115 100644 --- a/frappe/core/doctype/user_permission/user_permission.py +++ b/frappe/core/doctype/user_permission/user_permission.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2017, Frappe Technologies and contributors -# For license information, please see license.txt +# Copyright (c) 2021, Frappe Technologies and contributors +# License: MIT. See LICENSE import frappe, json from frappe.model.document import Document @@ -55,7 +54,7 @@ class UserPermission(Document): 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(allow_guest=True) +@frappe.whitelist() 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, @@ -179,11 +178,16 @@ def check_applicable_doc_perm(user, doctype, docname): @frappe.whitelist() def clear_user_permissions(user, for_doctype): - frappe.only_for('System Manager') - total = frappe.db.count('User Permission', filters = dict(user=user, allow=for_doctype)) + frappe.only_for("System Manager") + total = frappe.db.count("User Permission", {"user": user, "allow": for_doctype}) + if total: - frappe.db.sql('DELETE FROM `tabUser Permission` WHERE `user`=%s AND `allow`=%s', (user, for_doctype)) + frappe.db.delete("User Permission", { + "allow": for_doctype, + "user": user, + }) frappe.clear_cache() + return total @frappe.whitelist() @@ -225,7 +229,7 @@ def insert_user_perm(user, doctype, docname, is_default=0, hide_descendants=0, a user_perm.is_default = is_default user_perm.hide_descendants = hide_descendants if applicable: - user_perm.applicable_for = applicable + user_perm.applicable_for = applicable user_perm.apply_to_all_doctypes = 0 else: user_perm.apply_to_all_doctypes = 1 @@ -233,27 +237,27 @@ def insert_user_perm(user, doctype, docname, is_default=0, hide_descendants=0, a 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 - AND `allow`=%s - AND `for_value`=%s - """, (user, applicable_for, doctype, docname)) + frappe.db.delete("User Permission", { + "applicable_for": applicable_for, + "for_value": docname, + "allow": doctype, + "user": user, + }) def remove_apply_to_all(user, doctype, docname): - frappe.db.sql("""DELETE from `tabUser Permission` - WHERE `user`=%s - AND `apply_to_all_doctypes`=1 - AND `allow`=%s - AND `for_value`=%s - """,(user, doctype, docname)) + frappe.db.delete("User Permission", { + "apply_to_all_doctypes": 1, + "for_value": docname, + "allow": doctype, + "user": user, + }) def update_applicable(already_applied, to_apply, user, doctype, docname): for applied in already_applied: if applied not in to_apply: - frappe.db.sql("""DELETE FROM `tabUser Permission` - WHERE `user`=%s - AND `applicable_for`=%s - AND `allow`=%s - AND `for_value`=%s - """,(user, applied, doctype, docname)) + frappe.db.delete("User Permission", { + "applicable_for": applied, + "for_value": docname, + "allow": doctype, + "user": user, + }) diff --git a/frappe/core/doctype/user_select_document_type/user_select_document_type.py b/frappe/core/doctype/user_select_document_type/user_select_document_type.py index 13e3f0d351..18a21931e5 100644 --- a/frappe/core/doctype/user_select_document_type/user_select_document_type.py +++ b/frappe/core/doctype/user_select_document_type/user_select_document_type.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2021, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/user_social_login/user_social_login.py b/frappe/core/doctype/user_social_login/user_social_login.py index 4a34006d2b..80c0c89383 100644 --- a/frappe/core/doctype/user_social_login/user_social_login.py +++ b/frappe/core/doctype/user_social_login/user_social_login.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE from frappe.model.document import Document diff --git a/frappe/core/doctype/user_type/test_user_type.py b/frappe/core/doctype/user_type/test_user_type.py index 1c47f02bbb..7080e1830b 100644 --- a/frappe/core/doctype/user_type/test_user_type.py +++ b/frappe/core/doctype/user_type/test_user_type.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2021, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE # import frappe import unittest diff --git a/frappe/core/doctype/user_type/user_type.py b/frappe/core/doctype/user_type/user_type.py index 82ffb090f1..c1fd678141 100644 --- a/frappe/core/doctype/user_type/user_type.py +++ b/frappe/core/doctype/user_type/user_type.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2021, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe import _ @@ -36,8 +36,11 @@ class UserType(Document): if not self.user_doctypes: return - modules = frappe.get_all('DocType', fields=['distinct module as module'], - filters={'name': ('in', [d.document_type for d in self.user_doctypes])}) + modules = frappe.get_all("DocType", + fields=["module"], + filters={"name": ("in", [d.document_type for d in self.user_doctypes])}, + distinct=True, + ) self.set('user_type_modules', []) for row in modules: @@ -192,7 +195,7 @@ def get_user_linked_doctypes(doctype, txt, searchfield, start, page_len, filters ['DocType', 'read_only', '=', 0], ['DocType', 'name', 'like', '%{0}%'.format(txt)]] doctypes = frappe.get_all('DocType', fields = ['`tabDocType`.`name`'], filters=filters, - order_by = '`tabDocType`.`idx` desc', limit_start=start, limit_page_length=page_len, as_list=1, debug=1) + order_by = '`tabDocType`.`idx` desc', limit_start=start, limit_page_length=page_len, as_list=1) custom_dt_filters = [['Custom Field', 'dt', 'like', '%{0}%'.format(txt)], ['Custom Field', 'options', '=', 'User'], ['Custom Field', 'fieldtype', '=', 'Link']] diff --git a/frappe/core/doctype/user_type_module/user_type_module.py b/frappe/core/doctype/user_type_module/user_type_module.py index 9afbcd294d..d25479f869 100644 --- a/frappe/core/doctype/user_type_module/user_type_module.py +++ b/frappe/core/doctype/user_type_module/user_type_module.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2021, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/version/test_version.py b/frappe/core/doctype/version/test_version.py index f6c099c4ea..608dc9f0ab 100644 --- a/frappe/core/doctype/version/test_version.py +++ b/frappe/core/doctype/version/test_version.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest, copy from frappe.test_runner import make_test_objects diff --git a/frappe/core/doctype/version/version.css b/frappe/core/doctype/version/version.css deleted file mode 100644 index 769b352585..0000000000 --- a/frappe/core/doctype/version/version.css +++ /dev/null @@ -1,21 +0,0 @@ -.version-info { - overflow: auto; -} - -.version-info pre { - border: 0px; - margin: 0px; - background-color: inherit; -} - -.version-info .table { - background-color: inherit; -} - -.version-info .success { - background-color: #dff0d8 !important; -} - -.version-info .danger { - background-color: #f2dede !important; -} diff --git a/frappe/core/doctype/version/version.py b/frappe/core/doctype/version/version.py index a1bd851346..fcb558650a 100644 --- a/frappe/core/doctype/version/version.py +++ b/frappe/core/doctype/version/version.py @@ -1,7 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt - -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe, json diff --git a/frappe/core/doctype/view_log/test_view_log.py b/frappe/core/doctype/view_log/test_view_log.py index 025f3d8ad9..efa9538fbf 100644 --- a/frappe/core/doctype/view_log/test_view_log.py +++ b/frappe/core/doctype/view_log/test_view_log.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2018, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/core/doctype/view_log/view_log.json b/frappe/core/doctype/view_log/view_log.json index 6c3247c58f..3c4486c944 100644 --- a/frappe/core/doctype/view_log/view_log.json +++ b/frappe/core/doctype/view_log/view_log.json @@ -125,7 +125,7 @@ "issingle": 0, "istable": 0, "max_attachments": 0, - "modified": "2019-09-05 14:22:27.664645", + "modified": "2021-10-25 14:22:27.664645", "modified_by": "Administrator", "module": "Core", "name": "View Log", @@ -158,7 +158,6 @@ "show_name_in_global_search": 0, "sort_field": "modified", "sort_order": "DESC", - "track_changes": 1, "track_seen": 0, "track_views": 0 -} \ No newline at end of file +} diff --git a/frappe/core/doctype/view_log/view_log.py b/frappe/core/doctype/view_log/view_log.py index 242250be8b..fbbd6e1154 100644 --- a/frappe/core/doctype/view_log/view_log.py +++ b/frappe/core/doctype/view_log/view_log.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2018, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/core/notifications.py b/frappe/core/notifications.py index 707de43f28..b43d424df5 100644 --- a/frappe/core/notifications.py +++ b/frappe/core/notifications.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe diff --git a/frappe/core/page/__init__.py b/frappe/core/page/__init__.py index 0e57cb68c3..eb5ba62e5c 100644 --- a/frappe/core/page/__init__.py +++ b/frappe/core/page/__init__.py @@ -1,3 +1,3 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE diff --git a/frappe/core/page/background_jobs/background_jobs.py b/frappe/core/page/background_jobs/background_jobs.py index 847b23bd3e..4d9deca526 100644 --- a/frappe/core/page/background_jobs/background_jobs.py +++ b/frappe/core/page/background_jobs/background_jobs.py @@ -1,15 +1,15 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import json from typing import TYPE_CHECKING, Dict, List -from rq import Queue, Worker +from rq import Worker import frappe from frappe import _ from frappe.utils import convert_utc_to_user_timezone, format_datetime -from frappe.utils.background_jobs import get_redis_conn +from frappe.utils.background_jobs import get_redis_conn, get_queues from frappe.utils.scheduler import is_scheduler_inactive if TYPE_CHECKING: @@ -29,7 +29,7 @@ def get_info(show_failed=False) -> List[Dict]: show_failed = json.loads(show_failed) conn = get_redis_conn() - queues = Queue.all(conn) + queues = get_queues() workers = Worker.all(conn) jobs = [] @@ -67,7 +67,8 @@ def get_info(show_failed=False) -> List[Dict]: fail_registry = queue.failed_job_registry for job_id in fail_registry.get_job_ids(): job = queue.fetch_job(job_id) - add_job(job, queue.name) + if job: + add_job(job, queue.name) return jobs @@ -75,7 +76,7 @@ def get_info(show_failed=False) -> List[Dict]: @frappe.whitelist() def remove_failed_jobs(): conn = get_redis_conn() - queues = Queue.all(conn) + queues = get_queues() for queue in queues: fail_registry = queue.failed_job_registry for job_id in fail_registry.get_job_ids(): diff --git a/frappe/core/page/permission_manager/__init__.py b/frappe/core/page/permission_manager/__init__.py index 0e57cb68c3..eb5ba62e5c 100644 --- a/frappe/core/page/permission_manager/__init__.py +++ b/frappe/core/page/permission_manager/__init__.py @@ -1,3 +1,3 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE diff --git a/frappe/core/page/permission_manager/permission_manager.js b/frappe/core/page/permission_manager/permission_manager.js index 41cc900a97..6b427fdebf 100644 --- a/frappe/core/page/permission_manager/permission_manager.js +++ b/frappe/core/page/permission_manager/permission_manager.js @@ -325,15 +325,15 @@ frappe.PermissionEngine = class PermissionEngine { .attr("data-doctype", d.parent) .attr("data-role", d.role) .attr("data-permlevel", d.permlevel) - .click(function () { + .on("click", () => { return frappe.call({ module: "frappe.core", page: "permission_manager", method: "remove", args: { - doctype: $(this).attr("data-doctype"), - role: $(this).attr("data-role"), - permlevel: $(this).attr("data-permlevel") + doctype: d.parent, + role: d.role, + permlevel: d.permlevel }, callback: (r) => { if (r.exc) { diff --git a/frappe/core/page/permission_manager/permission_manager.py b/frappe/core/page/permission_manager/permission_manager.py index 15c7cb55ae..08642c599e 100644 --- a/frappe/core/page/permission_manager/permission_manager.py +++ b/frappe/core/page/permission_manager/permission_manager.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe from frappe import _ @@ -92,14 +92,14 @@ 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 + 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 + str: Refresh flag is permission is updated successfully """ frappe.only_for("System Manager") out = update_permission_property(doctype, role, permlevel, ptype, value) @@ -110,10 +110,9 @@ def remove(doctype, role, permlevel): frappe.only_for("System Manager") setup_custom_perms(doctype) - name = frappe.get_value('Custom DocPerm', dict(parent=doctype, role=role, permlevel=permlevel)) + frappe.db.delete("Custom DocPerm", {"parent": doctype, "role": role, "permlevel": permlevel}) - frappe.db.sql('delete from `tabCustom DocPerm` where name=%s', name) - if not frappe.get_all('Custom DocPerm', dict(parent=doctype)): + if not frappe.get_all('Custom DocPerm', {"parent": doctype}): frappe.throw(_('There must be atleast one permission rule.'), title=_('Cannot Remove')) validate_permissions_for_doctype(doctype, for_remove=True, alert=True) diff --git a/frappe/core/report/__init__.py b/frappe/core/report/__init__.py index 0e57cb68c3..eb5ba62e5c 100644 --- a/frappe/core/report/__init__.py +++ b/frappe/core/report/__init__.py @@ -1,3 +1,3 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE 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 13602ca777..535d354250 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 @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe from frappe import _, throw diff --git a/frappe/core/report/transaction_log_report/transaction_log_report.py b/frappe/core/report/transaction_log_report/transaction_log_report.py index ff8d8345d6..e9c68cb0c7 100644 --- a/frappe/core/report/transaction_log_report/transaction_log_report.py +++ b/frappe/core/report/transaction_log_report/transaction_log_report.py @@ -1,5 +1,5 @@ -# Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# Copyright (c) 2021, Frappe Technologies and contributors +# License: MIT. See LICENSE import frappe import hashlib @@ -12,13 +12,17 @@ def execute(filters=None): return columns, data def get_data(filters=None): - - logs = frappe.db.sql("SELECT * FROM `tabTransaction Log` order by creation desc ", as_dict=1) result = [] + logs = frappe.get_all("Transaction Log", fields=["*"], order_by="creation desc") + for l in logs: row_index = int(l.row_index) if row_index > 1: - previous_hash = frappe.db.sql("SELECT chaining_hash FROM `tabTransaction Log` WHERE row_index = {0}".format(row_index - 1)) + previous_hash = frappe.get_all( + "Transaction Log", + fields=["chaining_hash"], + filters={"row_index": row_index - 1}, + ) if not previous_hash: integrity = False else: diff --git a/frappe/core/utils.py b/frappe/core/utils.py index 9b8ee3a326..d4690cae89 100644 --- a/frappe/core/utils.py +++ b/frappe/core/utils.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe diff --git a/frappe/core/workspace/build/build.json b/frappe/core/workspace/build/build.json index aefda698b1..aabb4f9d1c 100644 --- a/frappe/core/workspace/build/build.json +++ b/frappe/core/workspace/build/build.json @@ -1,24 +1,20 @@ { - "cards_label": "Elements", - "category": "Modules", "charts": [], + "content": "[{\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\",\"level\":4,\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"DocType\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Workspace\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Report\",\"col\":4}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Elements\",\"level\":4,\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Modules\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Models\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Views\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Scripting\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Packages\",\"col\":4}}]", "creation": "2021-01-02 10:51:16.579957", - "developer_mode_only": 0, - "disable_user_customization": 0, "docstatus": 0, "doctype": "Workspace", - "extends_another_page": 0, + "for_user": "", "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_count": 0, "link_type": "DocType", "onboard": 0, "only_for": "", @@ -28,6 +24,7 @@ "hidden": 0, "is_query_report": 0, "label": "Module Def", + "link_count": 0, "link_to": "Module Def", "link_type": "DocType", "onboard": 0, @@ -38,6 +35,7 @@ "hidden": 0, "is_query_report": 0, "label": "Workspace", + "link_count": 0, "link_to": "Workspace", "link_type": "DocType", "onboard": 0, @@ -48,6 +46,7 @@ "hidden": 0, "is_query_report": 0, "label": "Module Onboarding", + "link_count": 0, "link_to": "Module Onboarding", "link_type": "DocType", "onboard": 0, @@ -58,6 +57,7 @@ "hidden": 0, "is_query_report": 0, "label": "Block Module", + "link_count": 0, "link_to": "Block Module", "link_type": "DocType", "onboard": 0, @@ -68,6 +68,7 @@ "hidden": 0, "is_query_report": 0, "label": "Models", + "link_count": 0, "link_type": "DocType", "onboard": 0, "only_for": "", @@ -77,6 +78,7 @@ "hidden": 0, "is_query_report": 0, "label": "DocType", + "link_count": 0, "link_to": "DocType", "link_type": "DocType", "onboard": 0, @@ -87,6 +89,7 @@ "hidden": 0, "is_query_report": 0, "label": "Workflow", + "link_count": 0, "link_to": "Workflow", "link_type": "DocType", "onboard": 0, @@ -97,6 +100,7 @@ "hidden": 0, "is_query_report": 0, "label": "Views", + "link_count": 0, "link_type": "DocType", "onboard": 0, "only_for": "", @@ -106,6 +110,7 @@ "hidden": 0, "is_query_report": 0, "label": "Report", + "link_count": 0, "link_to": "Report", "link_type": "DocType", "onboard": 0, @@ -116,6 +121,7 @@ "hidden": 0, "is_query_report": 0, "label": "Print Format", + "link_count": 0, "link_to": "Print Format", "link_type": "DocType", "onboard": 0, @@ -126,6 +132,7 @@ "hidden": 0, "is_query_report": 0, "label": "Workspace", + "link_count": 0, "link_to": "Workspace", "link_type": "DocType", "onboard": 0, @@ -136,6 +143,7 @@ "hidden": 0, "is_query_report": 0, "label": "Dashboard", + "link_count": 0, "link_to": "Dashboard", "link_type": "DocType", "onboard": 0, @@ -146,6 +154,7 @@ "hidden": 0, "is_query_report": 0, "label": "Scripting", + "link_count": 0, "link_type": "DocType", "onboard": 0, "only_for": "", @@ -155,6 +164,7 @@ "hidden": 0, "is_query_report": 0, "label": "Server Script", + "link_count": 0, "link_to": "Server Script", "link_type": "DocType", "onboard": 0, @@ -165,6 +175,7 @@ "hidden": 0, "is_query_report": 0, "label": "Client Script", + "link_count": 0, "link_to": "Client Script", "link_type": "DocType", "onboard": 0, @@ -175,20 +186,52 @@ "hidden": 0, "is_query_report": 0, "label": "Scheduled Job Type", + "link_count": 0, "link_to": "Scheduled Job Type", "link_type": "DocType", "onboard": 0, "only_for": "", "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Packages", + "link_count": 2, + "onboard": 0, + "type": "Card Break" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Package", + "link_count": 0, + "link_to": "Package", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Package Import", + "link_count": 0, + "link_to": "Package Import", + "link_type": "DocType", + "onboard": 0, + "type": "Link" } ], - "modified": "2021-02-04 13:48:48.493146", + "modified": "2021-09-05 21:14:52.384816", "modified_by": "Administrator", "module": "Core", "name": "Build", "owner": "Administrator", - "pin_to_bottom": 0, - "pin_to_top": 0, + "parent_page": "", + "public": 1, + "restrict_to_domain": "", + "roles": [], + "sequence_id": 5, "shortcuts": [ { "doc_view": "", @@ -208,5 +251,6 @@ "link_to": "Report", "type": "DocType" } - ] + ], + "title": "Build" } \ No newline at end of file diff --git a/frappe/core/workspace/settings/settings.json b/frappe/core/workspace/settings/settings.json index fb26b73cfc..917ce2cbdc 100644 --- a/frappe/core/workspace/settings/settings.json +++ b/frappe/core/workspace/settings/settings.json @@ -1,22 +1,20 @@ { - "category": "Modules", "charts": [], + "content": "[{\"type\":\"header\",\"data\": {\"text\":\"Settings\",\"level\": 4,\"col\": 12}}, {\"type\":\"shortcut\",\"data\": {\"shortcut_name\":\"System Settings\",\"col\": 4}}, {\"type\":\"shortcut\",\"data\": {\"shortcut_name\":\"Print Settings\",\"col\": 4}}, {\"type\":\"shortcut\",\"data\": {\"shortcut_name\":\"Website Settings\",\"col\": 4}}, {\"type\":\"spacer\",\"data\": {\"col\": 12}}, {\"type\":\"header\",\"data\": {\"text\":\"Reports & Masters\",\"level\": 4,\"col\": 12}}, {\"type\":\"card\",\"data\": {\"card_name\":\"Data\",\"col\": 4}}, {\"type\":\"card\",\"data\": {\"card_name\":\"Email / Notifications\",\"col\": 4}}, {\"type\":\"card\",\"data\": {\"card_name\":\"Website\",\"col\": 4}}, {\"type\":\"card\",\"data\": {\"card_name\":\"Core\",\"col\": 4}}, {\"type\":\"card\",\"data\": {\"card_name\":\"Printing\",\"col\": 4}}, {\"type\":\"card\",\"data\": {\"card_name\":\"Workflow\",\"col\": 4}}]", "creation": "2020-03-02 15:09:40.527211", - "developer_mode_only": 0, - "disable_user_customization": 1, "docstatus": 0, "doctype": "Workspace", - "extends_another_page": 0, + "for_user": "", "hide_custom": 0, "icon": "setting", "idx": 0, - "is_standard": 1, "label": "Settings", "links": [ { "hidden": 0, "is_query_report": 0, "label": "Data", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -25,6 +23,7 @@ "hidden": 0, "is_query_report": 0, "label": "Import Data", + "link_count": 0, "link_to": "Data Import", "link_type": "DocType", "onboard": 0, @@ -35,6 +34,7 @@ "hidden": 0, "is_query_report": 0, "label": "Export Data", + "link_count": 0, "link_to": "Data Export", "link_type": "DocType", "onboard": 0, @@ -45,6 +45,7 @@ "hidden": 0, "is_query_report": 0, "label": "Bulk Update", + "link_count": 0, "link_to": "Bulk Update", "link_type": "DocType", "onboard": 0, @@ -55,6 +56,7 @@ "hidden": 0, "is_query_report": 0, "label": "Download Backups", + "link_count": 0, "link_to": "backups", "link_type": "Page", "onboard": 0, @@ -65,6 +67,7 @@ "hidden": 0, "is_query_report": 0, "label": "Deleted Documents", + "link_count": 0, "link_to": "Deleted Document", "link_type": "DocType", "onboard": 0, @@ -74,6 +77,7 @@ "hidden": 0, "is_query_report": 0, "label": "Email / Notifications", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -82,6 +86,7 @@ "hidden": 0, "is_query_report": 0, "label": "Email Account", + "link_count": 0, "link_to": "Email Account", "link_type": "DocType", "onboard": 0, @@ -92,6 +97,7 @@ "hidden": 0, "is_query_report": 0, "label": "Email Domain", + "link_count": 0, "link_to": "Email Domain", "link_type": "DocType", "onboard": 0, @@ -102,6 +108,7 @@ "hidden": 0, "is_query_report": 0, "label": "Notification", + "link_count": 0, "link_to": "Notification", "link_type": "DocType", "onboard": 0, @@ -112,6 +119,7 @@ "hidden": 0, "is_query_report": 0, "label": "Email Template", + "link_count": 0, "link_to": "Email Template", "link_type": "DocType", "onboard": 0, @@ -122,6 +130,7 @@ "hidden": 0, "is_query_report": 0, "label": "Auto Email Report", + "link_count": 0, "link_to": "Auto Email Report", "link_type": "DocType", "onboard": 0, @@ -132,6 +141,7 @@ "hidden": 0, "is_query_report": 0, "label": "Newsletter", + "link_count": 0, "link_to": "Newsletter", "link_type": "DocType", "onboard": 0, @@ -142,6 +152,7 @@ "hidden": 0, "is_query_report": 0, "label": "Notification Settings", + "link_count": 0, "link_to": "Notification Settings", "link_type": "DocType", "onboard": 0, @@ -151,6 +162,7 @@ "hidden": 0, "is_query_report": 0, "label": "Website", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -159,6 +171,7 @@ "hidden": 0, "is_query_report": 0, "label": "Website Settings", + "link_count": 0, "link_to": "Website Settings", "link_type": "DocType", "onboard": 1, @@ -169,6 +182,7 @@ "hidden": 0, "is_query_report": 0, "label": "Website Theme", + "link_count": 0, "link_to": "Website Theme", "link_type": "DocType", "onboard": 1, @@ -179,6 +193,7 @@ "hidden": 0, "is_query_report": 0, "label": "Website Script", + "link_count": 0, "link_to": "Website Script", "link_type": "DocType", "onboard": 0, @@ -189,6 +204,7 @@ "hidden": 0, "is_query_report": 0, "label": "About Us Settings", + "link_count": 0, "link_to": "About Us Settings", "link_type": "DocType", "onboard": 0, @@ -199,6 +215,7 @@ "hidden": 0, "is_query_report": 0, "label": "Contact Us Settings", + "link_count": 0, "link_to": "Contact Us Settings", "link_type": "DocType", "onboard": 0, @@ -208,6 +225,7 @@ "hidden": 0, "is_query_report": 0, "label": "Core", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -216,6 +234,7 @@ "hidden": 0, "is_query_report": 0, "label": "System Settings", + "link_count": 0, "link_to": "System Settings", "link_type": "DocType", "onboard": 0, @@ -226,6 +245,7 @@ "hidden": 0, "is_query_report": 0, "label": "Error Log", + "link_count": 0, "link_to": "Error Log", "link_type": "DocType", "onboard": 0, @@ -236,6 +256,7 @@ "hidden": 0, "is_query_report": 0, "label": "Error Snapshot", + "link_count": 0, "link_to": "Error Snapshot", "link_type": "DocType", "onboard": 0, @@ -246,6 +267,7 @@ "hidden": 0, "is_query_report": 0, "label": "Domain Settings", + "link_count": 0, "link_to": "Domain Settings", "link_type": "DocType", "onboard": 0, @@ -255,6 +277,7 @@ "hidden": 0, "is_query_report": 0, "label": "Printing", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -263,6 +286,7 @@ "hidden": 0, "is_query_report": 0, "label": "Print Format Builder", + "link_count": 0, "link_to": "print-format-builder", "link_type": "Page", "onboard": 0, @@ -273,6 +297,7 @@ "hidden": 0, "is_query_report": 0, "label": "Print Settings", + "link_count": 0, "link_to": "Print Settings", "link_type": "DocType", "onboard": 0, @@ -283,6 +308,7 @@ "hidden": 0, "is_query_report": 0, "label": "Print Format", + "link_count": 0, "link_to": "Print Format", "link_type": "DocType", "onboard": 0, @@ -293,6 +319,7 @@ "hidden": 0, "is_query_report": 0, "label": "Print Style", + "link_count": 0, "link_to": "Print Style", "link_type": "DocType", "onboard": 0, @@ -302,6 +329,7 @@ "hidden": 0, "is_query_report": 0, "label": "Workflow", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -310,6 +338,7 @@ "hidden": 0, "is_query_report": 0, "label": "Workflow", + "link_count": 0, "link_to": "Workflow", "link_type": "DocType", "onboard": 0, @@ -320,6 +349,7 @@ "hidden": 0, "is_query_report": 0, "label": "Workflow State", + "link_count": 0, "link_to": "Workflow State", "link_type": "DocType", "onboard": 0, @@ -330,19 +360,23 @@ "hidden": 0, "is_query_report": 0, "label": "Workflow Action", + "link_count": 0, "link_to": "Workflow Action", "link_type": "DocType", "onboard": 0, "type": "Link" } ], - "modified": "2020-12-01 13:38:40.235323", + "modified": "2021-08-05 12:16:03.456174", "modified_by": "Administrator", "module": "Core", "name": "Settings", "owner": "Administrator", - "pin_to_bottom": 1, - "pin_to_top": 0, + "parent_page": "", + "public": 1, + "restrict_to_domain": "", + "roles": [], + "sequence_id": 29, "shortcuts": [ { "icon": "setting", @@ -363,5 +397,5 @@ "type": "DocType" } ], - "shortcuts_label": "Settings" + "title": "Settings" } \ No newline at end of file diff --git a/frappe/core/workspace/users/users.json b/frappe/core/workspace/users/users.json index ba82461b57..85c110151b 100644 --- a/frappe/core/workspace/users/users.json +++ b/frappe/core/workspace/users/users.json @@ -1,23 +1,20 @@ { - "category": "Administration", "charts": [], + "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Your Shortcuts\", \"level\": 4, \"col\": 12}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"User\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Role\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Permission Manager\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"User Profile\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"User Type\", \"col\": 4}}, {\"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Users\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Logs\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Permissions\", \"col\": 4}}]", "creation": "2020-03-02 15:12:16.754449", - "developer_mode_only": 0, - "disable_user_customization": 0, "docstatus": 0, "doctype": "Workspace", - "extends_another_page": 0, + "for_user": "", "hide_custom": 0, "icon": "users", "idx": 0, - "is_default": 0, - "is_standard": 1, "label": "Users", "links": [ { "hidden": 0, "is_query_report": 0, "label": "Users", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -26,6 +23,7 @@ "hidden": 0, "is_query_report": 0, "label": "User", + "link_count": 0, "link_to": "User", "link_type": "DocType", "onboard": 0, @@ -36,6 +34,7 @@ "hidden": 0, "is_query_report": 0, "label": "Role", + "link_count": 0, "link_to": "Role", "link_type": "DocType", "onboard": 0, @@ -46,6 +45,7 @@ "hidden": 0, "is_query_report": 0, "label": "Role Profile", + "link_count": 0, "link_to": "Role Profile", "link_type": "DocType", "onboard": 0, @@ -55,6 +55,7 @@ "hidden": 0, "is_query_report": 0, "label": "Logs", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -63,6 +64,7 @@ "hidden": 0, "is_query_report": 0, "label": "Activity Log", + "link_count": 0, "link_to": "Activity Log", "link_type": "DocType", "onboard": 0, @@ -73,6 +75,7 @@ "hidden": 0, "is_query_report": 0, "label": "Access Log", + "link_count": 0, "link_to": "Access Log", "link_type": "DocType", "onboard": 0, @@ -82,6 +85,7 @@ "hidden": 0, "is_query_report": 0, "label": "Permissions", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -90,6 +94,7 @@ "hidden": 0, "is_query_report": 0, "label": "Role Permissions Manager", + "link_count": 0, "link_to": "permission-manager", "link_type": "Page", "onboard": 0, @@ -100,6 +105,7 @@ "hidden": 0, "is_query_report": 0, "label": "User Permissions", + "link_count": 0, "link_to": "User Permission", "link_type": "DocType", "onboard": 0, @@ -110,6 +116,7 @@ "hidden": 0, "is_query_report": 0, "label": "Role Permission for Page and Report", + "link_count": 0, "link_to": "Role Permission for Page and Report", "link_type": "DocType", "onboard": 0, @@ -120,6 +127,7 @@ "hidden": 0, "is_query_report": 1, "label": "Permitted Documents For User", + "link_count": 0, "link_to": "Permitted Documents For User", "link_type": "Report", "onboard": 0, @@ -130,19 +138,23 @@ "hidden": 0, "is_query_report": 0, "label": "Document Share Report", + "link_count": 0, "link_to": "Document Share Report", "link_type": "Report", "onboard": 0, "type": "Link" } ], - "modified": "2021-03-25 23:02:34.582569", + "modified": "2021-08-05 12:16:03.010205", "modified_by": "Administrator", "module": "Core", "name": "Users", "owner": "Administrator", - "pin_to_bottom": 0, - "pin_to_top": 0, + "parent_page": "", + "public": 1, + "restrict_to_domain": "", + "roles": [], + "sequence_id": 27, "shortcuts": [ { "label": "User", @@ -170,5 +182,6 @@ "link_to": "User Type", "type": "DocType" } - ] + ], + "title": "Users" } \ No newline at end of file diff --git a/frappe/coverage.py b/frappe/coverage.py new file mode 100644 index 0000000000..1969cae141 --- /dev/null +++ b/frappe/coverage.py @@ -0,0 +1,62 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See LICENSE +""" + frappe.coverage + ~~~~~~~~~~~~~~~~ + + Coverage settings for frappe +""" + +STANDARD_INCLUSIONS = ["*.py"] + +STANDARD_EXCLUSIONS = [ + '*.js', + '*.xml', + '*.pyc', + '*.css', + '*.less', + '*.scss', + '*.vue', + '*.html', + '*/test_*', + '*/node_modules/*', + '*/doctype/*/*_dashboard.py', + '*/patches/*', +] + +FRAPPE_EXCLUSIONS = [ + "*/tests/*", + "*/commands/*", + "*/frappe/change_log/*", + "*/frappe/exceptions*", + "*frappe/setup.py", + "*/doctype/*/*_dashboard.py", + "*/patches/*", +] + +class CodeCoverage(): + def __init__(self, with_coverage, app): + self.with_coverage = with_coverage + self.app = app or 'frappe' + + def __enter__(self): + if self.with_coverage: + import os + from coverage import Coverage + from frappe.utils import get_bench_path + + # Generate coverage report only for app that is being tested + source_path = os.path.join(get_bench_path(), 'apps', self.app) + omit = STANDARD_EXCLUSIONS[:] + + if self.app == 'frappe': + omit.extend(FRAPPE_EXCLUSIONS) + + self.coverage = Coverage(source=[source_path], omit=omit, include=STANDARD_INCLUSIONS) + self.coverage.start() + + def __exit__(self, exc_type, exc_value, traceback): + if self.with_coverage: + self.coverage.stop() + self.coverage.save() + self.coverage.xml_report() \ No newline at end of file diff --git a/frappe/custom/doctype/client_script/__init__.py b/frappe/custom/doctype/client_script/__init__.py index 0e57cb68c3..eb5ba62e5c 100644 --- a/frappe/custom/doctype/client_script/__init__.py +++ b/frappe/custom/doctype/client_script/__init__.py @@ -1,3 +1,3 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE diff --git a/frappe/custom/doctype/client_script/client_script.json b/frappe/custom/doctype/client_script/client_script.json index db02d8d4bc..50f6bf3cc4 100644 --- a/frappe/custom/doctype/client_script/client_script.json +++ b/frappe/custom/doctype/client_script/client_script.json @@ -9,7 +9,10 @@ "field_order": [ "dt", "view", + "column_break_3", + "module", "enabled", + "section_break_6", "script", "sample" ], @@ -53,13 +56,27 @@ "label": "Apply To", "options": "List\nForm", "set_only_once": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fieldname": "module", + "fieldtype": "Link", + "label": "Module (for export)", + "options": "Module Def" + }, + { + "fieldname": "section_break_6", + "fieldtype": "Section Break" } ], "icon": "fa fa-glass", "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2021-03-16 20:33:51.400191", + "modified": "2021-09-04 12:03:27.029815", "modified_by": "Administrator", "module": "Custom", "name": "Client Script", diff --git a/frappe/custom/doctype/client_script/client_script.py b/frappe/custom/doctype/client_script/client_script.py index 9c098fe8c9..fd6bc9accd 100644 --- a/frappe/custom/doctype/client_script/client_script.py +++ b/frappe/custom/doctype/client_script/client_script.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe from frappe import _ diff --git a/frappe/custom/doctype/client_script/test_client_script.py b/frappe/custom/doctype/client_script/test_client_script.py index b8358468b9..4887956001 100644 --- a/frappe/custom/doctype/client_script/test_client_script.py +++ b/frappe/custom/doctype/client_script/test_client_script.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/custom/doctype/custom_field/__init__.py b/frappe/custom/doctype/custom_field/__init__.py index 0e57cb68c3..eb5ba62e5c 100644 --- a/frappe/custom/doctype/custom_field/__init__.py +++ b/frappe/custom/doctype/custom_field/__init__.py @@ -1,3 +1,3 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE diff --git a/frappe/custom/doctype/custom_field/custom_field.json b/frappe/custom/doctype/custom_field/custom_field.json index 2f0819ab68..235f11aad8 100644 --- a/frappe/custom/doctype/custom_field/custom_field.json +++ b/frappe/custom/doctype/custom_field/custom_field.json @@ -1,453 +1,458 @@ { - "actions": [], - "allow_import": 1, - "creation": "2013-01-10 16:34:01", - "description": "Adds a custom field to a DocType", - "doctype": "DocType", - "document_type": "Setup", - "engine": "InnoDB", - "field_order": [ - "dt", - "label", - "label_help", - "fieldname", - "insert_after", - "length", - "column_break_6", - "fieldtype", - "precision", - "hide_seconds", - "hide_days", - "options", - "fetch_from", - "fetch_if_empty", - "options_help", - "section_break_11", - "collapsible", - "collapsible_depends_on", - "default", - "depends_on", - "mandatory_depends_on", - "read_only_depends_on", - "properties", - "non_negative", - "reqd", - "unique", - "read_only", - "ignore_user_permissions", - "hidden", - "print_hide", - "print_hide_if_no_value", - "print_width", - "no_copy", - "allow_on_submit", - "in_list_view", - "in_standard_filter", - "in_global_search", - "in_preview", - "bold", - "report_hide", - "search_index", - "allow_in_quick_entry", - "ignore_xss_filter", - "translatable", - "hide_border", - "description", - "permlevel", - "width", - "columns" - ], - "fields": [ - { - "bold": 1, - "fieldname": "dt", - "fieldtype": "Link", - "in_filter": 1, - "in_list_view": 1, - "label": "Document", - "oldfieldname": "dt", - "oldfieldtype": "Link", - "options": "DocType", - "reqd": 1, - "search_index": 1 - }, - { - "bold": 1, - "fieldname": "label", - "fieldtype": "Data", - "in_filter": 1, - "label": "Label", - "no_copy": 1, - "oldfieldname": "label", - "oldfieldtype": "Data" - }, - { - "fieldname": "label_help", - "fieldtype": "HTML", - "label": "Label Help", - "oldfieldtype": "HTML" - }, - { - "fieldname": "fieldname", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Fieldname", - "no_copy": 1, - "oldfieldname": "fieldname", - "oldfieldtype": "Data", - "read_only": 1 - }, - { - "description": "Select the label after which you want to insert new field.", - "fieldname": "insert_after", - "fieldtype": "Select", - "label": "Insert After", - "no_copy": 1, - "oldfieldname": "insert_after", - "oldfieldtype": "Select" - }, - { - "fieldname": "column_break_6", - "fieldtype": "Column Break" - }, - { - "bold": 1, - "default": "Data", - "fieldname": "fieldtype", - "fieldtype": "Select", - "in_filter": 1, - "in_list_view": 1, - "label": "Field Type", - "oldfieldname": "fieldtype", - "oldfieldtype": "Select", - "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nGeolocation\nHTML\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRating\nRead Only\nSection Break\nSelect\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nSignature", - "reqd": 1 - }, - { - "depends_on": "eval:in_list([\"Float\", \"Currency\", \"Percent\"], doc.fieldtype)", - "description": "Set non-standard precision for a Float or Currency field", - "fieldname": "precision", - "fieldtype": "Select", - "label": "Precision", - "options": "\n0\n1\n2\n3\n4\n5\n6\n7\n8\n9" - }, - { - "fieldname": "options", - "fieldtype": "Small Text", - "in_list_view": 1, - "label": "Options", - "oldfieldname": "options", - "oldfieldtype": "Text" - }, - { - "fieldname": "fetch_from", - "fieldtype": "Small Text", - "label": "Fetch From" - }, - { - "default": "0", - "description": "If checked, this field will be not overwritten based on Fetch From if a value already exists.", - "fieldname": "fetch_if_empty", - "fieldtype": "Check", - "label": "Fetch If Empty" - }, - { - "fieldname": "options_help", - "fieldtype": "HTML", - "label": "Options Help", - "oldfieldtype": "HTML" - }, - { - "fieldname": "section_break_11", - "fieldtype": "Section Break" - }, - { - "default": "0", - "depends_on": "eval:doc.fieldtype==\"Section Break\"", - "fieldname": "collapsible", - "fieldtype": "Check", - "label": "Collapsible" - }, - { - "depends_on": "eval:doc.fieldtype==\"Section Break\"", - "fieldname": "collapsible_depends_on", - "fieldtype": "Code", - "label": "Collapsible Depends On" - }, - { - "fieldname": "default", - "fieldtype": "Text", - "label": "Default Value", - "oldfieldname": "default", - "oldfieldtype": "Text" - }, - { - "fieldname": "depends_on", - "fieldtype": "Code", - "label": "Depends On", - "length": 255 - }, - { - "fieldname": "description", - "fieldtype": "Text", - "label": "Field Description", - "oldfieldname": "description", - "oldfieldtype": "Text", - "print_width": "300px", - "width": "300px" - }, - { - "default": "0", - "fieldname": "permlevel", - "fieldtype": "Int", - "label": "Permission Level", - "oldfieldname": "permlevel", - "oldfieldtype": "Int" - }, - { - "fieldname": "width", - "fieldtype": "Data", - "label": "Width", - "oldfieldname": "width", - "oldfieldtype": "Data" - }, - { - "description": "Number of columns for a field in a List View or a Grid (Total Columns should be less than 11)", - "fieldname": "columns", - "fieldtype": "Int", - "label": "Columns" - }, - { - "fieldname": "properties", - "fieldtype": "Column Break", - "oldfieldtype": "Column Break", - "print_width": "50%", - "width": "50%" - }, - { - "default": "0", - "fieldname": "reqd", - "fieldtype": "Check", - "in_list_view": 1, - "label": "Is Mandatory Field", - "oldfieldname": "reqd", - "oldfieldtype": "Check" - }, - { - "default": "0", - "fieldname": "unique", - "fieldtype": "Check", - "label": "Unique" - }, - { - "default": "0", - "fieldname": "read_only", - "fieldtype": "Check", - "label": "Read Only" - }, - { - "default": "0", - "depends_on": "eval:doc.fieldtype===\"Link\"", - "fieldname": "ignore_user_permissions", - "fieldtype": "Check", - "label": "Ignore User Permissions" - }, - { - "default": "0", - "fieldname": "hidden", - "fieldtype": "Check", - "label": "Hidden" - }, - { - "default": "0", - "fieldname": "print_hide", - "fieldtype": "Check", - "label": "Print Hide", - "oldfieldname": "print_hide", - "oldfieldtype": "Check" - }, - { - "default": "0", - "depends_on": "eval:[\"Int\", \"Float\", \"Currency\", \"Percent\"].indexOf(doc.fieldtype)!==-1", - "fieldname": "print_hide_if_no_value", - "fieldtype": "Check", - "label": "Print Hide If No Value" - }, - { - "fieldname": "print_width", - "fieldtype": "Data", - "hidden": 1, - "label": "Print Width", - "no_copy": 1, - "print_hide": 1 - }, - { - "default": "0", - "fieldname": "no_copy", - "fieldtype": "Check", - "label": "No Copy", - "oldfieldname": "no_copy", - "oldfieldtype": "Check" - }, - { - "default": "0", - "fieldname": "allow_on_submit", - "fieldtype": "Check", - "label": "Allow on Submit", - "oldfieldname": "allow_on_submit", - "oldfieldtype": "Check" - }, - { - "default": "0", - "fieldname": "in_list_view", - "fieldtype": "Check", - "label": "In List View" - }, - { - "default": "0", - "fieldname": "in_standard_filter", - "fieldtype": "Check", - "label": "In Standard Filter" - }, - { - "default": "0", - "depends_on": "eval:([\"Data\", \"Select\", \"Table\", \"Text\", \"Text Editor\", \"Link\", \"Small Text\", \"Long Text\", \"Read Only\", \"Heading\", \"Dynamic Link\"].indexOf(doc.fieldtype) !== -1)", - "fieldname": "in_global_search", - "fieldtype": "Check", - "label": "In Global Search" - }, - { - "default": "0", - "fieldname": "bold", - "fieldtype": "Check", - "label": "Bold" - }, - { - "default": "0", - "fieldname": "report_hide", - "fieldtype": "Check", - "label": "Report Hide", - "oldfieldname": "report_hide", - "oldfieldtype": "Check" - }, - { - "default": "0", - "fieldname": "search_index", - "fieldtype": "Check", - "hidden": 1, - "label": "Index", - "no_copy": 1, - "print_hide": 1 - }, - { - "default": "0", - "description": "Don't HTML Encode HTML tags like <script> or just characters like < or >, as they could be intentionally used in this field", - "fieldname": "ignore_xss_filter", - "fieldtype": "Check", - "label": "Ignore XSS Filter" - }, - { - "default": "1", - "depends_on": "eval:['Data', 'Select', 'Text', 'Small Text', 'Text Editor'].includes(doc.fieldtype)", - "fieldname": "translatable", - "fieldtype": "Check", - "label": "Translatable" - }, - { - "depends_on": "eval:in_list(['Data', 'Link', 'Dynamic Link', 'Password', 'Select', 'Read Only', 'Attach', 'Attach Image', 'Int'], doc.fieldtype)", - "fieldname": "length", - "fieldtype": "Int", - "label": "Length" - }, - { - "fieldname": "mandatory_depends_on", - "fieldtype": "Code", - "label": "Mandatory Depends On", - "length": 255 - }, - { - "fieldname": "read_only_depends_on", - "fieldtype": "Code", - "label": "Read Only Depends On", - "length": 255 - }, - { - "default": "0", - "fieldname": "allow_in_quick_entry", - "fieldtype": "Check", - "label": "Allow in Quick Entry" - }, - { - "default": "0", - "depends_on": "eval:!in_list(['Table', 'Table MultiSelect'], doc.fieldtype);", - "fieldname": "in_preview", - "fieldtype": "Check", - "label": "In Preview" - }, - { - "default": "0", - "depends_on": "eval:doc.fieldtype=='Duration'", - "fieldname": "hide_seconds", - "fieldtype": "Check", - "label": "Hide Seconds" - }, - { - "default": "0", - "depends_on": "eval:doc.fieldtype=='Duration'", - "fieldname": "hide_days", - "fieldtype": "Check", - "label": "Hide Days" - }, - { - "default": "0", - "depends_on": "eval:doc.fieldtype=='Section Break'", - "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-10-29 06:14:43.073329", - "modified_by": "Administrator", - "module": "Custom", - "name": "Custom Field", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "Administrator", - "share": 1, - "write": 1 - }, - { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "search_fields": "dt,label,fieldtype,options", - "sort_field": "modified", - "sort_order": "ASC", - "track_changes": 1 + "actions": [], + "allow_import": 1, + "creation": "2013-01-10 16:34:01", + "description": "Adds a custom field to a DocType", + "doctype": "DocType", + "document_type": "Setup", + "engine": "InnoDB", + "field_order": [ + "dt", + "module", + "label", + "label_help", + "fieldname", + "insert_after", + "length", + "column_break_6", + "fieldtype", + "precision", + "hide_seconds", + "hide_days", + "options", + "fetch_from", + "fetch_if_empty", + "options_help", + "section_break_11", + "collapsible", + "collapsible_depends_on", + "default", + "depends_on", + "mandatory_depends_on", + "read_only_depends_on", + "properties", + "non_negative", + "reqd", + "unique", + "read_only", + "ignore_user_permissions", + "hidden", + "print_hide", + "print_hide_if_no_value", + "print_width", + "no_copy", + "allow_on_submit", + "in_list_view", + "in_standard_filter", + "in_global_search", + "in_preview", + "bold", + "report_hide", + "search_index", + "allow_in_quick_entry", + "ignore_xss_filter", + "translatable", + "hide_border", + "description", + "permlevel", + "width", + "columns" + ], + "fields": [{ + "bold": 1, + "fieldname": "dt", + "fieldtype": "Link", + "in_filter": 1, + "in_list_view": 1, + "label": "Document", + "oldfieldname": "dt", + "oldfieldtype": "Link", + "options": "DocType", + "reqd": 1, + "search_index": 1 + }, + { + "bold": 1, + "fieldname": "label", + "fieldtype": "Data", + "in_filter": 1, + "label": "Label", + "no_copy": 1, + "oldfieldname": "label", + "oldfieldtype": "Data" + }, + { + "fieldname": "label_help", + "fieldtype": "HTML", + "label": "Label Help", + "oldfieldtype": "HTML" + }, + { + "fieldname": "fieldname", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Fieldname", + "no_copy": 1, + "oldfieldname": "fieldname", + "oldfieldtype": "Data", + "read_only": 1 + }, + { + "description": "Select the label after which you want to insert new field.", + "fieldname": "insert_after", + "fieldtype": "Select", + "label": "Insert After", + "no_copy": 1, + "oldfieldname": "insert_after", + "oldfieldtype": "Select" + }, + { + "fieldname": "column_break_6", + "fieldtype": "Column Break" + }, + { + "bold": 1, + "default": "Data", + "fieldname": "fieldtype", + "fieldtype": "Select", + "in_filter": 1, + "in_list_view": 1, + "label": "Field Type", + "oldfieldname": "fieldtype", + "oldfieldtype": "Select", + "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRead Only\nRating\nSection Break\nSelect\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nSignature\nTab Break", + "reqd": 1 + }, + { + "depends_on": "eval:in_list([\"Float\", \"Currency\", \"Percent\"], doc.fieldtype)", + "description": "Set non-standard precision for a Float or Currency field", + "fieldname": "precision", + "fieldtype": "Select", + "label": "Precision", + "options": "\n0\n1\n2\n3\n4\n5\n6\n7\n8\n9" + }, + { + "fieldname": "options", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "Options", + "oldfieldname": "options", + "oldfieldtype": "Text" + }, + { + "fieldname": "fetch_from", + "fieldtype": "Small Text", + "label": "Fetch From" + }, + { + "default": "0", + "description": "If checked, this field will be not overwritten based on Fetch From if a value already exists.", + "fieldname": "fetch_if_empty", + "fieldtype": "Check", + "label": "Fetch If Empty" + }, + { + "fieldname": "options_help", + "fieldtype": "HTML", + "label": "Options Help", + "oldfieldtype": "HTML" + }, + { + "fieldname": "section_break_11", + "fieldtype": "Section Break" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype==\"Section Break\"", + "fieldname": "collapsible", + "fieldtype": "Check", + "label": "Collapsible" + }, + { + "depends_on": "eval:doc.fieldtype==\"Section Break\"", + "fieldname": "collapsible_depends_on", + "fieldtype": "Code", + "label": "Collapsible Depends On" + }, + { + "fieldname": "default", + "fieldtype": "Text", + "label": "Default Value", + "oldfieldname": "default", + "oldfieldtype": "Text" + }, + { + "fieldname": "depends_on", + "fieldtype": "Code", + "label": "Depends On", + "length": 255 + }, + { + "fieldname": "description", + "fieldtype": "Text", + "label": "Field Description", + "oldfieldname": "description", + "oldfieldtype": "Text", + "print_width": "300px", + "width": "300px" + }, + { + "default": "0", + "fieldname": "permlevel", + "fieldtype": "Int", + "label": "Permission Level", + "oldfieldname": "permlevel", + "oldfieldtype": "Int" + }, + { + "fieldname": "width", + "fieldtype": "Data", + "label": "Width", + "oldfieldname": "width", + "oldfieldtype": "Data" + }, + { + "description": "Number of columns for a field in a List View or a Grid (Total Columns should be less than 11)", + "fieldname": "columns", + "fieldtype": "Int", + "label": "Columns" + }, + { + "fieldname": "properties", + "fieldtype": "Column Break", + "oldfieldtype": "Column Break", + "print_width": "50%", + "width": "50%" + }, + { + "default": "0", + "fieldname": "reqd", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Is Mandatory Field", + "oldfieldname": "reqd", + "oldfieldtype": "Check" + }, + { + "default": "0", + "fieldname": "unique", + "fieldtype": "Check", + "label": "Unique" + }, + { + "default": "0", + "fieldname": "read_only", + "fieldtype": "Check", + "label": "Read Only" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype===\"Link\"", + "fieldname": "ignore_user_permissions", + "fieldtype": "Check", + "label": "Ignore User Permissions" + }, + { + "default": "0", + "fieldname": "hidden", + "fieldtype": "Check", + "label": "Hidden" + }, + { + "default": "0", + "fieldname": "print_hide", + "fieldtype": "Check", + "label": "Print Hide", + "oldfieldname": "print_hide", + "oldfieldtype": "Check" + }, + { + "default": "0", + "depends_on": "eval:[\"Int\", \"Float\", \"Currency\", \"Percent\"].indexOf(doc.fieldtype)!==-1", + "fieldname": "print_hide_if_no_value", + "fieldtype": "Check", + "label": "Print Hide If No Value" + }, + { + "fieldname": "print_width", + "fieldtype": "Data", + "hidden": 1, + "label": "Print Width", + "no_copy": 1, + "print_hide": 1 + }, + { + "default": "0", + "fieldname": "no_copy", + "fieldtype": "Check", + "label": "No Copy", + "oldfieldname": "no_copy", + "oldfieldtype": "Check" + }, + { + "default": "0", + "fieldname": "allow_on_submit", + "fieldtype": "Check", + "label": "Allow on Submit", + "oldfieldname": "allow_on_submit", + "oldfieldtype": "Check" + }, + { + "default": "0", + "fieldname": "in_list_view", + "fieldtype": "Check", + "label": "In List View" + }, + { + "default": "0", + "fieldname": "in_standard_filter", + "fieldtype": "Check", + "label": "In Standard Filter" + }, + { + "default": "0", + "depends_on": "eval:([\"Data\", \"Select\", \"Table\", \"Text\", \"Text Editor\", \"Link\", \"Small Text\", \"Long Text\", \"Read Only\", \"Heading\", \"Dynamic Link\"].indexOf(doc.fieldtype) !== -1)", + "fieldname": "in_global_search", + "fieldtype": "Check", + "label": "In Global Search" + }, + { + "default": "0", + "fieldname": "bold", + "fieldtype": "Check", + "label": "Bold" + }, + { + "default": "0", + "fieldname": "report_hide", + "fieldtype": "Check", + "label": "Report Hide", + "oldfieldname": "report_hide", + "oldfieldtype": "Check" + }, + { + "default": "0", + "fieldname": "search_index", + "fieldtype": "Check", + "hidden": 1, + "label": "Index", + "no_copy": 1, + "print_hide": 1 + }, + { + "default": "0", + "description": "Don't HTML Encode HTML tags like <script> or just characters like < or >, as they could be intentionally used in this field", + "fieldname": "ignore_xss_filter", + "fieldtype": "Check", + "label": "Ignore XSS Filter" + }, + { + "default": "1", + "depends_on": "eval:['Data', 'Select', 'Text', 'Small Text', 'Text Editor'].includes(doc.fieldtype)", + "fieldname": "translatable", + "fieldtype": "Check", + "label": "Translatable" + }, + { + "depends_on": "eval:in_list(['Data', 'Link', 'Dynamic Link', 'Password', 'Select', 'Read Only', 'Attach', 'Attach Image', 'Int'], doc.fieldtype)", + "fieldname": "length", + "fieldtype": "Int", + "label": "Length" + }, + { + "fieldname": "mandatory_depends_on", + "fieldtype": "Code", + "label": "Mandatory Depends On", + "length": 255 + }, + { + "fieldname": "read_only_depends_on", + "fieldtype": "Code", + "label": "Read Only Depends On", + "length": 255 + }, + { + "default": "0", + "fieldname": "allow_in_quick_entry", + "fieldtype": "Check", + "label": "Allow in Quick Entry" + }, + { + "default": "0", + "depends_on": "eval:!in_list(['Table', 'Table MultiSelect'], doc.fieldtype);", + "fieldname": "in_preview", + "fieldtype": "Check", + "label": "In Preview" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype=='Duration'", + "fieldname": "hide_seconds", + "fieldtype": "Check", + "label": "Hide Seconds" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype=='Duration'", + "fieldname": "hide_days", + "fieldtype": "Check", + "label": "Hide Days" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype=='Section Break'", + "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" + }, + { + "fieldname": "module", + "fieldtype": "Link", + "label": "Module (for export)", + "options": "Module Def" + } + ], + "icon": "fa fa-glass", + "idx": 1, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-09-04 12:45:23.810120", + "modified_by": "Administrator", + "module": "Custom", + "name": "Custom Field", + "owner": "Administrator", + "permissions": [{ + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Administrator", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "search_fields": "dt,label,fieldtype,options", + "sort_field": "modified", + "sort_order": "ASC", + "track_changes": 1 } \ No newline at end of file diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py index 7e6ea1875a..8c22d3c45c 100644 --- a/frappe/custom/doctype/custom_field/custom_field.py +++ b/frappe/custom/doctype/custom_field/custom_field.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe import json @@ -18,7 +18,7 @@ class CustomField(Document): if not self.fieldname: label = self.label if not label: - if self.fieldtype in ["Section Break", "Column Break"]: + if self.fieldtype in ["Section Break", "Column Break", "Tab Break"]: label = self.fieldtype + "_" + str(self.idx) else: frappe.throw(_("Label is mandatory")) @@ -85,12 +85,10 @@ class CustomField(Document): frappe.bold(self.label))) # delete property setter entries - frappe.db.sql("""\ - DELETE FROM `tabProperty Setter` - WHERE doc_type = %s - AND field_name = %s""", - (self.dt, self.fieldname)) - + frappe.db.delete("Property Setter", { + "doc_type": self.dt, + "field_name": self.fieldname + }) frappe.clear_cache(doctype=self.dt) def validate_insert_after(self, meta): @@ -133,7 +131,7 @@ def create_custom_field(doctype, df, ignore_validate=False): "permlevel": 0, "fieldtype": 'Data', "hidden": 0, - # Looks like we always use this programatically? + # Looks like we always use this programatically? # "is_standard": 1 }) custom_field.update(df) @@ -148,24 +146,29 @@ def create_custom_fields(custom_fields, ignore_validate = False, update=True): if not ignore_validate and frappe.flags.in_setup_wizard: ignore_validate = True - for doctype, fields in custom_fields.items(): + for doctypes, fields in custom_fields.items(): if isinstance(fields, dict): # only one field fields = [fields] - for df in fields: - field = frappe.db.get_value("Custom Field", {"dt": doctype, "fieldname": df["fieldname"]}) - if not field: - try: - df["owner"] = "Administrator" - create_custom_field(doctype, df, ignore_validate=ignore_validate) - except frappe.exceptions.DuplicateEntryError: - pass - elif update: - custom_field = frappe.get_doc("Custom Field", field) - custom_field.flags.ignore_validate = ignore_validate - custom_field.update(df) - custom_field.save() + if isinstance(doctypes, str): + # only one doctype + doctypes = (doctypes,) + + for doctype in doctypes: + for df in fields: + field = frappe.db.get_value("Custom Field", {"dt": doctype, "fieldname": df["fieldname"]}) + if not field: + try: + df["owner"] = "Administrator" + create_custom_field(doctype, df, ignore_validate=ignore_validate) + except frappe.exceptions.DuplicateEntryError: + pass + elif update: + custom_field = frappe.get_doc("Custom Field", field) + custom_field.flags.ignore_validate = ignore_validate + custom_field.update(df) + custom_field.save() frappe.clear_cache(doctype=doctype) frappe.db.updatedb(doctype) diff --git a/frappe/custom/doctype/custom_field/test_custom_field.py b/frappe/custom/doctype/custom_field/test_custom_field.py index 3196b66ee8..ad3cf27eea 100644 --- a/frappe/custom/doctype/custom_field/test_custom_field.py +++ b/frappe/custom/doctype/custom_field/test_custom_field.py @@ -1,12 +1,47 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest -test_records = frappe.get_test_records('Custom Field') +test_records = frappe.get_test_records("Custom Field") + class TestCustomField(unittest.TestCase): - pass + def test_create_custom_fields(self): + from .custom_field import create_custom_fields + + create_custom_fields( + { + "Address": [ + { + "fieldname": "_test_custom_field_1", + "label": "_Test Custom Field 1", + "fieldtype": "Data", + "insert_after": "phone", + }, + ], + ("Address", "Contact"): [ + { + "fieldname": "_test_custom_field_2", + "label": "_Test Custom Field 2", + "fieldtype": "Data", + "insert_after": "phone", + }, + ], + } + ) + + frappe.db.commit() + + self.assertTrue( + frappe.db.exists("Custom Field", "Address-_test_custom_field_1") + ) + self.assertTrue( + frappe.db.exists("Custom Field", "Address-_test_custom_field_2") + ) + self.assertTrue( + frappe.db.exists("Custom Field", "Contact-_test_custom_field_2") + ) diff --git a/frappe/custom/doctype/customize_form/__init__.py b/frappe/custom/doctype/customize_form/__init__.py index 0e57cb68c3..eb5ba62e5c 100644 --- a/frappe/custom/doctype/customize_form/__init__.py +++ b/frappe/custom/doctype/customize_form/__init__.py @@ -1,3 +1,3 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE diff --git a/frappe/custom/doctype/customize_form/customize_form.json b/frappe/custom/doctype/customize_form/customize_form.json index b9dde88126..c2940a92e3 100644 --- a/frappe/custom/doctype/customize_form/customize_form.json +++ b/frappe/custom/doctype/customize_form/customize_form.json @@ -31,7 +31,6 @@ "default_print_format", "column_break_29", "show_preview_popup", - "image_view", "email_settings_section", "default_email_template", "column_break_26", @@ -109,13 +108,6 @@ "fieldtype": "Check", "label": "Track Changes" }, - { - "default": "0", - "depends_on": "eval: doc.image_field", - "fieldname": "image_view", - "fieldtype": "Check", - "label": "Image View" - }, { "fieldname": "column_break_5", "fieldtype": "Column Break" @@ -296,7 +288,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-06-02 06:49:16.782806", + "modified": "2021-06-21 19:01:06.920663", "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 1b8977acc4..94f25a41aa 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -1,5 +1,5 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See LICENSE """ Customize Form is a Single DocType used to mask the Property Setter @@ -18,10 +18,11 @@ from frappe.custom.doctype.property_setter.property_setter import delete_propert from frappe.model.docfield import supports_translation from frappe.core.doctype.doctype.doctype import validate_series + class CustomizeForm(Document): def on_update(self): - frappe.db.sql("delete from tabSingles where doctype='Customize Form'") - frappe.db.sql("delete from `tabCustomize Form Field`") + frappe.db.delete("Singles", {"doctype": "Customize Form"}) + frappe.db.delete("Customize Form Field") @frappe.whitelist() def fetch_to_customize(self): @@ -192,6 +193,16 @@ class CustomizeForm(Document): if prop == "fieldtype": self.validate_fieldtype_change(df, meta_df[0].get(prop), df.get(prop)) + elif prop == "length": + old_value_length = cint(meta_df[0].get(prop)) + new_value_length = cint(df.get(prop)) + + if new_value_length and (old_value_length > new_value_length): + self.check_length_for_fieldtypes.append({'df': df, 'old_value': meta_df[0].get(prop)}) + self.validate_fieldtype_length() + else: + 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"): diff --git a/frappe/custom/doctype/customize_form/test_customize_form.py b/frappe/custom/doctype/customize_form/test_customize_form.py index 58bdcf9a18..8a287b17e8 100644 --- a/frappe/custom/doctype/customize_form/test_customize_form.py +++ b/frappe/custom/doctype/customize_form/test_customize_form.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe, unittest, json from frappe.test_runner import make_test_records_for_doctype @@ -188,6 +188,26 @@ class TestCustomizeForm(unittest.TestCase): def test_core_doctype_customization(self): self.assertRaises(frappe.ValidationError, self.get_customize_form, 'User') + def test_save_customization_length_field_property(self): + # Using Notification Log doctype as it doesn't have any other custom fields + d = self.get_customize_form("Notification Log") + + document_name = d.get("fields", {"fieldname": "document_name"})[0] + document_name.length = 255 + d.run_method("save_customization") + + self.assertEqual(frappe.db.get_value("Property Setter", + {"doc_type": "Notification Log", "property": "length", "field_name": "document_name"}, "value"), '255') + + self.assertTrue(d.flags.update_db) + + length = frappe.db.sql("""SELECT character_maximum_length + FROM information_schema.columns + WHERE table_name = 'tabNotification Log' + AND column_name = 'document_name'""")[0][0] + + self.assertEqual(length, 255) + def test_custom_link(self): try: # create a dummy doctype linked to Event @@ -232,6 +252,32 @@ class TestCustomizeForm(unittest.TestCase): testdt.delete() testdt1.delete() + def test_custom_internal_links(self): + # add a custom internal link + frappe.clear_cache() + d = self.get_customize_form("User Group") + + d.append('links', dict(link_doctype='User Group Member', parent_doctype='User', + link_fieldname='user', table_fieldname='user_group_members', group='Tests', custom=1)) + + d.run_method("save_customization") + + frappe.clear_cache() + user_group = frappe.get_meta('User Group') + + # check links exist + self.assertTrue([d.name for d in user_group.links if d.link_doctype == 'User Group Member']) + self.assertTrue([d.name for d in user_group.links if d.parent_doctype == 'User']) + + # remove the link + d = self.get_customize_form("User Group") + d.links = [] + d.run_method("save_customization") + + frappe.clear_cache() + user_group = frappe.get_meta('Event') + self.assertFalse([d.name for d in (user_group.links or []) if d.link_doctype == 'User Group Member']) + def test_custom_action(self): test_route = '/app/List/DocType' diff --git a/frappe/custom/doctype/customize_form_field/__init__.py b/frappe/custom/doctype/customize_form_field/__init__.py index 0e57cb68c3..eb5ba62e5c 100644 --- a/frappe/custom/doctype/customize_form_field/__init__.py +++ b/frappe/custom/doctype/customize_form_field/__init__.py @@ -1,3 +1,3 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE 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 227114137c..986b99a7af 100644 --- a/frappe/custom/doctype/customize_form_field/customize_form_field.json +++ b/frappe/custom/doctype/customize_form_field/customize_form_field.json @@ -82,7 +82,7 @@ "label": "Type", "oldfieldname": "fieldtype", "oldfieldtype": "Select", - "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRating\nRead Only\nSection Break\nSelect\nSignature\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime", + "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRating\nRead Only\nSection Break\nSelect\nSignature\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nTab Break", "reqd": 1, "search_index": 1 }, @@ -428,7 +428,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-10-29 06:11:57.661039", + "modified": "2021-07-11 21:57:24.479749", "modified_by": "Administrator", "module": "Custom", "name": "Customize Form Field", diff --git a/frappe/custom/doctype/customize_form_field/customize_form_field.py b/frappe/custom/doctype/customize_form_field/customize_form_field.py index f288e70754..67563cf048 100644 --- a/frappe/custom/doctype/customize_form_field/customize_form_field.py +++ b/frappe/custom/doctype/customize_form_field/customize_form_field.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe diff --git a/frappe/custom/doctype/doctype_layout/doctype_layout.js b/frappe/custom/doctype/doctype_layout/doctype_layout.js index 679330e065..533efea9b8 100644 --- a/frappe/custom/doctype/doctype_layout/doctype_layout.js +++ b/frappe/custom/doctype/doctype_layout/doctype_layout.js @@ -23,7 +23,7 @@ frappe.ui.form.on('DocType Layout', { 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`); + window.open(`/app/${frappe.router.slug(frm.doc.name)}`); }); } } diff --git a/frappe/custom/doctype/doctype_layout/doctype_layout.py b/frappe/custom/doctype/doctype_layout/doctype_layout.py index 0dc320353d..fa285ddb62 100644 --- a/frappe/custom/doctype/doctype_layout/doctype_layout.py +++ b/frappe/custom/doctype/doctype_layout/doctype_layout.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE from frappe.model.document import Document diff --git a/frappe/custom/doctype/doctype_layout/test_doctype_layout.py b/frappe/custom/doctype/doctype_layout/test_doctype_layout.py index dcde3c00a4..a63dd7ee16 100644 --- a/frappe/custom/doctype/doctype_layout/test_doctype_layout.py +++ b/frappe/custom/doctype/doctype_layout/test_doctype_layout.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE # import frappe import unittest diff --git a/frappe/custom/doctype/doctype_layout_field/doctype_layout_field.json b/frappe/custom/doctype/doctype_layout_field/doctype_layout_field.json index a1a36216c3..006c01ae4e 100644 --- a/frappe/custom/doctype/doctype_layout_field/doctype_layout_field.json +++ b/frappe/custom/doctype/doctype_layout_field/doctype_layout_field.json @@ -20,14 +20,13 @@ "fieldname": "label", "fieldtype": "Data", "in_list_view": 1, - "label": "Label", - "reqd": 1 + "label": "Label" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-11-16 17:13:01.892345", + "modified": "2021-05-19 16:27:40.585865", "modified_by": "Administrator", "module": "Custom", "name": "DocType Layout Field", @@ -36,4 +35,4 @@ "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 index c1e963602f..3f8487b659 100644 --- a/frappe/custom/doctype/doctype_layout_field/doctype_layout_field.py +++ b/frappe/custom/doctype/doctype_layout_field/doctype_layout_field.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/custom/doctype/property_setter/__init__.py b/frappe/custom/doctype/property_setter/__init__.py index 0e57cb68c3..eb5ba62e5c 100644 --- a/frappe/custom/doctype/property_setter/__init__.py +++ b/frappe/custom/doctype/property_setter/__init__.py @@ -1,3 +1,3 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE diff --git a/frappe/custom/doctype/property_setter/property_setter.json b/frappe/custom/doctype/property_setter/property_setter.json index b318d92c5a..fcb36637fe 100644 --- a/frappe/custom/doctype/property_setter/property_setter.json +++ b/frappe/custom/doctype/property_setter/property_setter.json @@ -13,6 +13,8 @@ "field_name", "row_name", "column_break0", + "module", + "section_break_9", "property", "property_type", "value", @@ -91,13 +93,23 @@ "fieldname": "row_name", "fieldtype": "Data", "label": "Row Name" + }, + { + "fieldname": "module", + "fieldtype": "Link", + "label": "Module (for export)", + "options": "Module Def" + }, + { + "fieldname": "section_break_9", + "fieldtype": "Section Break" } ], "icon": "fa fa-glass", "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2020-09-24 14:42:38.599684", + "modified": "2021-09-04 12:46:17.860769", "modified_by": "Administrator", "module": "Custom", "name": "Property Setter", diff --git a/frappe/custom/doctype/property_setter/property_setter.py b/frappe/custom/doctype/property_setter/property_setter.py index 2a6c06b70a..7f40be9725 100644 --- a/frappe/custom/doctype/property_setter/property_setter.py +++ b/frappe/custom/doctype/property_setter/property_setter.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe from frappe import _ @@ -34,7 +34,7 @@ class PropertySetter(Document): fields=['fieldname', 'label', 'fieldtype'], filters={ 'parent': dt, - 'fieldtype': ['not in', ('Section Break', 'Column Break', 'HTML', 'Read Only', 'Fold') + frappe.model.table_fields], + 'fieldtype': ['not in', ('Section Break', 'Column Break', 'Tab Break', 'HTML', 'Read Only', 'Fold') + frappe.model.table_fields], 'fieldname': ['!=', ''] }, order_by='label asc', @@ -43,20 +43,28 @@ class PropertySetter(Document): def get_setup_data(self): return { - 'doctypes': [d[0] for d in frappe.db.sql("select name from tabDocType")], + 'doctypes': frappe.get_all("DocType", pluck="name"), 'dt_properties': self.get_property_list('DocType'), 'df_properties': self.get_property_list('DocField') } def get_field_ids(self): - return frappe.db.sql("select name, fieldtype, label, fieldname from tabDocField where parent=%s", self.doc_type, as_dict = 1) + return frappe.db.get_values( + "DocField", + filters={"parent": self.doc_type}, + fieldname=["name", "fieldtype", "label", "fieldname"], + as_dict=True, + ) def get_defaults(self): if not self.field_name: - return frappe.db.sql("select * from `tabDocType` where name=%s", self.doc_type, as_dict = 1)[0] + return frappe.get_all("DocType", filters={"name": self.doc_type}, fields="*")[0] else: - return frappe.db.sql("select * from `tabDocField` where fieldname=%s and parent=%s", - (self.field_name, self.doc_type), as_dict = 1)[0] + return frappe.db.get_values( + "DocField", + filters={"fieldname": self.field_name, "parent": self.doc_type}, + fieldname="*", + )[0] def on_update(self): if frappe.flags.in_patch: diff --git a/frappe/custom/doctype/property_setter/test_property_setter.py b/frappe/custom/doctype/property_setter/test_property_setter.py index 4d4de66d51..1bbbe59a0f 100644 --- a/frappe/custom/doctype/property_setter/test_property_setter.py +++ b/frappe/custom/doctype/property_setter/test_property_setter.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/custom/doctype/test_rename_new/test_rename_new.py b/frappe/custom/doctype/test_rename_new/test_rename_new.py index 32d2396b2b..fc4ab97cfe 100644 --- a/frappe/custom/doctype/test_rename_new/test_rename_new.py +++ b/frappe/custom/doctype/test_rename_new/test_rename_new.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2021, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document 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 index b3ea4818de..03202669ed 100644 --- a/frappe/custom/doctype/test_rename_new/test_test_rename_new.py +++ b/frappe/custom/doctype/test_rename_new/test_test_rename_new.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2021, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE # import frappe import unittest diff --git a/frappe/custom/workspace/customization/customization.json b/frappe/custom/workspace/customization/customization.json index cdc3b73366..7aec530604 100644 --- a/frappe/custom/workspace/customization/customization.json +++ b/frappe/custom/workspace/customization/customization.json @@ -1,23 +1,20 @@ { - "category": "Administration", "charts": [], + "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Your Shortcuts\", \"level\": 4, \"col\": 12}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Customize Form\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Custom Role\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Client Script\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Server Script\", \"col\": 4}}, {\"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Dashboards\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Form Customization\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Other\", \"col\": 4}}]", "creation": "2020-03-02 15:15:03.839594", - "developer_mode_only": 0, - "disable_user_customization": 0, "docstatus": 0, "doctype": "Workspace", - "extends_another_page": 0, + "for_user": "", "hide_custom": 0, "icon": "customization", "idx": 0, - "is_default": 0, - "is_standard": 1, "label": "Customization", "links": [ { "hidden": 0, "is_query_report": 0, "label": "Dashboards", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -26,6 +23,7 @@ "hidden": 0, "is_query_report": 0, "label": "Dashboard", + "link_count": 0, "link_to": "Dashboard", "link_type": "DocType", "onboard": 0, @@ -36,6 +34,7 @@ "hidden": 0, "is_query_report": 0, "label": "Dashboard Chart", + "link_count": 0, "link_to": "Dashboard Chart", "link_type": "DocType", "onboard": 0, @@ -46,6 +45,7 @@ "hidden": 0, "is_query_report": 0, "label": "Dashboard Chart Source", + "link_count": 0, "link_to": "Dashboard Chart Source", "link_type": "DocType", "onboard": 0, @@ -55,6 +55,7 @@ "hidden": 0, "is_query_report": 0, "label": "Form Customization", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -63,6 +64,7 @@ "hidden": 0, "is_query_report": 0, "label": "Customize Form", + "link_count": 0, "link_to": "Customize Form", "link_type": "DocType", "onboard": 0, @@ -73,6 +75,7 @@ "hidden": 0, "is_query_report": 0, "label": "Custom Field", + "link_count": 0, "link_to": "Custom Field", "link_type": "DocType", "onboard": 0, @@ -83,6 +86,7 @@ "hidden": 0, "is_query_report": 0, "label": "Client Script", + "link_count": 0, "link_to": "Client Script", "link_type": "DocType", "onboard": 0, @@ -93,6 +97,7 @@ "hidden": 0, "is_query_report": 0, "label": "DocType", + "link_count": 0, "link_to": "DocType", "link_type": "DocType", "onboard": 0, @@ -102,6 +107,7 @@ "hidden": 0, "is_query_report": 0, "label": "Other", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -110,19 +116,23 @@ "hidden": 0, "is_query_report": 0, "label": "Custom Translations", + "link_count": 0, "link_to": "Translation", "link_type": "DocType", "onboard": 0, "type": "Link" } ], - "modified": "2021-02-04 13:50:35.750463", + "modified": "2021-08-05 12:15:57.486113", "modified_by": "Administrator", "module": "Custom", "name": "Customization", "owner": "Administrator", - "pin_to_bottom": 0, - "pin_to_top": 0, + "parent_page": "", + "public": 1, + "restrict_to_domain": "", + "roles": [], + "sequence_id": 8, "shortcuts": [ { "label": "Customize Form", @@ -145,5 +155,6 @@ "link_to": "Server Script", "type": "DocType" } - ] + ], + "title": "Customization" } \ No newline at end of file diff --git a/frappe/data/google_fonts.json b/frappe/data/google_fonts.json new file mode 100644 index 0000000000..232e509e77 --- /dev/null +++ b/frappe/data/google_fonts.json @@ -0,0 +1,56 @@ +[ + "Alegreya Sans", + "Alegreya", + "Andada Pro", + "Anton", + "Archivo Narrow", + "Archivo", + "BioRhyme", + "Cardo", + "Chivo", + "Cormorant", + "Crimson Text", + "DM Sans", + "Eczar", + "Encode Sans", + "Epilogue ", + "Fira Sans", + "Hahmlet", + "IBM Plex Sans", + "Inconsolata", + "Inknut Antiqua", + "Inter", + "JetBrains Mono", + "Karla", + "Lato", + "Libre Baskerville", + "Libre Franklin", + "Lora", + "Manrope", + "Merriweather", + "Montserrat", + "Neuton", + "Nunito", + "Old Standard TT", + "Open Sans", + "Oswald", + "Oxygen", + "Playfair Display", + "Poppins", + "Proza Libre", + "PT Sans", + "PT Serif", + "Raleway", + "Roboto Slab", + "Roboto", + "Rubik", + "Sora", + "Source Sans Pro", + "Source Serif Pro", + "Space Grotesk", + "Space Mono", + "Spectral", + "Syne", + "Work Sans" +] + diff --git a/frappe/data/sample_site_config.json b/frappe/data/sample_site_config.json deleted file mode 100644 index 715cd7b9fa..0000000000 --- a/frappe/data/sample_site_config.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "db_name": "testdb", - "db_password": "password", - "mute_emails": true, - - "limits": { - "emails": 1500, - "space": 0.157, - "expiry": "2016-07-25", - "users": 1 - }, - - "developer_mode": 1, - "auto_cache_clear": true, - "disable_website_cache": true, - "max_file_size": 1000000, - - "mail_server": "localhost", - "mail_login": null, - "mail_password": null, - "mail_port": 25, - "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" - }, - "github_login": { - "client_id": "github_client_id", - "client_secret": "github_client_secret" - }, - "facebook_login": { - "client_id": "facebook_client_id", - "client_secret": "facebook_client_secret" - }, - - "celery_broker": "redis://localhost", - "celery_result_backend": null, - "scheduler_interval": 300, - "celery_queue_per_site": true -} diff --git a/frappe/data_migration/doctype/data_migration_connector/data_migration_connector.py b/frappe/data_migration/doctype/data_migration_connector/data_migration_connector.py index d1137f2e67..2e4e4d45b3 100644 --- a/frappe/data_migration/doctype/data_migration_connector/data_migration_connector.py +++ b/frappe/data_migration/doctype/data_migration_connector/data_migration_connector.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe, os from frappe.model.document import Document diff --git a/frappe/data_migration/doctype/data_migration_connector/test_data_migration_connector.py b/frappe/data_migration/doctype/data_migration_connector/test_data_migration_connector.py index fd45f86ec1..ffc96c8266 100644 --- a/frappe/data_migration/doctype/data_migration_connector/test_data_migration_connector.py +++ b/frappe/data_migration/doctype/data_migration_connector/test_data_migration_connector.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import unittest class TestDataMigrationConnector(unittest.TestCase): diff --git a/frappe/data_migration/doctype/data_migration_mapping/data_migration_mapping.py b/frappe/data_migration/doctype/data_migration_mapping/data_migration_mapping.py index 5cb20ba56c..46d33eaca9 100644 --- a/frappe/data_migration/doctype/data_migration_mapping/data_migration_mapping.py +++ b/frappe/data_migration/doctype/data_migration_mapping/data_migration_mapping.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/data_migration/doctype/data_migration_mapping/test_data_migration_mapping.py b/frappe/data_migration/doctype/data_migration_mapping/test_data_migration_mapping.py index df11fc0522..b1040aaa58 100644 --- a/frappe/data_migration/doctype/data_migration_mapping/test_data_migration_mapping.py +++ b/frappe/data_migration/doctype/data_migration_mapping/test_data_migration_mapping.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import unittest class TestDataMigrationMapping(unittest.TestCase): diff --git a/frappe/data_migration/doctype/data_migration_mapping_detail/data_migration_mapping_detail.py b/frappe/data_migration/doctype/data_migration_mapping_detail/data_migration_mapping_detail.py index 6d3ef50937..ce46f60f67 100644 --- a/frappe/data_migration/doctype/data_migration_mapping_detail/data_migration_mapping_detail.py +++ b/frappe/data_migration/doctype/data_migration_mapping_detail/data_migration_mapping_detail.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE from frappe.model.document import Document diff --git a/frappe/data_migration/doctype/data_migration_plan/data_migration_plan.py b/frappe/data_migration/doctype/data_migration_plan/data_migration_plan.py index a8d0e40a4c..94ed77e2ec 100644 --- a/frappe/data_migration/doctype/data_migration_plan/data_migration_plan.py +++ b/frappe/data_migration/doctype/data_migration_plan/data_migration_plan.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.modules import get_module_path, scrub_dt_dn diff --git a/frappe/data_migration/doctype/data_migration_plan/test_data_migration_plan.py b/frappe/data_migration/doctype/data_migration_plan/test_data_migration_plan.py index 14c585a82d..649f7db903 100644 --- a/frappe/data_migration/doctype/data_migration_plan/test_data_migration_plan.py +++ b/frappe/data_migration/doctype/data_migration_plan/test_data_migration_plan.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import unittest class TestDataMigrationPlan(unittest.TestCase): diff --git a/frappe/data_migration/doctype/data_migration_plan_mapping/data_migration_plan_mapping.py b/frappe/data_migration/doctype/data_migration_plan_mapping/data_migration_plan_mapping.py index ba4cf28eb8..7939a68d97 100644 --- a/frappe/data_migration/doctype/data_migration_plan_mapping/data_migration_plan_mapping.py +++ b/frappe/data_migration/doctype/data_migration_plan_mapping/data_migration_plan_mapping.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE from frappe.model.document import Document diff --git a/frappe/data_migration/doctype/data_migration_run/data_migration_run.py b/frappe/data_migration/doctype/data_migration_run/data_migration_run.py index c35af5827b..deb14baf27 100644 --- a/frappe/data_migration/doctype/data_migration_run/data_migration_run.py +++ b/frappe/data_migration/doctype/data_migration_run/data_migration_run.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe, json, math from frappe.model.document import Document diff --git a/frappe/data_migration/doctype/data_migration_run/test_data_migration_run.py b/frappe/data_migration/doctype/data_migration_run/test_data_migration_run.py index ef7b70dca2..485f86a7f9 100644 --- a/frappe/data_migration/doctype/data_migration_run/test_data_migration_run.py +++ b/frappe/data_migration/doctype/data_migration_run/test_data_migration_run.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe, unittest class TestDataMigrationRun(unittest.TestCase): diff --git a/frappe/database/__init__.py b/frappe/database/__init__.py index a899bec3d1..b0e3183d4f 100644 --- a/frappe/database/__init__.py +++ b/frappe/database/__init__.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE # Database Module # -------------------- diff --git a/frappe/database/database.py b/frappe/database/database.py index 81e24cc7ad..a7dd9b6b66 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -1,11 +1,12 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE # Database Module # -------------------- import re import time +from typing import Dict, List, Union import frappe import datetime import frappe.defaults @@ -13,9 +14,13 @@ import frappe.model.meta from frappe import _ from time import time -from frappe.utils import now, getdate, cast_fieldtype, get_datetime +from frappe.utils import now, getdate, cast, get_datetime from frappe.model.utils.link_count import flush_local_link_count -from frappe.utils import cint +from frappe.query_builder.functions import Count +from frappe.query_builder.functions import Min, Max, Avg, Sum +from frappe.query_builder.utils import Column +from .query import Query +from pypika.terms import Criterion, PseudoColumn class Database(object): @@ -32,6 +37,7 @@ class Database(object): STANDARD_VARCHAR_COLUMNS = ('name', 'owner', 'modified_by', 'parent', 'parentfield', 'parenttype') DEFAULT_COLUMNS = ['name', 'creation', 'modified', 'modified_by', 'owner', 'docstatus', 'parent', 'parentfield', 'parenttype', 'idx'] + MAX_WRITES_PER_TRANSACTION = 200_000 class InvalidColumnName(frappe.ValidationError): pass @@ -55,6 +61,7 @@ class Database(object): self.password = password or frappe.conf.db_password self.value_cache = {} + self.query = Query() def setup_type_map(self): pass @@ -77,7 +84,8 @@ class Database(object): pass def sql(self, query, values=(), as_dict = 0, as_list = 0, formatted = 0, - debug=0, ignore_ddl=0, as_utf8=0, auto_commit=0, update=None, explain=False): + debug=0, ignore_ddl=0, as_utf8=0, auto_commit=0, update=None, + explain=False, run=True, pluck=False): """Execute a SQL query and fetch all rows. :param query: SQL query. @@ -90,7 +98,7 @@ class Database(object): :param as_utf8: Encode values as UTF 8. :param auto_commit: Commit after executing the query. :param update: Update this dict to all rows (if returned `as_dict`). - + :param run: Returns query without executing it if False. Examples: # return customer names as dicts @@ -104,6 +112,10 @@ class Database(object): {"name": "a%", "owner":"test@example.com"}) """ + query = str(query) + if not run: + return query + if re.search(r'ifnull\(', query, flags=re.IGNORECASE): # replaces ifnull in query with coalesce query = re.sub(r'ifnull\(', 'coalesce(', query, flags=re.IGNORECASE) @@ -158,6 +170,12 @@ class Database(object): frappe.errprint('Syntax error in query:') frappe.errprint(query) + elif self.is_deadlocked(e): + raise frappe.QueryDeadlockError + + elif self.is_timedout(e): + raise frappe.QueryTimeoutError + if ignore_ddl and (self.is_missing_column(e) or self.is_missing_table(e) or self.cant_drop_field_or_key(e)): pass else: @@ -168,6 +186,9 @@ class Database(object): if not self._cursor.description: return () + if pluck: + return [r[0] for r in self._cursor.fetchall()] + # scrub output if required if as_dict: ret = self.fetch_as_dict(formatted, as_utf8) @@ -223,7 +244,7 @@ class Database(object): except Exception: frappe.errprint("error in query explain") - def sql_list(self, query, values=(), debug=False): + def sql_list(self, query, values=(), debug=False, **kwargs): """Return data as list of single elements (first column). Example: @@ -231,7 +252,7 @@ class Database(object): # doctypes = ["DocType", "DocField", "User", ...] doctypes = frappe.db.sql_list("select name from DocType") """ - return [r[0] for r in self.sql(query, values, debug=debug)] + return [r[0] for r in self.sql(query, values, **kwargs, debug=debug)] def sql_ddl(self, query, values=(), debug=False): """Commit and execute a query. DDL (Data Definition Language) queries that alter schema @@ -252,7 +273,7 @@ class Database(object): if query[:6].lower() in ('update', 'insert', 'delete'): self.transaction_writes += 1 - if self.transaction_writes > 200000: + if self.transaction_writes > self.MAX_WRITES_PER_TRANSACTION: if self.auto_commit_on_many_writes: self.commit() else: @@ -309,65 +330,12 @@ class Database(object): nres.append(nr) return nres - 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: - - * ifnull(`fieldname`, default_value) = %(fieldname)s - * `fieldname` [=, !=, >, >=, <, <=] %(fieldname)s - """ - conditions = [] - values = {} - def _build_condition(key): - """ - filter's key is passed by map function - build conditions like: - * ifnull(`fieldname`, default_value) = %(fieldname)s - * `fieldname` [=, !=, >, >=, <, <=] %(fieldname)s - """ - _operator = "=" - _rhs = " %(" + key + ")s" - value = filters.get(key) - values[key] = value - if isinstance(value, (list, tuple)): - # 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")) - _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"]: - _operator = "=" - - if "[" in key: - split_key = key.split("[") - condition = "coalesce(`" + split_key[0] + "`, " + split_key[1][:-1] + ") " \ - + _operator + _rhs - else: - condition = "`" + key + "` " + _operator + _rhs - - conditions.append(condition) - - if isinstance(filters, int): - # docname is a number, convert to string - filters = str(filters) - - if isinstance(filters, str): - filters = { "name": filters } - - for f in filters: - _build_condition(f) - - return " and ".join(conditions), values - def get(self, doctype, filters=None, as_dict=True, cache=False): """Returns `get_value` with fieldname='*'""" return self.get_value(doctype, filters, "*", as_dict=as_dict, cache=cache) def get_value(self, doctype, filters=None, fieldname="name", ignore=None, as_dict=False, - debug=False, order_by=None, cache=False, for_update=False): + debug=False, order_by=None, cache=False, for_update=False, run=True): """Returns a document property or list of properties. :param doctype: DocType name. @@ -394,12 +362,15 @@ class Database(object): """ ret = self.get_values(doctype, filters, fieldname, ignore, as_dict, debug, - order_by, cache=cache, for_update=for_update) + order_by, cache=cache, for_update=for_update, run=run) + + if not run: + return ret return ((len(ret[0]) > 1 or as_dict) and ret[0] or ret[0][0]) if ret else None def get_values(self, doctype, filters=None, fieldname="name", ignore=None, as_dict=False, - debug=False, order_by=None, update=None, cache=False, for_update=False): + debug=False, order_by=None, update=None, cache=False, for_update=False, run=True): """Returns multiple document properties. :param doctype: DocType name. @@ -423,10 +394,9 @@ class Database(object): (doctype, filters, fieldname) in self.value_cache: return self.value_cache[(doctype, filters, fieldname)] - if not order_by: order_by = 'modified desc' - if isinstance(filters, list): - out = self._get_value_for_many_names(doctype, filters, fieldname, debug=debug) + order_by = order_by or "modified_desc" + out = self._get_value_for_many_names(doctype, filters, fieldname, debug=debug, run=run) else: fields = fieldname @@ -438,26 +408,29 @@ class Database(object): if (filters is not None) and (filters!=doctype or doctype=="DocType"): try: - out = self._get_values_from_table(fields, filters, doctype, as_dict, debug, order_by, update, for_update=for_update) + order_by = order_by or "modified" + out = self._get_values_from_table( + fields, filters, doctype, as_dict, debug, order_by, update, for_update=for_update, run=run + ) except Exception as e: if ignore and (frappe.db.is_missing_column(e) or frappe.db.is_table_missing(e)): # table or column not found, return None out = None elif (not ignore) and frappe.db.is_table_missing(e): # table not found, look in singles - out = self.get_values_from_single(fields, filters, doctype, as_dict, debug, update) + out = self.get_values_from_single(fields, filters, doctype, as_dict, debug, update, run=run) else: raise else: - out = self.get_values_from_single(fields, filters, doctype, as_dict, debug, update) + out = self.get_values_from_single(fields, filters, doctype, as_dict, debug, update, run=run) if cache and isinstance(filters, str): self.value_cache[(doctype, filters, fieldname)] = out return out - def get_values_from_single(self, fields, filters, doctype, as_dict=False, debug=False, update=None): + def get_values_from_single(self, fields, filters, doctype, as_dict=False, debug=False, update=None, run=True): """Get values from `tabSingles` (Single DocTypes) (internal). :param fields: List of fields, @@ -486,8 +459,9 @@ class Database(object): r = self.sql("""select field, value from `tabSingles` where field in (%s) and doctype=%s""" % (', '.join(['%s'] * len(fields)), '%s'), - tuple(fields) + (doctype,), as_dict=False, debug=debug) - + tuple(fields) + (doctype,), as_dict=False, debug=debug, run=run) + if not run: + return r if as_dict: if r: r = frappe._dict(r) @@ -515,7 +489,6 @@ class Database(object): FROM `tabSingles` WHERE doctype = %s """, doctype) - # result = _cast_result(doctype, result) dict_ = frappe._dict(result) @@ -542,7 +515,7 @@ class Database(object): """ if not doctype in self.value_cache: - self.value_cache = self.value_cache[doctype] = {} + self.value_cache[doctype] = {} if fieldname in self.value_cache[doctype]: return self.value_cache[doctype][fieldname] @@ -556,8 +529,7 @@ class Database(object): if not df: frappe.throw(_('Invalid field name: {0}').format(frappe.bold(fieldname)), self.InvalidColumnName) - if df.fieldtype in frappe.model.numeric_fieldtypes: - val = cint(val) + val = cast(df.fieldtype, val) self.value_cache[doctype][fieldname] = val @@ -567,44 +539,41 @@ class Database(object): """Alias for get_single_value""" return self.get_single_value(*args, **kwargs) - def _get_values_from_table(self, fields, filters, doctype, as_dict, debug, order_by=None, update=None, for_update=False): - fl = [] - if isinstance(fields, (list, tuple)): - for f in fields: - if "(" in f or " as " in f: # function - fl.append(f) + def _get_values_from_table(self, fields, filters, doctype, as_dict, debug, order_by=None, + update=None, for_update=False, run=True): + field_objects = [] + + if not isinstance(fields, Criterion): + for field in fields: + if "(" in field or " as " in field: + field_objects.append(PseudoColumn(field)) else: - fl.append("`" + f + "`") - fl = ", ".join(fl) + field_objects.append(field) + + criterion = self.query.build_conditions( + table=doctype, filters=filters, orderby=order_by, for_update=for_update + ) + if isinstance(fields, (list, tuple)): + query = criterion.select(*field_objects) + + elif isinstance(fields, Criterion): + query = criterion.select(fields) + else: - fl = fields if fields=="*": + query = criterion.select(fields) as_dict = True - - conditions, values = self.build_conditions(filters) - - order_by = ("order by " + order_by) if order_by else "" - - r = self.sql("select {fields} from `tab{doctype}` {where} {conditions} {order_by} {for_update}" - .format( - for_update = 'for update' if for_update else '', - fields = fl, - doctype = doctype, - where = "where" if conditions else "", - conditions = conditions, - order_by = order_by), - values, as_dict=as_dict, debug=debug, update=update) - + r = self.sql(query, as_dict=as_dict, debug=debug, update=update, run=run) return r - def _get_value_for_many_names(self, doctype, names, field, debug=False): + def _get_value_for_many_names(self, doctype, names, field, debug=False, run=True): names = list(filter(None, names)) if names: return self.get_all(doctype, fields=['name', field], filters=[['name', 'in', names]], - debug=debug, as_list=1) + debug=debug, as_list=1, run=run) else: return {} @@ -649,7 +618,7 @@ class Database(object): for key in to_update: set_values.append('`{0}`=%({0})s'.format(key)) - for name in self.get_values(dt, dn, 'name', for_update=for_update): + for name in self.get_values(dt, dn, 'name', for_update=for_update, debug=debug): values = dict(name=name[0]) values.update(to_update) @@ -820,24 +789,32 @@ class Database(object): except Exception: return None + def min(self, dt, fieldname, filters=None, **kwargs): + return self.query.build_conditions(dt, filters=filters).select(Min(Column(fieldname))).run(**kwargs)[0][0] or 0 + + def max(self, dt, fieldname, filters=None, **kwargs): + return self.query.build_conditions(dt, filters=filters).select(Max(Column(fieldname))).run(**kwargs)[0][0] or 0 + + def avg(self, dt, fieldname, filters=None, **kwargs): + return self.query.build_conditions(dt, filters=filters).select(Avg(Column(fieldname))).run(**kwargs)[0][0] or 0 + + def sum(self, dt, fieldname, filters=None, **kwargs): + return self.query.build_conditions(dt, filters=filters).select(Sum(Column(fieldname))).run(**kwargs)[0][0] or 0 + def count(self, dt, filters=None, debug=False, cache=False): """Returns `COUNT(*)` for given DocType and filters.""" if cache and not filters: cache_count = frappe.cache().get_value('doctype:count:{}'.format(dt)) if cache_count is not None: return cache_count + query = self.query.build_conditions(table=dt, filters=filters).select(Count("*")) if filters: - conditions, filters = self.build_conditions(filters) - count = self.sql("""select count(*) - from `tab%s` where %s""" % (dt, conditions), filters, debug=debug)[0][0] + count = self.sql(query, debug=debug)[0][0] return count else: - count = self.sql("""select count(*) - from `tab%s`""" % (dt,))[0][0] - + count = self.sql(query, debug=debug)[0][0] if cache: frappe.cache().set_value('doctype:count:{}'.format(dt), count, expires_in_sec = 86400) - return count @staticmethod @@ -896,13 +873,13 @@ class Database(object): WHERE table_name = 'tab{0}' AND column_name = '{1}' '''.format(doctype, column))[0][0] def has_index(self, table_name, index_name): - pass + raise NotImplementedError def add_index(self, doctype, fields, index_name=None): - pass + raise NotImplementedError def add_unique(self, doctype, fields, constraint_name=None): - pass + raise NotImplementedError @staticmethod def get_index_name(fields): @@ -928,7 +905,7 @@ class Database(object): def escape(s, percent=True): """Excape quotes and percent in given string.""" # implemented in specific class - pass + raise NotImplementedError @staticmethod def is_column_missing(e): @@ -953,15 +930,30 @@ class Database(object): query = sql_dict.get(current_dialect) return self.sql(query, values, **kwargs) - 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, debug=debug) - else: - frappe.throw(_('No conditions provided')) + def delete(self, doctype: str, filters: Union[Dict, List] = None, debug=False, **kwargs): + """Delete rows from a table in site which match the passed filters. This + does trigger DocType hooks. Simply runs a DELETE query in the database. + + Doctype name can be passed directly, it will be pre-pended with `tab`. + """ + values = () + filters = filters or kwargs.get("conditions") + query = self.query.build_conditions(table=doctype, filters=filters).delete() + if "debug" not in kwargs: + kwargs["debug"] = debug + return self.sql(query, values, **kwargs) + + def truncate(self, doctype: str): + """Truncate a table in the database. This runs a DDL command `TRUNCATE TABLE`. + This cannot be rolled back. + + Doctype name can be passed directly, it will be pre-pended with `tab`. + """ + table = doctype if doctype.startswith("__") else f"tab{doctype}" + return self.sql_ddl(f"truncate `{table}`") + + def clear_table(self, doctype): + return self.truncate(doctype) def get_last_created(self, doctype): last_record = self.get_all(doctype, ('creation'), limit=1, order_by='creation desc') @@ -970,9 +962,6 @@ class Database(object): else: return None - def clear_table(self, doctype): - self.sql('truncate `tab{}`'.format(doctype)) - def log_touched_tables(self, query, values=None): if values: query = frappe.safe_decode(self._cursor.mogrify(query, values)) @@ -1023,6 +1012,7 @@ class Database(object): ), tuple(insert_list)) insert_list = [] + def enqueue_jobs_after_commit(): from frappe.utils.background_jobs import execute_job, get_queue @@ -1032,19 +1022,3 @@ def enqueue_jobs_after_commit(): q.enqueue_call(execute_job, timeout=job.get("timeout"), kwargs=job.get("queue_args")) frappe.flags.enqueue_after_commit = [] - -# Helpers -def _cast_result(doctype, result): - batch = [ ] - - try: - for field, value in result: - df = frappe.get_meta(doctype).get_field(field) - if df: - value = cast_fieldtype(df.fieldtype, value) - - batch.append(tuple([field, value])) - except frappe.exceptions.DoesNotExistError: - return result - - return tuple(batch) diff --git a/frappe/database/mariadb/database.py b/frappe/database/mariadb/database.py index 879c8394d7..2f6d640743 100644 --- a/frappe/database/mariadb/database.py +++ b/frappe/database/mariadb/database.py @@ -1,3 +1,5 @@ +from typing import List, Tuple, Union + import pymysql from pymysql.constants import ER, FIELD_TYPE from pymysql.converters import conversions, escape_string @@ -5,7 +7,7 @@ from pymysql.converters import conversions, escape_string import frappe from frappe.database.database import Database from frappe.database.mariadb.schema import MariaDBTable -from frappe.utils import UnicodeWithAttrs, cstr, get_datetime +from frappe.utils import UnicodeWithAttrs, cstr, get_datetime, get_table_name class MariaDBDatabase(Database): @@ -20,11 +22,11 @@ class MariaDBDatabase(Database): def setup_type_map(self): self.db_type = 'mariadb' self.type_map = { - 'Currency': ('decimal', '18,6'), + 'Currency': ('decimal', '21,9'), 'Int': ('int', '11'), 'Long Int': ('bigint', '20'), - 'Float': ('decimal', '18,6'), - 'Percent': ('decimal', '18,6'), + 'Float': ('decimal', '21,9'), + 'Percent': ('decimal', '21,9'), 'Check': ('int', '1'), 'Small Text': ('text', ''), 'Long Text': ('longtext', ''), @@ -49,7 +51,8 @@ class MariaDBDatabase(Database): 'Color': ('varchar', self.VARCHAR_LEN), 'Barcode': ('longtext', ''), 'Geolocation': ('longtext', ''), - 'Duration': ('decimal', '18,6') + 'Duration': ('decimal', '21,9'), + 'Icon': ('varchar', self.VARCHAR_LEN) } def get_connection(self): @@ -123,6 +126,19 @@ class MariaDBDatabase(Database): def is_type_datetime(code): return code in (pymysql.DATE, pymysql.DATETIME) + def rename_table(self, old_name: str, new_name: str) -> Union[List, Tuple]: + old_name = get_table_name(old_name) + new_name = get_table_name(new_name) + return self.sql(f"RENAME TABLE `{old_name}` TO `{new_name}`") + + def describe(self, doctype: str) -> Union[List, Tuple]: + table_name = get_table_name(doctype) + return self.sql(f"DESC `{table_name}`") + + def change_column_type(self, doctype: str, column: str, type: str) -> Union[List, Tuple]: + table_name = get_table_name(doctype) + return self.sql(f"ALTER TABLE `{table_name}` MODIFY `{column}` {type} NOT NULL") + # exception types @staticmethod def is_deadlocked(e): @@ -179,7 +195,7 @@ class MariaDBDatabase(Database): `password` TEXT NOT NULL, `encrypted` INT(1) NOT NULL DEFAULT 0, PRIMARY KEY (`doctype`, `name`, `fieldname`) - ) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci""") + ) ENGINE=InnoDB ROW_FORMAT=DYNAMIC CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci""") def create_global_search_table(self): if not '__global_search' in self.get_tables(): @@ -240,11 +256,11 @@ class MariaDBDatabase(Database): index_name=index_name )) - def add_index(self, doctype, fields, index_name=None): + def add_index(self, doctype: str, fields: List, index_name: str = None): """Creates an index with given fields if not already created. Index name will be `fieldname1_fieldname2_index`""" index_name = index_name or self.get_index_name(fields) - table_name = 'tab' + doctype + table_name = get_table_name(doctype) if not self.has_index(table_name, index_name): self.commit() self.sql("""ALTER TABLE `%s` diff --git a/frappe/database/mariadb/framework_mariadb.sql b/frappe/database/mariadb/framework_mariadb.sql index a52efd01e3..73b98f0ff3 100644 --- a/frappe/database/mariadb/framework_mariadb.sql +++ b/frappe/database/mariadb/framework_mariadb.sql @@ -61,6 +61,7 @@ CREATE TABLE `tabDocField` ( `in_preview` int(1) NOT NULL DEFAULT 0, `read_only` int(1) NOT NULL DEFAULT 0, `precision` varchar(255) DEFAULT NULL, + `max_height` varchar(10) DEFAULT NULL, `length` int(11) NOT NULL DEFAULT 0, `translatable` int(1) NOT NULL DEFAULT 0, `hide_border` int(1) NOT NULL DEFAULT 0, @@ -71,7 +72,7 @@ CREATE TABLE `tabDocField` ( KEY `label` (`label`), KEY `fieldtype` (`fieldtype`), KEY `fieldname` (`fieldname`) -) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- @@ -108,7 +109,7 @@ CREATE TABLE `tabDocPerm` ( `email` int(1) NOT NULL DEFAULT 1, PRIMARY KEY (`name`), KEY `parent` (`parent`) -) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- -- Table structure for table `tabDocType Action` @@ -132,7 +133,7 @@ CREATE TABLE `tabDocType Action` ( PRIMARY KEY (`name`), KEY `parent` (`parent`), KEY `modified` (`modified`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=COMPRESSED; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; -- -- Table structure for table `tabDocType Action` @@ -155,7 +156,7 @@ CREATE TABLE `tabDocType Link` ( PRIMARY KEY (`name`), KEY `parent` (`parent`), KEY `modified` (`modified`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=COMPRESSED; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; -- -- Table structure for table `tabDocType` @@ -183,6 +184,7 @@ CREATE TABLE `tabDocType` ( `restrict_to_domain` varchar(255) DEFAULT NULL, `app` varchar(255) DEFAULT NULL, `autoname` varchar(255) DEFAULT NULL, + `naming_rule` varchar(40) DEFAULT NULL, `name_case` varchar(255) DEFAULT NULL, `title_field` varchar(255) DEFAULT NULL, `image_field` varchar(255) DEFAULT NULL, @@ -220,12 +222,14 @@ CREATE TABLE `tabDocType` ( `allow_guest_to_view` int(1) NOT NULL DEFAULT 0, `route` varchar(255) DEFAULT NULL, `is_published_field` varchar(255) DEFAULT NULL, + `website_search_field` varchar(255) DEFAULT NULL, `email_append_to` int(1) NOT NULL DEFAULT 0, `subject_field` varchar(255) DEFAULT NULL, `sender_field` varchar(255) DEFAULT NULL, + `migration_hash` varchar(255) DEFAULT NULL, PRIMARY KEY (`name`), KEY `parent` (`parent`) -) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- -- Table structure for table `tabSeries` @@ -236,7 +240,7 @@ CREATE TABLE `tabSeries` ( `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; +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- @@ -253,7 +257,7 @@ CREATE TABLE `tabSessions` ( `device` varchar(255) DEFAULT 'desktop', `status` varchar(20) DEFAULT NULL, KEY `sid` (`sid`) -) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- @@ -266,7 +270,7 @@ CREATE TABLE `tabSingles` ( `field` varchar(255) DEFAULT NULL, `value` text, KEY `singles_doctype_field_index` (`doctype`, `field`) -) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- -- Table structure for table `__Auth` @@ -280,7 +284,7 @@ CREATE TABLE `__Auth` ( `password` TEXT NOT NULL, `encrypted` INT(1) NOT NULL DEFAULT 0, PRIMARY KEY (`doctype`, `name`, `fieldname`) -) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- -- Table structure for table `tabFile` @@ -308,7 +312,7 @@ CREATE TABLE `tabFile` ( KEY `parent` (`parent`), KEY `attached_to_name` (`attached_to_name`), KEY `attached_to_doctype` (`attached_to_doctype`) -) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- -- Table structure for table `tabDefaultValue` @@ -331,4 +335,4 @@ CREATE TABLE `tabDefaultValue` ( PRIMARY KEY (`name`), KEY `parent` (`parent`), KEY `defaultvalue_parent_defkey_index` (`parent`,`defkey`) -) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB ROW_FORMAT=DYNAMIC CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/frappe/database/mariadb/schema.py b/frappe/database/mariadb/schema.py index b40af59286..5768a2f23d 100644 --- a/frappe/database/mariadb/schema.py +++ b/frappe/database/mariadb/schema.py @@ -4,18 +4,22 @@ from frappe.database.schema import DBTable class MariaDBTable(DBTable): def create(self): - add_text = '' + additional_definitions = "" + engine = self.meta.get("engine") or "InnoDB" + varchar_len = frappe.db.VARCHAR_LEN # columns column_defs = self.get_column_definitions() - if column_defs: add_text += ',\n'.join(column_defs) + ',\n' + if column_defs: + additional_definitions += ',\n'.join(column_defs) + ',\n' # index index_defs = self.get_index_definitions() - if index_defs: add_text += ',\n'.join(index_defs) + ',\n' + if index_defs: + additional_definitions += ',\n'.join(index_defs) + ',\n' # create table - frappe.db.sql("""create table `%s` ( + query = f"""create table `{self.table_name}` ( name varchar({varchar_len}) not null primary key, creation datetime(6), modified datetime(6), @@ -26,13 +30,15 @@ class MariaDBTable(DBTable): parentfield varchar({varchar_len}), parenttype varchar({varchar_len}), idx int(8) not null default '0', - %sindex parent(parent), + {additional_definitions} + index parent(parent), index modified(modified)) ENGINE={engine} - ROW_FORMAT=COMPRESSED + ROW_FORMAT=DYNAMIC CHARACTER SET=utf8mb4 - COLLATE=utf8mb4_unicode_ci""".format(varchar_len=frappe.db.VARCHAR_LEN, - engine=self.meta.get("engine") or 'InnoDB') % (self.table_name, add_text)) + COLLATE=utf8mb4_unicode_ci""" + + frappe.db.sql(query) def alter(self): for col in self.columns.values(): diff --git a/frappe/database/mariadb/setup_db.py b/frappe/database/mariadb/setup_db.py index 6be08c66bb..8088cc2331 100644 --- a/frappe/database/mariadb/setup_db.py +++ b/frappe/database/mariadb/setup_db.py @@ -34,25 +34,23 @@ def setup_database(force, source_sql, verbose, no_mariadb_socket=False): db_name = frappe.local.conf.db_name root_conn = get_root_connection(frappe.flags.root_login, frappe.flags.root_password) dbman = DbManager(root_conn) + dbman_kwargs = {} + if no_mariadb_socket: + dbman_kwargs["host"] = "%" + if force or (db_name not in dbman.get_database_list()): - dbman.delete_user(db_name) - if no_mariadb_socket: - dbman.delete_user(db_name, host="%") + dbman.delete_user(db_name, **dbman_kwargs) dbman.drop_database(db_name) else: raise Exception("Database %s already exists" % (db_name,)) - dbman.create_user(db_name, frappe.conf.db_password) - if no_mariadb_socket: - dbman.create_user(db_name, frappe.conf.db_password, host="%") + dbman.create_user(db_name, frappe.conf.db_password, **dbman_kwargs) if verbose: print("Created user %s" % db_name) dbman.create_database(db_name) if verbose: print("Created database %s" % db_name) - dbman.grant_all_privileges(db_name, db_name) - if no_mariadb_socket: - dbman.grant_all_privileges(db_name, db_name, host="%") + dbman.grant_all_privileges(db_name, db_name, **dbman_kwargs) dbman.flush_privileges() if verbose: print("Granted privileges to user %s and database %s" % (db_name, db_name)) diff --git a/frappe/database/postgres/database.py b/frappe/database/postgres/database.py index 8235277e30..bfa5515111 100644 --- a/frappe/database/postgres/database.py +++ b/frappe/database/postgres/database.py @@ -1,12 +1,15 @@ import re -import frappe +from typing import List, Tuple, Union + import psycopg2 import psycopg2.extensions -from frappe.utils import cstr from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT +from psycopg2.errorcodes import STRING_DATA_RIGHT_TRUNCATION +import frappe from frappe.database.database import Database from frappe.database.postgres.schema import PostgresTable +from frappe.utils import cstr, get_table_name # cast decimals as floats DEC2FLOAT = psycopg2.extensions.new_type( @@ -29,11 +32,11 @@ class PostgresDatabase(Database): def setup_type_map(self): self.db_type = 'postgres' self.type_map = { - 'Currency': ('decimal', '18,6'), + 'Currency': ('decimal', '21,9'), 'Int': ('bigint', None), 'Long Int': ('bigint', None), - 'Float': ('decimal', '18,6'), - 'Percent': ('decimal', '18,6'), + 'Float': ('decimal', '21,9'), + 'Percent': ('decimal', '21,9'), 'Check': ('smallint', None), 'Small Text': ('text', ''), 'Long Text': ('text', ''), @@ -58,7 +61,8 @@ class PostgresDatabase(Database): 'Color': ('varchar', self.VARCHAR_LEN), 'Barcode': ('text', ''), 'Geolocation': ('text', ''), - 'Duration': ('decimal', '18,6') + 'Duration': ('decimal', '21,9'), + 'Icon': ('varchar', self.VARCHAR_LEN) } def get_connection(self): @@ -168,7 +172,20 @@ class PostgresDatabase(Database): @staticmethod def is_data_too_long(e): - return e.pgcode == '22001' + return e.pgcode == STRING_DATA_RIGHT_TRUNCATION + + def rename_table(self, old_name: str, new_name: str) -> Union[List, Tuple]: + old_name = get_table_name(old_name) + new_name = get_table_name(new_name) + return self.sql(f"ALTER TABLE `{old_name}` RENAME TO `{new_name}`") + + def describe(self, doctype: str)-> Union[List, Tuple]: + table_name = get_table_name(doctype) + return self.sql(f"SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_NAME = '{table_name}'") + + def change_column_type(self, doctype: str, column: str, type: str) -> Union[List, Tuple]: + table_name = get_table_name(doctype) + return self.sql(f'ALTER TABLE "{table_name}" ALTER COLUMN "{column}" TYPE {type}') def create_auth_table(self): self.sql_ddl("""create table if not exists "__Auth" ( @@ -242,14 +259,14 @@ class PostgresDatabase(Database): return self.sql("""SELECT 1 FROM pg_indexes WHERE tablename='{table_name}' and indexname='{index_name}' limit 1""".format(table_name=table_name, index_name=index_name)) - def add_index(self, doctype, fields, index_name=None): + def add_index(self, doctype: str, fields: List, index_name: str = None): """Creates an index with given fields if not already created. Index name will be `fieldname1_fieldname2_index`""" + table_name = get_table_name(doctype) index_name = index_name or self.get_index_name(fields) - table_name = 'tab' + doctype + fields_str = '", "'.join(re.sub(r"\(.*\)", "", field) for field in fields) - self.commit() - self.sql("""CREATE INDEX IF NOT EXISTS "{}" ON `{}`("{}")""".format(index_name, table_name, '", "'.join(fields))) + self.sql_ddl(f'CREATE INDEX IF NOT EXISTS "{index_name}" ON `{table_name}` ("{fields_str}")') def add_unique(self, doctype, fields, constraint_name=None): if isinstance(fields, str): @@ -297,6 +314,7 @@ class PostgresDatabase(Database): def modify_query(query): """"Modifies query according to the requirements of postgres""" # replace ` with " for definitions + query = str(query) query = query.replace('`', '"') query = replace_locate_with_strpos(query) # select from requires "" diff --git a/frappe/database/postgres/framework_postgres.sql b/frappe/database/postgres/framework_postgres.sql index eeb0eecd3f..e8e047f194 100644 --- a/frappe/database/postgres/framework_postgres.sql +++ b/frappe/database/postgres/framework_postgres.sql @@ -61,6 +61,7 @@ CREATE TABLE "tabDocField" ( "in_preview" smallint NOT NULL DEFAULT 0, "read_only" smallint NOT NULL DEFAULT 0, "precision" varchar(255) DEFAULT NULL, + "max_height" varchar(10) DEFAULT NULL, "length" bigint NOT NULL DEFAULT 0, "translatable" smallint NOT NULL DEFAULT 0, "hide_border" smallint NOT NULL DEFAULT 0, @@ -188,6 +189,7 @@ CREATE TABLE "tabDocType" ( "restrict_to_domain" varchar(255) DEFAULT NULL, "app" varchar(255) DEFAULT NULL, "autoname" varchar(255) DEFAULT NULL, + "naming_rule" varchar(40) DEFAULT NULL, "name_case" varchar(255) DEFAULT NULL, "title_field" varchar(255) DEFAULT NULL, "image_field" varchar(255) DEFAULT NULL, @@ -225,9 +227,11 @@ CREATE TABLE "tabDocType" ( "allow_guest_to_view" smallint NOT NULL DEFAULT 0, "route" varchar(255) DEFAULT NULL, "is_published_field" varchar(255) DEFAULT NULL, + "website_search_field" varchar(255) DEFAULT NULL, "email_append_to" smallint NOT NULL DEFAULT 0, "subject_field" varchar(255) DEFAULT NULL, "sender_field" varchar(255) DEFAULT NULL, + "migration_hash" varchar(255) DEFAULT NULL, PRIMARY KEY ("name") ) ; diff --git a/frappe/database/query.py b/frappe/database/query.py new file mode 100644 index 0000000000..3545efb412 --- /dev/null +++ b/frappe/database/query.py @@ -0,0 +1,267 @@ +import operator +from typing import Any, Dict, List, Tuple, Union + +import frappe +from frappe.query_builder import Criterion, Order, Field + + +def like(key: str, value: str) -> frappe.qb: + """Wrapper method for `LIKE` + + Args: + key (str): field + value (str): criterion + + Returns: + frappe.qb: `frappe.qb object with `LIKE` + """ + return Field(key).like(value) + + +def func_in(key: str, value: Union[List, Tuple]) -> frappe.qb: + """Wrapper method for `IN` + + Args: + key (str): field + value (Union[int, str]): criterion + + Returns: + frappe.qb: `frappe.qb object with `IN` + """ + return Field(key).isin(value) + + +def not_like(key: str, value: str) -> frappe.qb: + """Wrapper method for `NOT LIKE` + + Args: + key (str): field + value (str): criterion + + Returns: + frappe.qb: `frappe.qb object with `NOT LIKE` + """ + return Field(key).not_like(value) + + +def func_not_in(key: str, value: Union[List, Tuple]): + """Wrapper method for `NOT IN` + + Args: + key (str): field + value (Union[int, str]): criterion + + Returns: + frappe.qb: `frappe.qb object with `NOT IN` + """ + return Field(key).notin(value) + + +def func_regex(key: str, value: str) -> frappe.qb: + """Wrapper method for `REGEX` + + Args: + key (str): field + value (str): criterion + + Returns: + frappe.qb: `frappe.qb object with `REGEX` + """ + return Field(key).regex(value) + + +def func_between(key: str, value: Union[List, Tuple]) -> frappe.qb: + """Wrapper method for `BETWEEN` + + Args: + key (str): field + value (Union[int, str]): criterion + + Returns: + frappe.qb: `frappe.qb object with `BETWEEN` + """ + return Field(key)[slice(*value)] + +def make_function(key: Any, value: Union[int, str]): + """returns fucntion query + + Args: + key (Any): field + value (Union[int, str]): criterion + + Returns: + frappe.qb: frappe.qb object + """ + return OPERATOR_MAP[value[0]](key, value[1]) + + +def change_orderby(order: str): + """Convert orderby to standart Order object + + Args: + order (str): Field, order + + Returns: + tuple: field, order + """ + order = order.split() + if order[1].lower() == "asc": + orderby, order = order[0], Order.asc + return orderby, order + orderby, order = order[0], Order.desc + return orderby, order + + +OPERATOR_MAP = { + "+": operator.add, + "=": operator.eq, + "-": operator.sub, + "!=": operator.ne, + "<": operator.lt, + ">": operator.gt, + "<=": operator.le, + ">=": operator.ge, + "in": func_in, + "not in": func_not_in, + "like": like, + "not like": not_like, + "regex": func_regex, + "between": func_between + } + + +class Query: + def get_condition(self, table: str, **kwargs) -> frappe.qb: + """Get initial table object + + Args: + table (str): DocType + + Returns: + frappe.qb: DocType with initial condition + """ + if kwargs.get("update"): + return frappe.qb.update(table) + if kwargs.get("into"): + return frappe.qb.into(table) + return frappe.qb.from_(table) + + def criterion_query(self, table: str, criterion: Criterion, **kwargs) -> frappe.qb: + """Generate filters from Criterion objects + + Args: + table (str): DocType + criterion (Criterion): Filters + + Returns: + frappe.qb: condition object + """ + condition = self.add_conditions(self.get_condition(table, **kwargs), **kwargs) + return condition.where(criterion) + + def add_conditions(self, conditions: frappe.qb, **kwargs): + """Adding additional conditions + + Args: + conditions (frappe.qb): built conditions + + Returns: + conditions (frappe.qb): frappe.qb object + """ + if kwargs.get("orderby"): + orderby = kwargs.get("orderby") + order = kwargs.get("order") if kwargs.get("order") else Order.desc + if isinstance(orderby, str) and len(orderby.split()) > 1: + orderby, order = change_orderby(orderby) + conditions = conditions.orderby(orderby, order=order) + + if kwargs.get("limit"): + conditions = conditions.limit(kwargs.get("limit")) + + if kwargs.get("distinct"): + conditions = conditions.distinct() + + if kwargs.get("for_update"): + conditions = conditions.for_update() + + return conditions + + def misc_query(self, table: str, filters: Union[List, Tuple] = None, **kwargs): + """Build conditions using the given Lists or Tuple filters + + Args: + table (str): DocType + filters (Union[List, Tuple], optional): Filters. Defaults to None. + """ + conditions = self.get_condition(table, **kwargs) + if not filters: + return conditions + if isinstance(filters, list): + for f in filters: + if not isinstance(f, (list, tuple)): + _operator = OPERATOR_MAP[filters[1]] + if not isinstance(filters[0], str): + conditions = make_function(filters[0], filters[2]) + break + conditions = conditions.where(_operator(Field(filters[0]), filters[2])) + break + else: + _operator = OPERATOR_MAP[f[1]] + conditions = conditions.where(_operator(Field(f[0]), f[2])) + + conditions = self.add_conditions(conditions, **kwargs) + return conditions + + def dict_query(self, table: str, filters: Dict[str, Union[str, int]] = None, **kwargs) -> frappe.qb: + """Build conditions using the given dictionary filters + + Args: + table (str): DocType + filters (Dict[str, Union[str, int]], optional): Filters. Defaults to None. + + Returns: + frappe.qb: conditions object + """ + conditions = self.get_condition(table, **kwargs) + if not filters: + return conditions + + for key in filters: + value = filters.get(key) + _operator = OPERATOR_MAP["="] + + if not isinstance(key, str): + conditions = conditions.where(make_function(key, value)) + continue + if isinstance(value, (list, tuple)): + if isinstance(value[1], (list, tuple)) or value[0] in list(OPERATOR_MAP.keys())[-4:]: + _operator = OPERATOR_MAP[value[0]] + conditions = conditions.where(_operator(key, value[1])) + else: + _operator = OPERATOR_MAP[value[0]] + conditions = conditions.where(_operator(Field(key), value[1])) + else: + conditions = conditions.where(_operator(Field(key), value)) + conditions = self.add_conditions(conditions, **kwargs) + return conditions + + def build_conditions(self, table: str, filters: Union[Dict[str, Union[str, int]], str, int] = None, **kwargs) -> frappe.qb: + """Build conditions for sql query + + Args: + filters (Union[Dict[str, Union[str, int]], str, int]): conditions in Dict + table (str): DocType + + Returns: + frappe.qb: frappe.qb conditions object + """ + if isinstance(filters, Criterion): + return self.criterion_query(table, filters, **kwargs) + + if isinstance(filters, int) or isinstance(filters, str): + filters = {"name": str(filters)} + + if isinstance(filters, (list, tuple)): + return self.misc_query(table, filters, **kwargs) + + return self.dict_query(filters=filters, table=table, **kwargs) diff --git a/frappe/database/schema.py b/frappe/database/schema.py index 31f11dbd5e..ce9fcb4147 100644 --- a/frappe/database/schema.py +++ b/frappe/database/schema.py @@ -303,6 +303,8 @@ def get_definition(fieldtype, precision=None, length=None): size = d[1] if d[1] else None if size: + # This check needs to exist for backward compatibility. + # Till V13, default size used for float, currency and percent are (18, 6). if fieldtype in ["Float", "Currency", "Percent"] and cint(precision) > 6: size = '21,9' diff --git a/frappe/defaults.py b/frappe/defaults.py index fde48d71ff..eb98db449f 100644 --- a/frappe/defaults.py +++ b/frappe/defaults.py @@ -1,9 +1,10 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe from frappe.desk.notifications import clear_notifications from frappe.cache_manager import clear_defaults_cache, common_default_keys +from frappe.query_builder import DocType # Note: DefaultValue records are identified by parenttype # __default, __global or 'User Permission' @@ -116,19 +117,15 @@ def set_default(key, value, parent, parenttype="__default"): :param value: Default value. :param parent: Usually, **User** to whom the default belongs. :param parenttype: [optional] default is `__default`.""" - if frappe.db.sql(''' - select - defkey - from - `tabDefaultValue` - where - defkey=%s and parent=%s - for update''', (key, parent)): - frappe.db.sql(""" - delete from - `tabDefaultValue` - where - defkey=%s and parent=%s""", (key, parent)) + table = DocType("DefaultValue") + key_exists = frappe.qb.from_(table).where( + (table.defkey == key) & (table.parent == parent) + ).select(table.defkey).for_update().run() + if key_exists: + frappe.db.delete("DefaultValue", { + "defkey": key, + "parent": parent + }) if value != None: add_default(key, value, parent) else: @@ -155,29 +152,23 @@ def clear_default(key=None, value=None, parent=None, name=None, parenttype=None) :param name: Default ID. :param parenttype: Clear defaults table for a particular type e.g. **User**. """ - conditions = [] - values = [] + filters = {} if name: - conditions.append("name=%s") - values.append(name) + filters.update({"name": name}) else: if key: - conditions.append("defkey=%s") - values.append(key) + filters.update({"defkey": key}) if value: - conditions.append("defvalue=%s") - values.append(value) + filters.update({"defvalue": value}) if parent: - conditions.append("parent=%s") - values.append(parent) + filters.update({"parent": parent}) if parenttype: - conditions.append("parenttype=%s") - values.append(parenttype) + filters.update({"parenttype": parenttype}) if parent: clear_defaults_cache(parent) @@ -185,11 +176,10 @@ def clear_default(key=None, value=None, parent=None, name=None, parenttype=None) clear_defaults_cache("__default") clear_defaults_cache("__global") - if not conditions: + if not filters: raise Exception("[clear_default] No key specified.") - frappe.db.sql("""delete from tabDefaultValue where {0}""".format(" and ".join(conditions)), - tuple(values)) + frappe.db.delete("DefaultValue", filters) _clear_cache(parent) @@ -199,8 +189,12 @@ def get_defaults_for(parent="__default"): if defaults==None: # sort descending because first default must get precedence - res = frappe.db.sql("""select defkey, defvalue from `tabDefaultValue` - where parent = %s order by creation""", (parent,), as_dict=1) + table = DocType("DefaultValue") + res = frappe.qb.from_(table).where( + table.parent == parent + ).select( + table.defkey, table.defvalue + ).orderby("creation").run(as_dict=True) defaults = frappe._dict({}) for d in res: diff --git a/frappe/desk/__init__.py b/frappe/desk/__init__.py index 0e57cb68c3..eb5ba62e5c 100644 --- a/frappe/desk/__init__.py +++ b/frappe/desk/__init__.py @@ -1,3 +1,3 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE diff --git a/frappe/desk/calendar.py b/frappe/desk/calendar.py index f00f729415..66e6dd8434 100644 --- a/frappe/desk/calendar.py +++ b/frappe/desk/calendar.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe from frappe import _ @@ -25,7 +25,6 @@ def get_event_conditions(doctype, filters=None): @frappe.whitelist() 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) @@ -36,8 +35,7 @@ def get_events(doctype, start, end, field_map, filters=None, fields=None): "color": d.fieldname }) - if filters: - filters = json.loads(filters or '') + filters = json.loads(filters) if filters else [] if not fields: fields = [field_map.start, field_map.end, field_map.title, 'name'] @@ -52,5 +50,5 @@ def get_events(doctype, start, end, field_map, filters=None, fields=None): [doctype, start_date, '<=', end], [doctype, end_date, '>=', start], ] - + fields = list({field for field in fields if field}) return frappe.get_list(doctype, fields=fields, filters=filters) diff --git a/frappe/desk/desk_page.py b/frappe/desk/desk_page.py index d373dbda0e..a01008280c 100644 --- a/frappe/desk/desk_page.py +++ b/frappe/desk/desk_page.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe from frappe.translate import send_translations diff --git a/frappe/desk/desktop.py b/frappe/desk/desktop.py index 0a7d436169..e1789852f1 100644 --- a/frappe/desk/desktop.py +++ b/frappe/desk/desktop.py @@ -1,11 +1,12 @@ # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE # Author - Shivam Mishra import frappe from json import loads, dumps from frappe import _, DoesNotExistError, ValidationError, _dict from frappe.boot import get_allowed_pages, get_allowed_reports +from frappe.core.doctype.custom_role.custom_role import get_custom_allowed_roles from functools import wraps from frappe.cache_manager import ( build_domain_restriced_doctype_cache, @@ -27,18 +28,18 @@ def handle_not_exist(fn): class Workspace: - def __init__(self, page_name, minimal=False): - self.page_name = page_name - self.extended_links = [] - self.extended_charts = [] - self.extended_shortcuts = [] + def __init__(self, page, minimal=False): + self.page_name = page.get('name') + self.page_title = page.get('title') + self.public_page = page.get('public') + self.workspace_manager = "Workspace Manager" in frappe.get_roles() self.user = frappe.get_user() self.allowed_modules = self.get_cached('user_allowed_modules', self.get_allowed_modules) - self.doc = self.get_page_for_user() + self.doc = frappe.get_cached_doc("Workspace", self.page_name) - if self.doc.module and self.doc.module not in self.allowed_modules: + if self.doc and self.doc.module and self.doc.module not in self.allowed_modules and not self.workspace_manager: raise frappe.PermissionError self.can_read = self.get_cached('user_perm_can_read', self.get_can_read_items) @@ -47,16 +48,17 @@ class Workspace: self.allowed_reports = get_allowed_reports(cache=True) if not minimal: - self.onboarding_doc = self.get_onboarding_doc() - self.onboarding = None + if self.doc.content: + self.onboarding_list = [x['data']['onboarding_name'] for x in loads(self.doc.content) if x['type'] == 'onboarding'] + self.onboardings = [] self.table_counts = get_table_with_counts() self.restricted_doctypes = frappe.cache().get_value("domain_restricted_doctypes") or build_domain_restriced_doctype_cache() self.restricted_pages = frappe.cache().get_value("domain_restricted_pages") or build_domain_restriced_page_cache() def is_page_allowed(self): - cards = self.doc.get_link_groups() + get_custom_reports_and_doctypes(self.doc.module) + self.extended_links - shortcuts = self.doc.shortcuts + self.extended_shortcuts + cards = self.doc.get_link_groups() + get_custom_reports_and_doctypes(self.doc.module) + shortcuts = self.doc.shortcuts for section in cards: links = loads(section.get('links')) if isinstance(section.get('links'), str) else section.get('links') @@ -74,8 +76,28 @@ class Workspace: if self.is_item_allowed(item.link_to, item.type) and _in_active_domains(item): return True + if not shortcuts and not self.doc.links: + return True + return False + def is_permitted(self): + """Returns true if Has Role is not set or the user is allowed.""" + from frappe.utils import has_common + + allowed = [d.role for d in frappe.get_all("Has Role", fields=["role"], filters={"parent": self.doc.name})] + + custom_roles = get_custom_allowed_roles('page', self.doc.name) + allowed.extend(custom_roles) + + if not allowed: + return True + + roles = frappe.get_roles() + + if has_common(roles, allowed): + return True + def get_cached(self, cache_key, fallback_fn): _cache = frappe.cache() @@ -101,39 +123,18 @@ class Workspace: return self.user.allow_modules - def get_page_for_user(self): - filters = { - 'extends': self.page_name, - 'for_user': frappe.session.user - } - user_pages = frappe.get_all("Workspace", filters=filters, limit=1) - if user_pages: - return frappe.get_cached_doc("Workspace", user_pages[0]) - - filters = { - 'extends_another_page': 1, - 'extends': self.page_name, - 'is_default': 1 - } - default_page = frappe.get_all("Workspace", filters=filters, limit=1) - if default_page: - return frappe.get_cached_doc("Workspace", default_page[0]) - - self.get_pages_to_extend() - return frappe.get_cached_doc("Workspace", self.page_name) - - def get_onboarding_doc(self): + def get_onboarding_doc(self, onboarding): # Check if onboarding is enabled if not frappe.get_system_settings("enable_onboarding"): return None - if not self.doc.onboarding: + if not self.onboarding_list: return None - if frappe.db.get_value("Module Onboarding", self.doc.onboarding, "is_complete"): + if frappe.db.get_value("Module Onboarding", onboarding, "is_complete"): return None - doc = frappe.get_doc("Module Onboarding", self.doc.onboarding) + doc = frappe.get_doc("Module Onboarding", onboarding) # Check if user is allowed allowed_roles = set(doc.get_allowed_roles()) @@ -147,21 +148,6 @@ class Workspace: return doc - def get_pages_to_extend(self): - pages = frappe.get_all("Workspace", filters={ - "extends": self.page_name, - 'restrict_to_domain': ['in', frappe.get_active_domains()], - 'for_user': '', - 'module': ['in', self.allowed_modules] - }) - - pages = [frappe.get_cached_doc("Workspace", page['name']) for page in pages] - - for page in pages: - self.extended_links = self.extended_links + page.get_link_groups() - self.extended_charts = self.extended_charts + page.charts - self.extended_shortcuts = self.extended_shortcuts + page.shortcuts - def is_item_allowed(self, name, item_type): if frappe.session.user == "Administrator": return True @@ -183,28 +169,20 @@ class Workspace: def build_workspace(self): self.cards = { - 'label': _(self.doc.cards_label), 'items': self.get_links() } self.charts = { - 'label': _(self.doc.charts_label), 'items': self.get_charts() } self.shortcuts = { - 'label': _(self.doc.shortcuts_label), 'items': self.get_shortcuts() } - if self.onboarding_doc: - self.onboarding = { - 'label': _(self.onboarding_doc.title), - 'subtitle': _(self.onboarding_doc.subtitle), - 'success': _(self.onboarding_doc.success_message), - 'docs_url': self.onboarding_doc.documentation_url, - 'items': self.get_onboarding_steps() - } + self.onboardings = { + 'items': self.get_onboardings() + } def _doctype_contains_a_record(self, name): exists = self.table_counts.get(name, False) @@ -250,9 +228,6 @@ class Workspace: if not self.doc.hide_custom: cards = cards + get_custom_reports_and_doctypes(self.doc.module) - if len(self.extended_links): - cards = merge_cards_based_on_label(cards + self.extended_links) - default_country = frappe.db.get_default("country") new_data = [] @@ -290,8 +265,6 @@ class Workspace: all_charts = [] if frappe.has_permission("Dashboard Chart", throw=False): charts = self.doc.charts - if len(self.extended_charts): - charts = charts + self.extended_charts for chart in charts: if frappe.has_permission('Dashboard Chart', doc=chart.chart_name): @@ -312,8 +285,6 @@ class Workspace: items = [] shortcuts = self.doc.shortcuts - if len(self.extended_shortcuts): - shortcuts = shortcuts + self.extended_shortcuts for item in shortcuts: new_item = item.as_dict().copy() @@ -333,9 +304,26 @@ class Workspace: return items @handle_not_exist - def get_onboarding_steps(self): + def get_onboardings(self): + if self.onboarding_list: + for onboarding in self.onboarding_list: + onboarding_doc = self.get_onboarding_doc(onboarding) + if onboarding_doc: + item = { + 'label': _(onboarding), + 'title': _(onboarding_doc.title), + 'subtitle': _(onboarding_doc.subtitle), + 'success': _(onboarding_doc.success_message), + 'docs_url': onboarding_doc.documentation_url, + 'items': self.get_onboarding_steps(onboarding_doc) + } + self.onboardings.append(item) + return self.onboardings + + @handle_not_exist + def get_onboarding_steps(self, onboarding_doc): steps = [] - for doc in self.onboarding_doc.get_steps(): + for doc in onboarding_doc.get_steps(): step = doc.as_dict().copy() step.label = _(doc.title) if step.action == "Create Entry": @@ -352,58 +340,64 @@ def get_desktop_page(page): on desk. Args: - page (string): page name + page (json): page data Returns: dict: dictionary of cards, charts and shortcuts to be displayed on website """ try: - wspace = Workspace(page) + wspace = Workspace(loads(page)) wspace.build_workspace() return { 'charts': wspace.charts, 'shortcuts': wspace.shortcuts, 'cards': wspace.cards, - 'onboarding': wspace.onboarding, - 'allow_customization': not wspace.doc.disable_user_customization + 'onboardings': wspace.onboardings } except DoesNotExistError: + frappe.log_error(frappe.get_traceback()) return {} @frappe.whitelist() -def get_desk_sidebar_items(): +def get_wspace_sidebar_items(): """Get list of sidebar items for desk""" + has_access = "Workspace Manager" in frappe.get_roles() # don't get domain restricted pages blocked_modules = frappe.get_doc('User', frappe.session.user).get_blocked_modules() + blocked_modules.append('Dummy Module') filters = { 'restrict_to_domain': ['in', frappe.get_active_domains()], - 'extends_another_page': 0, - 'for_user': '', 'module': ['not in', blocked_modules] } - if not frappe.local.conf.developer_mode: - filters['developer_mode_only'] = '0' + if has_access: + filters = [] - # pages sorted based on pinned to top and then by name - order_by = "pin_to_top desc, pin_to_bottom asc, name asc" - all_pages = frappe.get_all("Workspace", fields=["name", "category", "icon", "module"], - filters=filters, order_by=order_by, ignore_permissions=True) + # pages sorted based on sequence id + order_by = "sequence_id asc" + fields = ["name", "title", "for_user", "parent_page", "content", "public", "module", "icon"] + all_pages = frappe.get_all("Workspace", fields=fields, filters=filters, order_by=order_by, ignore_permissions=True) pages = [] + private_pages = [] # Filter Page based on Permission for page in all_pages: try: - wspace = Workspace(page.get('name'), True) - if wspace.is_page_allowed(): - pages.append(page) + wspace = Workspace(page, True) + if wspace.is_permitted() and wspace.is_page_allowed() or has_access: + if page.public: + pages.append(page) + elif page.for_user == frappe.session.user: + private_pages.append(page) page['label'] = _(page.get('name')) except frappe.PermissionError: pass + if private_pages: + pages.extend(private_pages) - return pages + return {'pages': pages, 'has_access': has_access} def get_table_with_counts(): counts = frappe.cache().get_value("information_schema:counts") @@ -438,7 +432,6 @@ def get_custom_doctype_list(module): return out - def get_custom_report_list(module): """Returns list on new style reports for modules.""" reports = frappe.get_all("Report", fields=["name", "ref_doctype", "report_type"], filters= @@ -451,6 +444,7 @@ def get_custom_report_list(module): "type": "Link", "link_type": "report", "doctype": r.ref_doctype, + "dependencies": r.ref_doctype, "is_query_report": 1 if r.report_type in ("Query Report", "Script Report", "Custom Report") else 0, "label": _(r.name), "link_to": r.name, @@ -458,73 +452,26 @@ def get_custom_report_list(module): return out -def get_custom_workspace_for_user(page): - """Get custom page from workspace if exists or create one +def save_new_widget(doc, page, blocks, new_widgets): - Args: - page (stirng): Page name + widgets = _dict(loads(new_widgets)) - Returns: - Object: Document object - """ - filters = { - 'extends': page, - 'for_user': frappe.session.user - } - pages = frappe.get_list("Workspace", filters=filters) - if pages: - return frappe.get_doc("Workspace", pages[0]) - doc = frappe.new_doc("Workspace") - doc.extends = page - doc.for_user = frappe.session.user - return doc + if widgets.chart: + doc.charts.extend(new_widget(widgets.chart, "Workspace Chart", "charts")) + if widgets.shortcut: + doc.shortcuts.extend(new_widget(widgets.shortcut, "Workspace Shortcut", "shortcuts")) + if widgets.card: + doc.build_links_table_from_card(widgets.card) - -@frappe.whitelist() -def save_customization(page, config): - """Save customizations as a separate doctype in Workspace per user - - Args: - page (string): Name of the page to be edited - config (dict): Dictionary config of al widgets - - Returns: - Boolean: Customization saving status - """ - original_page = frappe.get_doc("Workspace", page) - page_doc = get_custom_workspace_for_user(page) - - # Update field values - page_doc.update({ - "icon": original_page.icon, - "charts_label": original_page.charts_label, - "cards_label": original_page.cards_label, - "shortcuts_label": original_page.shortcuts_label, - "module": original_page.module, - "onboarding": original_page.onboarding, - "developer_mode_only": original_page.developer_mode_only, - "category": original_page.category - }) - - config = _dict(loads(config)) - if config.charts: - page_doc.charts = prepare_widget(config.charts, "Workspace Chart", "charts") - if config.shortcuts: - page_doc.shortcuts = prepare_widget(config.shortcuts, "Workspace Shortcut", "shortcuts") - if config.cards: - page_doc.build_links_table_from_cards(config.cards) - - # Set label - page_doc.label = page + '-' + frappe.session.user + # remove duplicate and unwanted widgets + if widgets: + clean_up(doc, blocks) try: - if page_doc.is_new(): - page_doc.insert(ignore_permissions=True) - else: - page_doc.save(ignore_permissions=True) + doc.save(ignore_permissions=True) except (ValidationError, TypeError) as e: # Create a json string to log - json_config = dumps(config, sort_keys=True, indent=4) + json_config = dumps(widgets, sort_keys=True, indent=4) # Error log body log = \ @@ -538,6 +485,48 @@ def save_customization(page, config): return True +def clean_up(original_page, blocks): + page_widgets = {} + + for wid in ['shortcut', 'card', 'chart']: + # get list of widget's name from blocks + page_widgets[wid] = [x['data'][wid + '_name'] for x in loads(blocks) if x['type'] == wid] + + # shortcut & chart cleanup + for wid in ['shortcut', 'chart']: + updated_widgets = [] + original_page.get(wid+'s').reverse() + + for w in original_page.get(wid+'s'): + if w.label in page_widgets[wid] and w.label not in [x.label for x in updated_widgets]: + updated_widgets.append(w) + original_page.set(wid+'s', updated_widgets) + + # card cleanup + for i, v in enumerate(original_page.links): + if v.type == 'Card Break' and v.label not in page_widgets['card']: + del original_page.links[i : i+v.link_count+1] + +def new_widget(config, doctype, parentfield): + if not config: + return [] + prepare_widget_list = [] + for idx, widget in enumerate(config): + # Some cleanup + widget.pop("name", None) + + # New Doc + doc = frappe.new_doc(doctype) + doc.update(widget) + + # Manually Set IDX + doc.idx = idx + 1 + + # Set Parent Field + doc.parentfield = parentfield + + prepare_widget_list.append(doc) + return prepare_widget_list def prepare_widget(config, doctype, parentfield): """Create widget child table entries with parent details @@ -573,40 +562,14 @@ def prepare_widget(config, doctype, parentfield): prepare_widget_list.append(doc) return prepare_widget_list - @frappe.whitelist() def update_onboarding_step(name, field, value): """Update status of onboaridng step Args: - name (string): Name of the doc - field (string): field to be updated - value: Value to be updated + name (string): Name of the doc + field (string): field to be updated + value: Value to be updated """ frappe.db.set_value("Onboarding Step", name, field, value) - -@frappe.whitelist() -def reset_customization(page): - """Reset workspace customizations for a user - - Args: - page (string): Name of the page to be reset - """ - page_doc = get_custom_workspace_for_user(page) - page_doc.delete() - -def merge_cards_based_on_label(cards): - """Merge cards with common label.""" - cards_dict = {} - for card in cards: - label = card.get('label') - if label in cards_dict: - links = cards_dict[label].links + card.links - cards_dict[label].update(dict(links=links)) - cards_dict[label] = cards_dict.pop(label) - else: - cards_dict[label] = card - - return list(cards_dict.values()) - diff --git a/frappe/desk/doctype/bulk_update/bulk_update.py b/frappe/desk/doctype/bulk_update/bulk_update.py index 469ee839f1..b512ca175c 100644 --- a/frappe/desk/doctype/bulk_update/bulk_update.py +++ b/frappe/desk/doctype/bulk_update/bulk_update.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/desk/doctype/calendar_view/calendar_view.py b/frappe/desk/doctype/calendar_view/calendar_view.py index 3a986f3273..11612f5587 100644 --- a/frappe/desk/doctype/calendar_view/calendar_view.py +++ b/frappe/desk/doctype/calendar_view/calendar_view.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE from frappe.model.document import Document diff --git a/frappe/desk/doctype/console_log/console_log.py b/frappe/desk/doctype/console_log/console_log.py index 5d0f1cfa93..e0b552ebfd 100644 --- a/frappe/desk/doctype/console_log/console_log.py +++ b/frappe/desk/doctype/console_log/console_log.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/desk/doctype/console_log/test_console_log.py b/frappe/desk/doctype/console_log/test_console_log.py index 3bb1605204..c41b9d68c8 100644 --- a/frappe/desk/doctype/console_log/test_console_log.py +++ b/frappe/desk/doctype/console_log/test_console_log.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE # import frappe import unittest diff --git a/frappe/desk/doctype/dashboard/dashboard.py b/frappe/desk/doctype/dashboard/dashboard.py index 1d333609db..0dfd458a37 100644 --- a/frappe/desk/doctype/dashboard/dashboard.py +++ b/frappe/desk/doctype/dashboard/dashboard.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE from frappe.model.document import Document from frappe.modules.export_file import export_to_files diff --git a/frappe/desk/doctype/dashboard/test_dashboard.py b/frappe/desk/doctype/dashboard/test_dashboard.py index dd1bc31d86..15c132c027 100644 --- a/frappe/desk/doctype/dashboard/test_dashboard.py +++ b/frappe/desk/doctype/dashboard/test_dashboard.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import unittest class TestDashboard(unittest.TestCase): diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.js b/frappe/desk/doctype/dashboard_chart/dashboard_chart.js index 3b4d5e7be5..e0d2cab8ef 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.js +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.js @@ -45,6 +45,7 @@ frappe.ui.form.on('Dashboard Chart', { frm.set_df_property("filters_section", "hidden", 1); frm.set_df_property("dynamic_filters_section", "hidden", 1); + frm.trigger('set_parent_document_type'); frm.trigger('set_time_series'); frm.set_query('document_type', function() { return { @@ -110,9 +111,11 @@ frappe.ui.form.on('Dashboard Chart', { frm.set_value('source', ''); frm.set_value('based_on', ''); frm.set_value('value_based_on', ''); + frm.set_value('parent_document_type', ''); frm.set_value('filters_json', '[]'); frm.set_value('dynamic_filters_json', '[]'); frm.trigger('update_options'); + frm.trigger('set_parent_document_type'); }, report_name: function(frm) { @@ -125,7 +128,6 @@ frappe.ui.form.on('Dashboard Chart', { frm.trigger('set_chart_report_filters'); }, - set_chart_report_filters: function(frm) { let report_name = frm.doc.report_name; @@ -148,6 +150,10 @@ frappe.ui.form.on('Dashboard Chart', { } }, + use_report_chart: function(frm) { + !frm.doc.use_report_chart && frm.trigger('set_chart_field_options'); + }, + set_chart_field_options: function(frm) { let filters = frm.doc.filters_json.length > 2 ? JSON.parse(frm.doc.filters_json) : null; if (frm.doc.dynamic_filters_json && frm.doc.dynamic_filters_json.length > 2) { @@ -179,6 +185,9 @@ frappe.ui.form.on('Dashboard Chart', { } else { frappe.msgprint(__('Report has no data, please modify the filters or change the Report Name')); } + } else { + frm.set_value('use_report_chart', 1); + frm.set_df_property('use_report_chart', 'hidden', false); } }); }, @@ -223,7 +232,7 @@ frappe.ui.form.on('Dashboard Chart', { if (['Date', 'Datetime'].includes(df.fieldtype)) { date_fields.push({label: df.label, value: df.fieldname}); } - if (['Int', 'Float', 'Currency', 'Percent'].includes(df.fieldtype)) { + if (['Int', 'Float', 'Currency', 'Percent', 'Duration'].includes(df.fieldtype)) { value_fields.push({label: df.label, value: df.fieldname}); aggregate_function_fields.push({label: df.label, value: df.fieldname}); } @@ -365,6 +374,7 @@ frappe.ui.form.on('Dashboard Chart', { frm.filter_group = new frappe.ui.FilterGroup({ parent: dialog.get_field('filter_area').$wrapper, doctype: frm.doc.document_type, + parent_doctype: frm.doc.parent_document_type, on_change: () => {}, }); @@ -481,6 +491,36 @@ frappe.ui.form.on('Dashboard Chart', { frm.dynamic_filter_table.find('tbody').html(filter_rows); } + }, + + set_parent_document_type: async function(frm) { + let document_type = frm.doc.document_type; + let doc_is_table = document_type && + (await frappe.db.get_value('DocType', document_type, 'istable')).message.istable; + + frm.set_df_property('parent_document_type', 'hidden', !doc_is_table); + + if (document_type && doc_is_table) { + let parent = await frappe.db.get_list('DocField', { + filters: { + 'fieldtype': 'Table', + 'options': document_type + }, + fields: ['parent'] + }); + + parent && frm.set_query('parent_document_type', function() { + return { + filters: { + "name": ['in', parent.map(({ parent }) => parent)] + } + }; + }); + + if (parent.length === 1) { + frm.set_value('parent_document_type', parent[0].parent); + } + } } }); diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.json b/frappe/desk/doctype/dashboard_chart/dashboard_chart.json index d4bba53068..a5d30c10e5 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.json +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.json @@ -17,6 +17,7 @@ "y_axis", "source", "document_type", + "parent_document_type", "based_on", "value_based_on", "group_by_type", @@ -268,10 +269,18 @@ "fieldname": "use_report_chart", "fieldtype": "Check", "label": "Use Report Chart" + }, + { + "depends_on": "eval: doc.chart_type !== 'Custom' && doc.chart_type !== 'Report'", + "description": "The document type selected is a child table, so the parent document type is required.", + "fieldname": "parent_document_type", + "fieldtype": "Link", + "label": "Parent Document Type", + "options": "DocType" } ], "links": [], - "modified": "2020-07-23 11:10:33.509497", + "modified": "2021-11-09 17:18:11.456145", "modified_by": "Administrator", "module": "Desk", "name": "Dashboard Chart", diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py index db5964e7b2..cb77ef7a1a 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe import _ @@ -333,7 +333,10 @@ class DashboardChart(Document): def check_required_field(self): if not self.document_type: - frappe.throw(_("Document type is required to create a dashboard chart")) + frappe.throw(_("Document type is required to create a dashboard chart")) + + if self.document_type and frappe.get_meta(self.document_type).istable and not self.parent_document_type: + frappe.throw(_("Parent document type is required to create a dashboard chart")) if self.chart_type == 'Group By': if not self.group_by_based_on: diff --git a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py index 78d133b2d5..5562f2fc92 100644 --- a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import unittest, frappe from frappe.utils import getdate, formatdate, get_last_day from frappe.utils.dateutils import get_period_ending, get_period @@ -64,7 +64,7 @@ class TestDashboardChart(unittest.TestCase): if frappe.db.exists('Dashboard Chart', 'Test Empty Dashboard Chart'): frappe.delete_doc('Dashboard Chart', 'Test Empty Dashboard Chart') - frappe.db.sql('delete from `tabError Log`') + frappe.db.delete("Error Log") frappe.get_doc(dict( doctype = 'Dashboard Chart', @@ -94,7 +94,7 @@ class TestDashboardChart(unittest.TestCase): if frappe.db.exists('Dashboard Chart', 'Test Empty Dashboard Chart 2'): frappe.delete_doc('Dashboard Chart', 'Test Empty Dashboard Chart 2') - frappe.db.sql('delete from `tabError Log`') + frappe.db.delete("Error Log") # create one data point frappe.get_doc(dict(doctype = 'Error Log', creation = '2018-06-01 00:00:00')).insert() diff --git a/frappe/desk/doctype/dashboard_chart_field/dashboard_chart_field.py b/frappe/desk/doctype/dashboard_chart_field/dashboard_chart_field.py index 7d6f66daa2..8b2fba2e58 100644 --- a/frappe/desk/doctype/dashboard_chart_field/dashboard_chart_field.py +++ b/frappe/desk/doctype/dashboard_chart_field/dashboard_chart_field.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/desk/doctype/dashboard_chart_link/dashboard_chart_link.py b/frappe/desk/doctype/dashboard_chart_link/dashboard_chart_link.py index 359801a303..87d095d5d1 100644 --- a/frappe/desk/doctype/dashboard_chart_link/dashboard_chart_link.py +++ b/frappe/desk/doctype/dashboard_chart_link/dashboard_chart_link.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/desk/doctype/dashboard_chart_source/dashboard_chart_source.py b/frappe/desk/doctype/dashboard_chart_source/dashboard_chart_source.py index 791dbc563b..71ded32837 100644 --- a/frappe/desk/doctype/dashboard_chart_source/dashboard_chart_source.py +++ b/frappe/desk/doctype/dashboard_chart_source/dashboard_chart_source.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe, os from frappe import _ diff --git a/frappe/desk/doctype/dashboard_chart_source/test_dashboard_chart_source.py b/frappe/desk/doctype/dashboard_chart_source/test_dashboard_chart_source.py index 53fe127dfb..6d6773d52e 100644 --- a/frappe/desk/doctype/dashboard_chart_source/test_dashboard_chart_source.py +++ b/frappe/desk/doctype/dashboard_chart_source/test_dashboard_chart_source.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import unittest class TestDashboardChartSource(unittest.TestCase): diff --git a/frappe/desk/doctype/dashboard_settings/dashboard_settings.py b/frappe/desk/doctype/dashboard_settings/dashboard_settings.py index df61c52114..2f29b3e989 100644 --- a/frappe/desk/doctype/dashboard_settings/dashboard_settings.py +++ b/frappe/desk/doctype/dashboard_settings/dashboard_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/desk/doctype/desktop_icon/desktop_icon.py b/frappe/desk/doctype/desktop_icon/desktop_icon.py index 81a79cdb09..194b0d0ca4 100644 --- a/frappe/desk/doctype/desktop_icon/desktop_icon.py +++ b/frappe/desk/doctype/desktop_icon/desktop_icon.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe import _ @@ -197,7 +197,7 @@ def set_desktop_icons(visible_list, ignore_duplicate=True): # clear all custom only if setup is not complete if not int(frappe.defaults.get_defaults().setup_complete or 0): - frappe.db.sql('delete from `tabDesktop Icon` where standard=0') + frappe.db.delete("Desktop Icon", {"standard": 0}) # set standard as blocked and hidden if setting first active domain if not frappe.flags.keep_desktop_icons: diff --git a/frappe/desk/doctype/event/__init__.py b/frappe/desk/doctype/event/__init__.py index 0e57cb68c3..eb5ba62e5c 100644 --- a/frappe/desk/doctype/event/__init__.py +++ b/frappe/desk/doctype/event/__init__.py @@ -1,3 +1,3 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE diff --git a/frappe/desk/doctype/event/event.py b/frappe/desk/doctype/event/event.py index 57c89eaf2e..d4c185e56f 100644 --- a/frappe/desk/doctype/event/event.py +++ b/frappe/desk/doctype/event/event.py @@ -1,5 +1,5 @@ # Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe @@ -338,9 +338,8 @@ def delete_events(ref_type, ref_name, delete_event=False): total_participants = frappe.get_all("Event Participants", filters={"parenttype": "Event", "parent": participation.parent}) if len(total_participants) <= 1: - frappe.db.sql("DELETE FROM `tabEvent` WHERE `name` = %(name)s", {'name': participation.parent}) - - frappe.db.sql("DELETE FROM `tabEvent Participants ` WHERE `name` = %(name)s", {'name': participation.name}) + frappe.db.delete("Event", {"name": participation.parent}) + frappe.db.delete("Event Participants", {"name": participation.name}) # Close events if ends_on or repeat_till is less than now_datetime def set_status_of_events(): diff --git a/frappe/desk/doctype/event/test_event.py b/frappe/desk/doctype/event/test_event.py index 77211946a9..6b7f6ee471 100644 --- a/frappe/desk/doctype/event/test_event.py +++ b/frappe/desk/doctype/event/test_event.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE """Use blog post test to test user permissions logic""" import frappe @@ -14,7 +14,7 @@ test_records = frappe.get_test_records('Event') class TestEvent(unittest.TestCase): def setUp(self): - frappe.db.sql('delete from tabEvent') + frappe.db.delete("Event") make_test_objects('Event', reset=True) self.test_records = frappe.get_test_records('Event') diff --git a/frappe/desk/doctype/event_participants/event_participants.py b/frappe/desk/doctype/event_participants/event_participants.py index ca4fae9930..b834ba3a82 100644 --- a/frappe/desk/doctype/event_participants/event_participants.py +++ b/frappe/desk/doctype/event_participants/event_participants.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2018, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE from frappe.model.document import Document class EventParticipants(Document): diff --git a/frappe/chat/doctype/chat_room_user/__init__.py b/frappe/desk/doctype/form_tour/__init__.py similarity index 100% rename from frappe/chat/doctype/chat_room_user/__init__.py rename to frappe/desk/doctype/form_tour/__init__.py diff --git a/frappe/desk/doctype/form_tour/form_tour.js b/frappe/desk/doctype/form_tour/form_tour.js new file mode 100644 index 0000000000..8d70dcd3dc --- /dev/null +++ b/frappe/desk/doctype/form_tour/form_tour.js @@ -0,0 +1,123 @@ +// Copyright (c) 2021, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Form Tour', { + setup: function(frm) { + if (!frm.doc.is_standard || frappe.boot.developer_mode) { + frm.trigger('setup_queries'); + } + }, + + refresh(frm) { + if (frm.doc.is_standard && !frappe.boot.developer_mode) { + frm.trigger("disable_form"); + } + + frm.add_custom_button(__('Show Tour'), async () => { + const issingle = await check_if_single(frm.doc.reference_doctype); + let route_changed = null; + + if (issingle) { + route_changed = frappe.set_route('Form', frm.doc.reference_doctype); + } else { + route_changed = frappe.set_route('Form', frm.doc.reference_doctype, 'new'); + } + route_changed.then(() => { + const tour_name = frm.doc.name; + cur_frm.tour + .init({ tour_name }) + .then(() => cur_frm.tour.start()); + }); + }); + }, + + disable_form: function(frm) { + frm.set_read_only(); + frm.fields + .filter((field) => field.has_input) + .forEach((field) => { + frm.set_df_property(field.df.fieldname, "read_only", "1"); + }); + frm.disable_save(); + }, + + setup_queries(frm) { + frm.set_query("reference_doctype", function() { + return { + filters: { + istable: 0 + } + }; + }); + + frm.set_query("field", "steps", function() { + return { + query: "frappe.desk.doctype.form_tour.form_tour.get_docfield_list", + filters: { + doctype: frm.doc.reference_doctype, + hidden: 0 + } + }; + }); + + frm.set_query("parent_field", "steps", function() { + return { + query: "frappe.desk.doctype.form_tour.form_tour.get_docfield_list", + filters: { + doctype: frm.doc.reference_doctype, + fieldtype: "Table", + hidden: 0, + } + }; + }); + + frm.trigger('reference_doctype'); + }, + + reference_doctype(frm) { + if (!frm.doc.reference_doctype) return; + + frappe.db.get_list('DocField', { + filters: { + parent: frm.doc.reference_doctype, + parenttype: 'DocType', + fieldtype: 'Table' + }, + fields: ['options'] + }).then(res => { + if (Array.isArray(res)) { + frm.child_doctypes = res.map(r => r.options); + } + }); + + } +}); + +frappe.ui.form.on('Form Tour Step', { + parent_field(frm, cdt, cdn) { + const child_row = locals[cdt][cdn]; + frappe.model.set_value(cdt, cdn, 'field', ''); + const field_control = get_child_field("steps", cdn, "field"); + field_control.get_query = function() { + return { + query: "frappe.desk.doctype.form_tour.form_tour.get_docfield_list", + filters: { + doctype: child_row.child_doctype, + hidden: 0 + } + }; + }; + } +}); + +function get_child_field(child_table, child_name, fieldname) { + // gets the field from grid row form + const grid = cur_frm.fields_dict[child_table].grid; + const grid_row = grid.grid_rows_by_docname[child_name]; + return grid_row.grid_form.fields_dict[fieldname]; +} + +async function check_if_single(doctype) { + const { message } = await frappe.db.get_value('DocType', doctype, 'issingle'); + return message.issingle || 0; +} \ No newline at end of file diff --git a/frappe/desk/doctype/form_tour/form_tour.json b/frappe/desk/doctype/form_tour/form_tour.json new file mode 100644 index 0000000000..e4ea528fcc --- /dev/null +++ b/frappe/desk/doctype/form_tour/form_tour.json @@ -0,0 +1,91 @@ +{ + "actions": [], + "autoname": "field:title", + "creation": "2021-05-21 23:02:52.242721", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "title", + "reference_doctype", + "module", + "is_standard", + "save_on_complete", + "section_break_3", + "steps" + ], + "fields": [ + { + "fieldname": "reference_doctype", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Reference Document", + "options": "DocType", + "reqd": 1 + }, + { + "depends_on": "reference_doctype", + "fieldname": "steps", + "fieldtype": "Table", + "label": "Steps", + "options": "Form Tour Step", + "reqd": 1 + }, + { + "fieldname": "section_break_3", + "fieldtype": "Section Break" + }, + { + "fieldname": "title", + "fieldtype": "Data", + "label": "Title", + "reqd": 1, + "unique": 1 + }, + { + "default": "0", + "fieldname": "save_on_complete", + "fieldtype": "Check", + "label": "Save on Completion" + }, + { + "default": "0", + "fieldname": "is_standard", + "fieldtype": "Check", + "label": "Is Standard" + }, + { + "fetch_from": "reference_doctype.module", + "fieldname": "module", + "fieldtype": "Link", + "hidden": 1, + "label": "Module", + "options": "Module Def", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-06-06 20:32:54.068774", + "modified_by": "Administrator", + "module": "Desk", + "name": "Form Tour", + "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/desk/doctype/form_tour/form_tour.py b/frappe/desk/doctype/form_tour/form_tour.py new file mode 100644 index 0000000000..82d47224dd --- /dev/null +++ b/frappe/desk/doctype/form_tour/form_tour.py @@ -0,0 +1,62 @@ +# Copyright (c) 2021, Frappe Technologies and contributors +# License: MIT. See LICENSE + +import frappe +from frappe.model.document import Document +from frappe.modules.export_file import export_to_files + +class FormTour(Document): + def before_insert(self): + if not self.is_standard: + return + + # while syncing, set proper docfield reference + for d in self.steps: + if not frappe.db.exists('DocField', d.field): + d.field = frappe.db.get_value('DocField', { + 'fieldname': d.fieldname, 'parent': self.reference_doctype, 'fieldtype': d.fieldtype + }, "name") + + if d.is_table_field and not frappe.db.exists('DocField', d.parent_field): + d.parent_field = frappe.db.get_value('DocField', { + 'fieldname': d.parent_fieldname, 'parent': self.reference_doctype, 'fieldtype': 'Table' + }, "name") + + def on_update(self): + if frappe.conf.developer_mode and self.is_standard: + export_to_files([['Form Tour', self.name]], self.module) + + def before_export(self, doc): + for d in doc.steps: + d.field = "" + d.parent_field = "" + +@frappe.whitelist() +@frappe.validate_and_sanitize_search_inputs +def get_docfield_list(doctype, txt, searchfield, start, page_len, filters): + or_filters = [ + ['fieldname', 'like', '%' + txt + '%'], + ['label', 'like', '%' + txt + '%'], + ['fieldtype', 'like', '%' + txt + '%'] + ] + + parent_doctype = filters.get('doctype') + fieldtype = filters.get('fieldtype') + if not fieldtype: + excluded_fieldtypes = ['Column Break'] + excluded_fieldtypes += filters.get('excluded_fieldtypes', []) + fieldtype_filter = ['not in', excluded_fieldtypes] + else: + fieldtype_filter = fieldtype + + docfields = frappe.get_all( + doctype, + fields=["name as value", "label", "fieldtype"], + filters={'parent': parent_doctype, 'fieldtype': fieldtype_filter}, + or_filters=or_filters, + limit_start=start, + limit_page_length=page_len, + order_by="idx", + as_list=1, + ) + return docfields diff --git a/frappe/desk/doctype/form_tour/test_form_tour.py b/frappe/desk/doctype/form_tour/test_form_tour.py new file mode 100644 index 0000000000..3670cbc218 --- /dev/null +++ b/frappe/desk/doctype/form_tour/test_form_tour.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies and Contributors +# License: MIT. See LICENSE + +# import frappe +import unittest + +class TestFormTour(unittest.TestCase): + pass diff --git a/frappe/chat/doctype/chat_token/__init__.py b/frappe/desk/doctype/form_tour_step/__init__.py similarity index 100% rename from frappe/chat/doctype/chat_token/__init__.py rename to frappe/desk/doctype/form_tour_step/__init__.py diff --git a/frappe/desk/doctype/form_tour_step/form_tour_step.json b/frappe/desk/doctype/form_tour_step/form_tour_step.json new file mode 100644 index 0000000000..3b6c91a208 --- /dev/null +++ b/frappe/desk/doctype/form_tour_step/form_tour_step.json @@ -0,0 +1,151 @@ +{ + "actions": [], + "creation": "2021-05-21 23:05:45.342114", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "is_table_field", + "section_break_2", + "parent_field", + "field", + "title", + "description", + "column_break_2", + "position", + "label", + "has_next_condition", + "next_step_condition", + "section_break_13", + "fieldname", + "parent_fieldname", + "fieldtype", + "child_doctype" + ], + "fields": [ + { + "fieldname": "title", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Title", + "reqd": 1 + }, + { + "columns": 4, + "fieldname": "description", + "fieldtype": "HTML Editor", + "in_list_view": 1, + "label": "Description", + "reqd": 1 + }, + { + "depends_on": "eval: (!doc.is_table_field || (doc.is_table_field && doc.parent_field))", + "fieldname": "field", + "fieldtype": "Link", + "label": "Field", + "options": "DocField", + "reqd": 1 + }, + { + "fetch_from": "field.fieldname", + "fieldname": "fieldname", + "fieldtype": "Data", + "hidden": 1, + "label": "Fieldname", + "read_only": 1 + }, + { + "fetch_from": "field.label", + "fieldname": "label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Label", + "read_only": 1 + }, + { + "fieldname": "column_break_2", + "fieldtype": "Column Break" + }, + { + "default": "Bottom", + "fieldname": "position", + "fieldtype": "Select", + "label": "Position", + "options": "Left\nLeft Center\nLeft Bottom\nTop\nTop Center\nTop Right\nRight\nRight Center\nRight Bottom\nBottom\nBottom Center\nBottom Right\nMid Center" + }, + { + "depends_on": "has_next_condition", + "fieldname": "next_step_condition", + "fieldtype": "Code", + "label": "Next Step Condition", + "oldfieldname": "condition", + "options": "JS" + }, + { + "default": "0", + "fieldname": "has_next_condition", + "fieldtype": "Check", + "label": "Has Next Condition" + }, + { + "default": "0", + "fetch_from": "field.fieldtype", + "fieldname": "fieldtype", + "fieldtype": "Data", + "hidden": 1, + "label": "Fieldtype", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "is_table_field", + "fieldtype": "Check", + "label": "Is Table Field" + }, + { + "fieldname": "section_break_2", + "fieldtype": "Section Break" + }, + { + "depends_on": "is_table_field", + "fieldname": "parent_field", + "fieldtype": "Link", + "label": "Parent Field", + "mandatory_depends_on": "is_table_field", + "options": "DocField" + }, + { + "fieldname": "section_break_13", + "fieldtype": "Section Break", + "hidden": 1, + "label": "Hidden Fields" + }, + { + "fetch_from": "parent_field.options", + "fieldname": "child_doctype", + "fieldtype": "Data", + "hidden": 1, + "label": "Child Doctype", + "read_only": 1 + }, + { + "fetch_from": "parent_field.fieldname", + "fieldname": "parent_fieldname", + "fieldtype": "Data", + "hidden": 1, + "label": "Parent Fieldname", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-06-06 20:52:21.076972", + "modified_by": "Administrator", + "module": "Desk", + "name": "Form Tour Step", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/desk/doctype/form_tour_step/form_tour_step.py b/frappe/desk/doctype/form_tour_step/form_tour_step.py new file mode 100644 index 0000000000..bbc8edea08 --- /dev/null +++ b/frappe/desk/doctype/form_tour_step/form_tour_step.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies and contributors +# License: MIT. See LICENSE + +# import frappe +from frappe.model.document import Document + +class FormTourStep(Document): + pass diff --git a/frappe/desk/doctype/global_search_doctype/global_search_doctype.py b/frappe/desk/doctype/global_search_doctype/global_search_doctype.py index de8a48af01..30a31f959f 100644 --- a/frappe/desk/doctype/global_search_doctype/global_search_doctype.py +++ b/frappe/desk/doctype/global_search_doctype/global_search_doctype.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/desk/doctype/global_search_settings/global_search_settings.py b/frappe/desk/doctype/global_search_settings/global_search_settings.py index 9112349c1b..9ffe9aaf06 100644 --- a/frappe/desk/doctype/global_search_settings/global_search_settings.py +++ b/frappe/desk/doctype/global_search_settings/global_search_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/desk/doctype/kanban_board/kanban_board.py b/frappe/desk/doctype/kanban_board/kanban_board.py index 5100727f43..155a925fcf 100644 --- a/frappe/desk/doctype/kanban_board/kanban_board.py +++ b/frappe/desk/doctype/kanban_board/kanban_board.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe import json diff --git a/frappe/desk/doctype/kanban_board/test_kanban_board.py b/frappe/desk/doctype/kanban_board/test_kanban_board.py index f9503d736a..f00446141a 100644 --- a/frappe/desk/doctype/kanban_board/test_kanban_board.py +++ b/frappe/desk/doctype/kanban_board/test_kanban_board.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/desk/doctype/kanban_board_column/kanban_board_column.py b/frappe/desk/doctype/kanban_board_column/kanban_board_column.py index aebba3351c..d919fd6aed 100644 --- a/frappe/desk/doctype/kanban_board_column/kanban_board_column.py +++ b/frappe/desk/doctype/kanban_board_column/kanban_board_column.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/desk/doctype/list_filter/list_filter.py b/frappe/desk/doctype/list_filter/list_filter.py index 2467ae40a4..d2b01d301e 100644 --- a/frappe/desk/doctype/list_filter/list_filter.py +++ b/frappe/desk/doctype/list_filter/list_filter.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2018, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe, json from frappe.model.document import Document diff --git a/frappe/desk/doctype/list_view_settings/list_view_settings.py b/frappe/desk/doctype/list_view_settings/list_view_settings.py index f4a288b7ba..78b56fe7d5 100644 --- a/frappe/desk/doctype/list_view_settings/list_view_settings.py +++ b/frappe/desk/doctype/list_view_settings/list_view_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/desk/doctype/list_view_settings/test_list_view_settings.py b/frappe/desk/doctype/list_view_settings/test_list_view_settings.py index 00010d7604..85872dd36e 100644 --- a/frappe/desk/doctype/list_view_settings/test_list_view_settings.py +++ b/frappe/desk/doctype/list_view_settings/test_list_view_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE # import frappe import unittest diff --git a/frappe/desk/doctype/module_onboarding/module_onboarding.py b/frappe/desk/doctype/module_onboarding/module_onboarding.py index 6f01e0fd8d..aa268c792c 100644 --- a/frappe/desk/doctype/module_onboarding/module_onboarding.py +++ b/frappe/desk/doctype/module_onboarding/module_onboarding.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/desk/doctype/module_onboarding/test_module_onboarding.py b/frappe/desk/doctype/module_onboarding/test_module_onboarding.py index 39184401a1..42f472abc1 100644 --- a/frappe/desk/doctype/module_onboarding/test_module_onboarding.py +++ b/frappe/desk/doctype/module_onboarding/test_module_onboarding.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE # import frappe import unittest diff --git a/frappe/desk/doctype/note/note.json b/frappe/desk/doctype/note/note.json index 8d476e83fe..69a9518ac4 100644 --- a/frappe/desk/doctype/note/note.json +++ b/frappe/desk/doctype/note/note.json @@ -1,322 +1,106 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 1, - "beta": 0, - "creation": "2013-05-24 13:41:00", - "custom": 0, - "description": "", - "docstatus": 0, - "doctype": "DocType", - "document_type": "Document", - "editable_grid": 0, - "fields": [ - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "title", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 1, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Title", - "length": 0, - "no_copy": 1, - "permlevel": 0, - "print_hide": 1, - "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 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 1, - "collapsible": 0, - "columns": 0, - "description": "", - "fieldname": "public", - "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": "Public", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 1, - "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 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 1, - "collapsible": 0, - "columns": 0, - "depends_on": "public", - "fieldname": "notify_on_login", - "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": "Notify users with a popup when they log in", - "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 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 1, - "collapsible": 0, - "columns": 0, - "default": "0", - "depends_on": "notify_on_login", - "description": "If enabled, users will be notified every time they login. If not enabled, users will only be notified once.", - "fieldname": "notify_on_every_login", - "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": "Notify Users On Every Login", - "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 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "eval:doc.notify_on_login && doc.public", - "fieldname": "expire_notification_on", - "fieldtype": "Date", - "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": "Expire Notification On", - "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": 1, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 1, - "collapsible": 0, - "columns": 0, - "description": "Help: To link to another record in the system, use \"#Form/Note/[Note Name]\" as the Link URL. (don't use \"http://\")", - "fieldname": "content", - "fieldtype": "Text Editor", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 1, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Content", - "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 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 1, - "columns": 0, - "fieldname": "seen_by_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": "Seen By", - "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 - }, - { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "seen_by", - "fieldtype": "Table", - "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": "Seen By Table", - "length": 0, - "no_copy": 0, - "options": "Note Seen By", - "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 - } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "icon": "fa fa-file-text", - "idx": 1, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2018-09-21 15:15:44.909636", - "modified_by": "Administrator", - "module": "Desk", - "name": "Note", - "owner": "Administrator", - "permissions": [ - { - "amend": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 0, - "role": "All", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 1 - } - ], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 1, - "show_name_in_global_search": 0, - "sort_order": "ASC", - "track_changes": 1, - "track_seen": 0, - "track_views": 0 - } \ No newline at end of file + "actions": [], + "allow_rename": 1, + "creation": "2013-05-24 13:41:00", + "doctype": "DocType", + "document_type": "Document", + "engine": "InnoDB", + "field_order": [ + "title", + "public", + "notify_on_login", + "notify_on_every_login", + "expire_notification_on", + "content", + "seen_by_section", + "seen_by" + ], + "fields": [ + { + "fieldname": "title", + "fieldtype": "Data", + "in_global_search": 1, + "in_list_view": 1, + "label": "Title", + "no_copy": 1, + "print_hide": 1, + "reqd": 1 + }, + { + "bold": 1, + "default": "0", + "fieldname": "public", + "fieldtype": "Check", + "label": "Public", + "print_hide": 1 + }, + { + "bold": 1, + "default": "0", + "depends_on": "public", + "fieldname": "notify_on_login", + "fieldtype": "Check", + "label": "Notify users with a popup when they log in" + }, + { + "bold": 1, + "default": "0", + "depends_on": "notify_on_login", + "description": "If enabled, users will be notified every time they login. If not enabled, users will only be notified once.", + "fieldname": "notify_on_every_login", + "fieldtype": "Check", + "label": "Notify Users On Every Login" + }, + { + "depends_on": "eval:doc.notify_on_login && doc.public", + "fieldname": "expire_notification_on", + "fieldtype": "Date", + "label": "Expire Notification On", + "search_index": 1 + }, + { + "bold": 1, + "description": "Help: To link to another record in the system, use \"/app/note/[Note Name]\" as the Link URL. (don't use \"http://\")", + "fieldname": "content", + "fieldtype": "Text Editor", + "in_global_search": 1, + "label": "Content" + }, + { + "collapsible": 1, + "fieldname": "seen_by_section", + "fieldtype": "Section Break", + "label": "Seen By" + }, + { + "fieldname": "seen_by", + "fieldtype": "Table", + "label": "Seen By Table", + "options": "Note Seen By" + } + ], + "icon": "fa fa-file-text", + "idx": 1, + "links": [], + "modified": "2021-09-18 10:57:51.352643", + "modified_by": "Administrator", + "module": "Desk", + "name": "Note", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "All", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "ASC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/desk/doctype/note/note.py b/frappe/desk/doctype/note/note.py index 790f9a514c..ae7af07cd9 100644 --- a/frappe/desk/doctype/note/note.py +++ b/frappe/desk/doctype/note/note.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: See license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/desk/doctype/note/test_note.py b/frappe/desk/doctype/note/test_note.py index 1bb1730357..ac2116c38a 100644 --- a/frappe/desk/doctype/note/test_note.py +++ b/frappe/desk/doctype/note/test_note.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest @@ -8,9 +8,9 @@ test_records = frappe.get_test_records('Note') class TestNote(unittest.TestCase): def insert_note(self): - frappe.db.sql('delete from tabVersion') - frappe.db.sql('delete from tabNote') - frappe.db.sql('delete from `tabNote Seen By`') + frappe.db.delete("Version") + frappe.db.delete("Note") + frappe.db.delete("Note Seen By") return frappe.get_doc(dict(doctype='Note', title='test note', content='test note content')).insert() diff --git a/frappe/desk/doctype/note_seen_by/note_seen_by.py b/frappe/desk/doctype/note_seen_by/note_seen_by.py index cec4628b20..01bee05a9f 100644 --- a/frappe/desk/doctype/note_seen_by/note_seen_by.py +++ b/frappe/desk/doctype/note_seen_by/note_seen_by.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/desk/doctype/notification_log/notification_log.json b/frappe/desk/doctype/notification_log/notification_log.json index 9e802298e3..e188708277 100644 --- a/frappe/desk/doctype/notification_log/notification_log.json +++ b/frappe/desk/doctype/notification_log/notification_log.json @@ -120,7 +120,7 @@ "hide_toolbar": 1, "in_create": 1, "links": [], - "modified": "2020-09-18 17:26:09.703215", + "modified": "2021-10-25 17:26:09.703215", "modified_by": "Administrator", "module": "Desk", "name": "Notification Log", @@ -139,6 +139,5 @@ "sort_field": "modified", "sort_order": "DESC", "title_field": "subject", - "track_changes": 1, "track_seen": 1 -} \ No newline at end of file +} diff --git a/frappe/desk/doctype/notification_log/notification_log.py b/frappe/desk/doctype/notification_log/notification_log.py index 414f272f59..12e628ada2 100644 --- a/frappe/desk/doctype/notification_log/notification_log.py +++ b/frappe/desk/doctype/notification_log/notification_log.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe import _ @@ -12,7 +12,10 @@ class NotificationLog(Document): frappe.publish_realtime('notification', after_commit=True, user=self.for_user) set_notifications_as_unseen(self.for_user) if is_email_notifications_enabled_for_type(self.for_user, self.type): - send_notification_email(self) + try: + send_notification_email(self) + except frappe.OutgoingEmailError: + frappe.log_error(message=frappe.get_traceback(), title=_("Failed to send notification email")) def get_permission_query_conditions(for_user): diff --git a/frappe/desk/doctype/notification_log/test_notification_log.py b/frappe/desk/doctype/notification_log/test_notification_log.py index af4dee8df3..4c415a860c 100644 --- a/frappe/desk/doctype/notification_log/test_notification_log.py +++ b/frappe/desk/doctype/notification_log/test_notification_log.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe +from frappe.core.doctype.user.user import get_system_users from frappe.desk.form.assign_to import add as assign_task import unittest @@ -54,7 +55,4 @@ def get_todo(): return frappe.get_cached_doc('ToDo', res[0].name) def get_user(): - users = frappe.db.get_all('User', - filters={'name': ('not in', ['Administrator', 'Guest'])}, - fields='name', limit=1) - return users[0].name + return get_system_users(limit=1)[0] diff --git a/frappe/desk/doctype/notification_settings/notification_settings.py b/frappe/desk/doctype/notification_settings/notification_settings.py index eb3a16435f..cf6bb2d78d 100644 --- a/frappe/desk/doctype/notification_settings/notification_settings.py +++ b/frappe/desk/doctype/notification_settings/notification_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/desk/doctype/notification_subscribed_document/notification_subscribed_document.py b/frappe/desk/doctype/notification_subscribed_document/notification_subscribed_document.py index 6931e77754..1fdba22779 100644 --- a/frappe/desk/doctype/notification_subscribed_document/notification_subscribed_document.py +++ b/frappe/desk/doctype/notification_subscribed_document/notification_subscribed_document.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/desk/doctype/number_card/number_card.py b/frappe/desk/doctype/number_card/number_card.py index d8d5fe0953..5662523a9d 100644 --- a/frappe/desk/doctype/number_card/number_card.py +++ b/frappe/desk/doctype/number_card/number_card.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/desk/doctype/number_card/test_number_card.py b/frappe/desk/doctype/number_card/test_number_card.py index c395f5f915..cc92e63341 100644 --- a/frappe/desk/doctype/number_card/test_number_card.py +++ b/frappe/desk/doctype/number_card/test_number_card.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE # import frappe import unittest diff --git a/frappe/desk/doctype/number_card_link/number_card_link.py b/frappe/desk/doctype/number_card_link/number_card_link.py index 6c16f45f4b..0b55ae6dcd 100644 --- a/frappe/desk/doctype/number_card_link/number_card_link.py +++ b/frappe/desk/doctype/number_card_link/number_card_link.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/desk/doctype/onboarding_permission/onboarding_permission.py b/frappe/desk/doctype/onboarding_permission/onboarding_permission.py index 40d3dc33b1..a0e87c3067 100644 --- a/frappe/desk/doctype/onboarding_permission/onboarding_permission.py +++ b/frappe/desk/doctype/onboarding_permission/onboarding_permission.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/desk/doctype/onboarding_permission/test_onboarding_permission.py b/frappe/desk/doctype/onboarding_permission/test_onboarding_permission.py index 80b166de0a..c13fb29678 100644 --- a/frappe/desk/doctype/onboarding_permission/test_onboarding_permission.py +++ b/frappe/desk/doctype/onboarding_permission/test_onboarding_permission.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE # import frappe import unittest diff --git a/frappe/desk/doctype/onboarding_step/onboarding_step.py b/frappe/desk/doctype/onboarding_step/onboarding_step.py index 10bd8926ce..45e0ca34fd 100644 --- a/frappe/desk/doctype/onboarding_step/onboarding_step.py +++ b/frappe/desk/doctype/onboarding_step/onboarding_step.py @@ -1,11 +1,27 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE -# import frappe +import frappe +from frappe import _ +import json from frappe.model.document import Document class OnboardingStep(Document): def before_export(self, doc): doc.is_complete = 0 doc.is_skipped = 0 + + +@frappe.whitelist() +def get_onboarding_steps(ob_steps): + steps = [] + for s in json.loads(ob_steps): + doc = frappe.get_doc('Onboarding Step', s.get('step')) + step = doc.as_dict().copy() + step.label = _(doc.title) + if step.action == "Create Entry": + step.is_submittable = frappe.db.get_value("DocType", step.reference_document, 'is_submittable', cache=True) + steps.append(step) + + return steps diff --git a/frappe/desk/doctype/onboarding_step/test_onboarding_step.py b/frappe/desk/doctype/onboarding_step/test_onboarding_step.py index 2425577478..b0651da4da 100644 --- a/frappe/desk/doctype/onboarding_step/test_onboarding_step.py +++ b/frappe/desk/doctype/onboarding_step/test_onboarding_step.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE # import frappe import unittest diff --git a/frappe/desk/doctype/onboarding_step_map/onboarding_step_map.py b/frappe/desk/doctype/onboarding_step_map/onboarding_step_map.py index c79244c4ad..7c20e220db 100644 --- a/frappe/desk/doctype/onboarding_step_map/onboarding_step_map.py +++ b/frappe/desk/doctype/onboarding_step_map/onboarding_step_map.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/desk/doctype/route_history/route_history.json b/frappe/desk/doctype/route_history/route_history.json index 7390aa011b..09db2320ca 100644 --- a/frappe/desk/doctype/route_history/route_history.json +++ b/frappe/desk/doctype/route_history/route_history.json @@ -88,7 +88,7 @@ "issingle": 0, "istable": 0, "max_attachments": 0, - "modified": "2018-10-05 13:26:03.106050", + "modified": "2021-10-25 13:26:03.106050", "modified_by": "Administrator", "module": "Desk", "name": "Route History", @@ -121,7 +121,6 @@ "show_name_in_global_search": 0, "sort_field": "modified", "sort_order": "DESC", - "track_changes": 1, "track_seen": 0, "track_views": 0 -} \ No newline at end of file +} diff --git a/frappe/desk/doctype/route_history/route_history.py b/frappe/desk/doctype/route_history/route_history.py index b82077f485..01184fcc3a 100644 --- a/frappe/desk/doctype/route_history/route_history.py +++ b/frappe/desk/doctype/route_history/route_history.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2018, Frappe Technologies and contributors -# For license information, please see license.txt +# Copyright (c) 2021, Frappe Technologies and contributors +# License: MIT. See LICENSE import frappe from frappe.model.document import Document @@ -8,6 +7,7 @@ from frappe.model.document import Document class RouteHistory(Document): pass + def flush_old_route_records(): """Deletes all route records except last 500 records per user""" @@ -24,19 +24,14 @@ def flush_old_route_records(): for user in users: user = user[0] last_record_to_keep = frappe.db.get_all('Route History', - filters={ - 'user': user, - }, + filters={'user': user}, limit=1, limit_start=500, fields=['modified'], - order_by='modified desc') + order_by='modified desc' + ) - frappe.db.sql(''' - DELETE - FROM `tabRoute History` - WHERE `modified` <= %(modified)s and `user`=%(modified)s - ''', { - "modified": last_record_to_keep[0].modified, + frappe.db.delete("Route History", { + "modified": ("<=", last_record_to_keep[0].modified), "user": user - }) \ No newline at end of file + }) diff --git a/frappe/desk/doctype/system_console/system_console.js b/frappe/desk/doctype/system_console/system_console.js index c7eac39490..0fe3932671 100644 --- a/frappe/desk/doctype/system_console/system_console.js +++ b/frappe/desk/doctype/system_console/system_console.js @@ -5,17 +5,100 @@ frappe.ui.form.on('System Console', { onload: function(frm) { frappe.ui.keys.add_shortcut({ shortcut: 'shift+enter', - action: () => frm.execute_action('Execute'), + action: () => frm.page.btn_primary.trigger('click'), page: frm.page, description: __('Execute Console script'), ignore_inputs: true, }); + frm.set_value("type", "Python"); }, refresh: function(frm) { frm.disable_save(); - frm.page.set_primary_action(__("Execute"), () => { - frm.execute_action('Execute'); + frm.page.set_primary_action(__("Execute"), $btn => { + $btn.text(__("Executing...")); + return frm + .execute_action("Execute") + .then(() => frm.trigger("render_sql_output")) + .finally(() => $btn.text(__("Execute"))); + }); + }, + + type: function(frm) { + if (frm.doc.type == "Python") { + frm.set_value("output", ""); + if (frm.sql_output) { + frm.sql_output.destroy(); + frm.get_field("sql_output").html(""); + } + } + }, + + render_sql_output: function(frm) { + if (frm.doc.type !== "SQL") return; + if (frm.sql_output) { + frm.sql_output.destroy(); + frm.get_field("sql_output").html(""); + } + + if (frm.doc.output.startsWith("Traceback")) { + return; + } + + let result = JSON.parse(frm.doc.output); + frm.set_value("output", `${result.length} ${result.length == 1 ? 'row' : 'rows'}`); + + if (result.length) { + let columns = Object.keys(result[0]); + frm.sql_output = new DataTable( + frm.get_field("sql_output").$wrapper.get(0), + { + columns, + data: result + } + ); + } + }, + + show_processlist: function(frm) { + if (frm.doc.show_processlist) { + // keep refreshing every 5 seconds + frm.events.refresh_processlist(frm); + frm.processlist_interval = setInterval(() => frm.events.refresh_processlist(frm), 5000); + } else { + if (frm.processlist_interval) { + + // end it + clearInterval(frm.processlist_interval); + frm.get_field("processlist").html(''); + } + } + }, + + refresh_processlist: function(frm) { + let timestamp = new Date(); + frappe.call('frappe.desk.doctype.system_console.system_console.show_processlist').then(r => { + let rows = ''; + for (let row of r.message) { + rows += ` + ${row.Id} + ${row.Time} + ${row.State} + ${row.Info} + ${row.Progress} + ` + } + frm.get_field('processlist').html(` +

Requested on: ${timestamp}

+ + + + ${rows}`); }); } }); diff --git a/frappe/desk/doctype/system_console/system_console.json b/frappe/desk/doctype/system_console/system_console.json index 14e36e6fd3..657e9df89d 100644 --- a/frappe/desk/doctype/system_console/system_console.json +++ b/frappe/desk/doctype/system_console/system_console.json @@ -17,9 +17,15 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "execute_section", + "type", "console", "commit", - "output" + "output", + "sql_output", + "database_processes_section", + "show_processlist", + "processlist" ], "fields": [ { @@ -40,13 +46,47 @@ "fieldname": "commit", "fieldtype": "Check", "label": "Commit" + }, + { + "fieldname": "execute_section", + "fieldtype": "Section Break", + "label": "Execute" + }, + { + "fieldname": "database_processes_section", + "fieldtype": "Section Break", + "label": "Database Processes" + }, + { + "default": "0", + "fieldname": "show_processlist", + "fieldtype": "Check", + "label": "Show Processlist" + }, + { + "fieldname": "processlist", + "fieldtype": "HTML", + "label": "processlist" + }, + { + "default": "Python", + "fieldname": "type", + "fieldtype": "Select", + "label": "Type", + "options": "Python\nSQL" + }, + { + "depends_on": "eval:doc.type == 'SQL'", + "fieldname": "sql_output", + "fieldtype": "HTML", + "label": "SQL Output" } ], "hide_toolbar": 1, "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2020-08-21 14:44:35.296877", + "modified": "2021-09-15 17:17:44.844767", "modified_by": "Administrator", "module": "Desk", "name": "System Console", @@ -65,4 +105,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} +} \ No newline at end of file diff --git a/frappe/desk/doctype/system_console/system_console.py b/frappe/desk/doctype/system_console/system_console.py index e2b5656bc0..107ab2f932 100644 --- a/frappe/desk/doctype/system_console/system_console.py +++ b/frappe/desk/doctype/system_console/system_console.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import json import frappe -from frappe.utils.safe_exec import safe_exec +from frappe.utils.safe_exec import safe_exec, read_sql from frappe.model.document import Document class SystemConsole(Document): @@ -13,8 +13,11 @@ class SystemConsole(Document): frappe.only_for('System Manager') try: frappe.debug_log = [] - safe_exec(self.console) - self.output = '\n'.join(frappe.debug_log) + if self.type == 'Python': + safe_exec(self.console) + self.output = '\n'.join(frappe.debug_log) + elif self.type == 'SQL': + self.output = frappe.as_json(read_sql(self.console, as_dict=1)) except: # noqa: E722 self.output = frappe.get_traceback() @@ -33,4 +36,9 @@ class SystemConsole(Document): def execute_code(doc): console = frappe.get_doc(json.loads(doc)) console.run() - return console.as_dict() \ No newline at end of file + return console.as_dict() + +@frappe.whitelist() +def show_processlist(): + frappe.only_for('System Manager') + return frappe.db.sql('show full processlist', as_dict=1) diff --git a/frappe/desk/doctype/system_console/test_system_console.py b/frappe/desk/doctype/system_console/test_system_console.py index 743c2d6dde..fa7c577faa 100644 --- a/frappe/desk/doctype/system_console/test_system_console.py +++ b/frappe/desk/doctype/system_console/test_system_console.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/desk/doctype/tag/tag.py b/frappe/desk/doctype/tag/tag.py index 4ea5c9cd7e..381c24a765 100644 --- a/frappe/desk/doctype/tag/tag.py +++ b/frappe/desk/doctype/tag/tag.py @@ -1,10 +1,10 @@ -# -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document from frappe.utils import unique +from frappe.query_builder import DocType class Tag(Document): pass @@ -12,7 +12,8 @@ class Tag(Document): def check_user_tags(dt): "if the user does not have a tags column, then it creates one" try: - frappe.db.sql("select `_user_tags` from `tab%s` limit 1" % dt) + doctype = DocType(dt) + frappe.qb.from_(doctype).select(doctype._user_tags).limit(1).run() except Exception as e: if frappe.db.is_column_missing(e): DocTags(dt).setup() @@ -43,10 +44,12 @@ def remove_tag(tag, dt, dn): @frappe.whitelist() def get_tagged_docs(doctype, tag): frappe.has_permission(doctype, throw=True) - - return frappe.db.sql("""SELECT name - FROM `tab{0}` - WHERE _user_tags LIKE '%{1}%'""".format(doctype, tag)) + doctype = DocType(doctype) + return ( + frappe.qb.from_(doctype) + .where(doctype._user_tags.like(tag)) + .select(doctype.name) + ).run() @frappe.whitelist() def get_tags(doctype, txt): @@ -123,45 +126,41 @@ def delete_tags_for_document(doc): if not frappe.db.table_exists("Tag Link"): return - frappe.db.sql("""DELETE FROM `tabTag Link` WHERE `document_type`=%s AND `document_name`=%s""", (doc.doctype, doc.name)) + frappe.db.delete("Tag Link", { + "document_type": doc.doctype, + "document_name": doc.name + }) def update_tags(doc, tags): - """ - Adds tags for documents - :param doc: Document to be added to global tags - """ + """Adds tags for documents + :param doc: Document to be added to global tags + """ new_tags = {tag.strip() for tag in tags.split(",") if tag} - - for tag in new_tags: - if not frappe.db.exists("Tag Link", {"parenttype": doc.doctype, "parent": doc.name, "tag": tag}): - frappe.get_doc({ - "doctype": "Tag Link", - "document_type": doc.doctype, - "document_name": doc.name, - "parenttype": doc.doctype, - "parent": doc.name, - "title": doc.get_title() or '', - "tag": tag - }).insert(ignore_permissions=True) - existing_tags = [tag.tag for tag in frappe.get_list("Tag Link", filters={ "document_type": doc.doctype, "document_name": doc.name }, fields=["tag"])] - deleted_tags = get_deleted_tags(new_tags, existing_tags) + added_tags = set(new_tags) - set(existing_tags) + for tag in added_tags: + frappe.get_doc({ + "doctype": "Tag Link", + "document_type": doc.doctype, + "document_name": doc.name, + "parenttype": doc.doctype, + "parent": doc.name, + "title": doc.get_title() or '', + "tag": tag + }).insert(ignore_permissions=True) - if deleted_tags: - for tag in deleted_tags: - delete_tag_for_document(doc.doctype, doc.name, tag) - -def get_deleted_tags(new_tags, existing_tags): - - return list(set(existing_tags) - set(new_tags)) - -def delete_tag_for_document(dt, dn, tag): - frappe.db.sql("""DELETE FROM `tabTag Link` WHERE `document_type`=%s AND `document_name`=%s AND tag=%s""", (dt, dn, tag)) + deleted_tags = list(set(existing_tags) - set(new_tags)) + for tag in deleted_tags: + frappe.db.delete("Tag Link", { + "document_type": doc.doctype, + "document_name": doc.name, + "tag": tag + }) @frappe.whitelist() def get_documents_for_tag(tag): diff --git a/frappe/desk/doctype/tag/test_tag.py b/frappe/desk/doctype/tag/test_tag.py index 442a891fd8..b9c6e0b744 100644 --- a/frappe/desk/doctype/tag/test_tag.py +++ b/frappe/desk/doctype/tag/test_tag.py @@ -1,8 +1,26 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2019, Frappe Technologies and Contributors -# See license.txt -# import frappe import unittest +import frappe + +from frappe.desk.reportview import get_stats +from frappe.desk.doctype.tag.tag import add_tag class TestTag(unittest.TestCase): - pass + def setUp(self) -> None: + frappe.db.delete("Tag") + frappe.db.sql("UPDATE `tabDocType` set _user_tags=''") + + def test_tag_count_query(self): + self.assertDictEqual(get_stats('["_user_tags"]', 'DocType'), + {'_user_tags': [['No Tags', frappe.db.count('DocType')]]}) + add_tag('Standard', 'DocType', 'User') + add_tag('Standard', 'DocType', 'ToDo') + + # count with no filter + self.assertDictEqual(get_stats('["_user_tags"]', 'DocType'), + {'_user_tags': [['Standard', 2], ['No Tags', frappe.db.count('DocType') - 2]]}) + + # count with child table field filter + self.assertDictEqual(get_stats('["_user_tags"]', + 'DocType', + filters='[["DocField", "fieldname", "like", "%last_name%"], ["DocType", "name", "like", "%use%"]]'), + {'_user_tags': [['Standard', 1], ['No Tags', 0]]}) \ No newline at end of file diff --git a/frappe/desk/doctype/tag_link/tag_link.json b/frappe/desk/doctype/tag_link/tag_link.json index 00a7349c5c..9142279fa3 100644 --- a/frappe/desk/doctype/tag_link/tag_link.json +++ b/frappe/desk/doctype/tag_link/tag_link.json @@ -1,4 +1,5 @@ { + "actions": [], "creation": "2019-09-24 13:25:36.435685", "doctype": "DocType", "editable_grid": 1, @@ -44,7 +45,8 @@ "read_only": 1 } ], - "modified": "2019-10-03 16:42:35.932409", + "links": [], + "modified": "2021-09-20 16:53:37.217998", "modified_by": "Administrator", "module": "Desk", "name": "Tag Link", @@ -61,6 +63,17 @@ "role": "System Manager", "share": 1, "write": 1 + }, + { + "create": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "All", + "share": 1, + "write": 1 } ], "read_only": 1, diff --git a/frappe/desk/doctype/tag_link/tag_link.py b/frappe/desk/doctype/tag_link/tag_link.py index 4c5149f42c..d07894989d 100644 --- a/frappe/desk/doctype/tag_link/tag_link.py +++ b/frappe/desk/doctype/tag_link/tag_link.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/desk/doctype/tag_link/test_tag_link.py b/frappe/desk/doctype/tag_link/test_tag_link.py index 297ee3cc96..fa6a22903f 100644 --- a/frappe/desk/doctype/tag_link/test_tag_link.py +++ b/frappe/desk/doctype/tag_link/test_tag_link.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE # import frappe import unittest diff --git a/frappe/desk/doctype/todo/__init__.py b/frappe/desk/doctype/todo/__init__.py index 0e57cb68c3..eb5ba62e5c 100644 --- a/frappe/desk/doctype/todo/__init__.py +++ b/frappe/desk/doctype/todo/__init__.py @@ -1,3 +1,3 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE diff --git a/frappe/desk/doctype/todo/test_todo.py b/frappe/desk/doctype/todo/test_todo.py index b38e4a059a..34d3cee191 100644 --- a/frappe/desk/doctype/todo/test_todo.py +++ b/frappe/desk/doctype/todo/test_todo.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest from frappe.model.db_query import DatabaseQuery @@ -14,7 +14,7 @@ class TestToDo(unittest.TestCase): todo = frappe.get_doc(dict(doctype='ToDo', description='test todo', assigned_by='Administrator')).insert() - frappe.db.sql('delete from `tabDeleted Document`') + frappe.db.delete("Deleted Document") todo.delete() deleted = frappe.get_doc('Deleted Document', dict(deleted_doctype=todo.doctype, deleted_name=todo.name)) @@ -27,7 +27,7 @@ class TestToDo(unittest.TestCase): frappe.db.get_value('User', todo.assigned_by, 'full_name')) def test_fetch_setup(self): - frappe.db.sql('delete from tabToDo') + frappe.db.delete("ToDo") todo_meta = frappe.get_doc('DocType', 'ToDo') todo_meta.get('fields', dict(fieldname='assigned_by_full_name'))[0].fetch_from = '' @@ -104,8 +104,8 @@ class TestToDo(unittest.TestCase): clear_permissions_cache('ToDo') frappe.db.rollback() -def test_fetch_if_empty(self): - frappe.db.sql('delete from tabToDo') + def test_fetch_if_empty(self): + frappe.db.delete("ToDo") # Allow user changes todo_meta = frappe.get_doc('DocType', 'ToDo') @@ -122,9 +122,8 @@ def test_fetch_if_empty(self): self.assertEqual(todo.assigned_by_full_name, 'Admin') # Overwrite user changes - todo_meta = frappe.get_doc('DocType', 'ToDo') - todo_meta.get('fields', dict(fieldname='assigned_by_full_name'))[0].fetch_if_empty = 0 - todo_meta.save() + todo.meta.get('fields', dict(fieldname='assigned_by_full_name'))[0].fetch_if_empty = 0 + todo.meta.save() todo.reload() todo.save() diff --git a/frappe/desk/doctype/todo/todo.py b/frappe/desk/doctype/todo/todo.py index 4696563445..6f3f4160e6 100644 --- a/frappe/desk/doctype/todo/todo.py +++ b/frappe/desk/doctype/todo/todo.py @@ -1,15 +1,17 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe import json from frappe.model.document import Document -from frappe.utils import get_fullname +from frappe.utils import get_fullname, parse_addr exclude_from_linked_with = True class ToDo(Document): + DocType = 'ToDo' + def validate(self): self._assignment = None if self.is_new(): @@ -27,8 +29,15 @@ class ToDo(Document): else: # NOTE the previous value is only available in validate method if self.get_db_value("status") != self.status: + if self.owner == frappe.session.user: + removal_message = frappe._("{0} removed their assignment.").format( + get_fullname(frappe.session.user)) + else: + removal_message = frappe._("Assignment of {0} removed by {1}").format( + get_fullname(self.owner), get_fullname(frappe.session.user)) + self._assignment = { - "text": frappe._("Assignment closed by {0}").format(get_fullname(frappe.session.user)), + "text": removal_message, "comment_type": "Assignment Completed" } @@ -39,13 +48,7 @@ class ToDo(Document): self.update_in_reference() def on_trash(self): - # unlink todo from linked comments - frappe.db.sql(""" - delete from `tabCommunication Link` - where link_doctype=%(doctype)s and link_name=%(name)s""", { - "doctype": self.doctype, "name": self.name - }) - + self.delete_communication_links() self.update_in_reference() def add_assign_comment(self, text, comment_type): @@ -54,6 +57,13 @@ class ToDo(Document): frappe.get_doc(self.reference_type, self.reference_name).add_comment(comment_type, text) + def delete_communication_links(self): + # unlink todo from linked comments + return frappe.db.delete("Communication Link", { + "link_doctype": self.doctype, + "link_name": self.name + }) + def update_in_reference(self): if not (self.reference_type and self.reference_name): return @@ -84,6 +94,13 @@ class ToDo(Document): else: raise + @classmethod + def get_owners(cls, filters=None): + """Returns list of owners after applying filters on todo's. + """ + rows = frappe.get_all(cls.DocType, filters=filters or {}, fields=['owner']) + return [parse_addr(row.owner)[1] for row in rows if row.owner] + # NOTE: todo is viewable if a user is an owner, or set as assigned_to value, or has any role that is allowed to access ToDo doctype. def on_doctype_update(): frappe.db.add_index("ToDo", ["reference_type", "reference_name"]) diff --git a/frappe/desk/doctype/workspace/test_workspace.py b/frappe/desk/doctype/workspace/test_workspace.py index 619b3608eb..6c16e69afe 100644 --- a/frappe/desk/doctype/workspace/test_workspace.py +++ b/frappe/desk/doctype/workspace/test_workspace.py @@ -1,8 +1,95 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and Contributors -# See license.txt -# import frappe +# License: MIT. See LICENSE +import frappe import unittest - class TestWorkspace(unittest.TestCase): - pass + def setUp(self): + create_module("Test Module") + + def tearDown(self): + frappe.db.delete("Workspace", {"module": "Test Module"}) + frappe.db.delete("DocType", {"module": "Test Module"}) + frappe.delete_doc("Module Def", "Test Module") + + # TODO: FIX ME - flaky test!!! + # def test_workspace_with_cards_specific_to_a_country(self): + # workspace = create_workspace() + # insert_card(workspace, "Card Label 1", "DocType 1", "DocType 2", "France") + # insert_card(workspace, "Card Label 2", "DocType A", "DocType B") + + # workspace.insert(ignore_if_duplicate = True) + + # cards = workspace.get_link_groups() + + # if frappe.get_system_settings('country') == "France": + # self.assertEqual(len(cards), 2) + # else: + # self.assertEqual(len(cards), 1) + +def create_module(module_name): + module = frappe.get_doc({ + "doctype": "Module Def", + "module_name": module_name, + "app_name": "frappe" + }) + module.insert(ignore_if_duplicate = True) + + return module + +def create_workspace(**args): + workspace = frappe.new_doc("Workspace") + args = frappe._dict(args) + + workspace.name = args.name or "Test Workspace" + workspace.label = args.label or "Test Workspace" + workspace.category = args.category or "Modules" + workspace.is_standard = args.is_standard or 1 + workspace.module = "Test Module" + + return workspace + +def insert_card(workspace, card_label, doctype1, doctype2, country=None): + workspace.append("links", { + "type": "Card Break", + "label": card_label, + "only_for": country + }) + + create_doctype(doctype1, "Test Module") + workspace.append("links", { + "type": "Link", + "label": doctype1, + "only_for": country, + "link_type": "DocType", + "link_to": doctype1 + }) + + create_doctype(doctype2, "Test Module") + workspace.append("links", { + "type": "Link", + "label": doctype2, + "only_for": country, + "link_type": "DocType", + "link_to": doctype2 + }) + +def create_doctype(doctype_name, module): + frappe.get_doc({ + 'doctype': 'DocType', + 'name': doctype_name, + 'module': module, + 'custom': 1, + 'autoname': 'field:title', + 'fields': [ + {'label': 'Title', 'fieldname': 'title', 'reqd': 1, 'fieldtype': 'Data'}, + {'label': 'Description', 'fieldname': 'description', 'fieldtype': 'Small Text'}, + {'label': 'Date', 'fieldname': 'date', 'fieldtype': 'Date'}, + {'label': 'Duration', 'fieldname': 'duration', 'fieldtype': 'Duration'}, + {'label': 'Number', 'fieldname': 'number', 'fieldtype': 'Int'}, + {'label': 'Number', 'fieldname': 'another_number', 'fieldtype': 'Int'} + ], + 'permissions': [ + {'role': 'System Manager'} + ] + }).insert(ignore_if_duplicate = True) diff --git a/frappe/desk/doctype/workspace/workspace.js b/frappe/desk/doctype/workspace/workspace.js index 19d429f9f6..5377470343 100644 --- a/frappe/desk/doctype/workspace/workspace.js +++ b/frappe/desk/doctype/workspace/workspace.js @@ -8,14 +8,9 @@ frappe.ui.form.on('Workspace', { refresh: function(frm) { frm.enable_save(); - frm.get_field("is_standard").toggle(frappe.boot.developer_mode); - frm.get_field("developer_mode_only").toggle(frappe.boot.developer_mode); - if (frm.doc.for_user) { - frm.set_df_property("extends", "read_only", true); - } - - if (frm.doc.for_user || (frm.doc.is_standard && !frappe.boot.developer_mode)) { + if (frm.doc.for_user || (frm.doc.public && !frm.has_perm('write') && + !frappe.user.has_role('Workspace Manager'))) { frm.trigger('disable_form'); } }, diff --git a/frappe/desk/doctype/workspace/workspace.json b/frappe/desk/doctype/workspace/workspace.json index 386267b699..04975c69e3 100644 --- a/frappe/desk/doctype/workspace/workspace.json +++ b/frappe/desk/doctype/workspace/workspace.json @@ -8,37 +8,32 @@ "engine": "InnoDB", "field_order": [ "label", + "title", + "sequence_id", "for_user", - "extends", + "parent_page", "module", - "category", + "column_break_3", "icon", "restrict_to_domain", - "onboarding", - "column_break_3", - "extends_another_page", - "is_default", - "is_standard", - "developer_mode_only", - "disable_user_customization", - "pin_to_top", - "pin_to_bottom", "hide_custom", + "public", + "content", "section_break_2", - "charts_label", "charts", "section_break_15", - "shortcuts_label", "shortcuts", "section_break_18", - "cards_label", - "links" + "links", + "roles_section", + "roles" ], "fields": [ { "fieldname": "label", "fieldtype": "Data", "label": "Name", + "reqd": 1, "unique": 1 }, { @@ -55,7 +50,6 @@ "options": "Workspace Chart" }, { - "depends_on": "eval:!doc.extends_another_page || !doc.is_standard || frappe.boot.developer_mode", "fieldname": "shortcuts", "fieldtype": "Table", "label": "Shortcuts", @@ -66,7 +60,6 @@ "fieldtype": "Link", "label": "Restrict to Domain", "options": "Domain", - "read_only_depends_on": "eval:doc.extends_another_page == 0", "search_index": 1 }, { @@ -81,64 +74,6 @@ "fieldname": "column_break_3", "fieldtype": "Column Break" }, - { - "fieldname": "category", - "fieldtype": "Select", - "label": "Category", - "options": "Modules\nDomains\nPlaces\nAdministration", - "read_only_depends_on": "eval:doc.extends_another_page == 1", - "search_index": 1 - }, - { - "default": "0", - "depends_on": "eval:doc.extends_another_page == 0", - "fieldname": "developer_mode_only", - "fieldtype": "Check", - "label": "Developer Mode Only", - "search_index": 1 - }, - { - "default": "0", - "depends_on": "eval:doc.pin_to_bottom!=1 && doc.extends_another_page == 0", - "fieldname": "pin_to_top", - "fieldtype": "Check", - "label": "Pin To Top", - "search_index": 1 - }, - { - "default": "0", - "depends_on": "eval:doc.extends_another_page == 0", - "fieldname": "disable_user_customization", - "fieldtype": "Check", - "label": "Disable User Customization", - "search_index": 1 - }, - { - "default": "0", - "depends_on": "eval:doc.pin_to_top!=1 && doc.extends_another_page == 0", - "fieldname": "pin_to_bottom", - "fieldtype": "Check", - "label": "Pin To Bottom", - "search_index": 1 - }, - { - "depends_on": "eval:!doc.extends_another_page || !doc.is_standard", - "fieldname": "charts_label", - "fieldtype": "Data", - "label": "Label" - }, - { - "depends_on": "eval:!doc.extends_another_page || !doc.is_standard", - "fieldname": "shortcuts_label", - "fieldtype": "Data", - "label": "Label" - }, - { - "depends_on": "eval:!doc.extends_another_page || !doc.is_standard", - "fieldname": "cards_label", - "fieldtype": "Data", - "label": "Label" - }, { "collapsible": 1, "collapsible_depends_on": "shortcuts", @@ -153,43 +88,12 @@ "fieldtype": "Section Break", "label": "Link Cards" }, - { - "default": "0", - "fieldname": "is_standard", - "fieldtype": "Check", - "in_list_view": 1, - "in_standard_filter": 1, - "label": "Is Standard", - "search_index": 1 - }, - { - "default": "0", - "fieldname": "extends_another_page", - "fieldtype": "Check", - "label": "Extends Another Page", - "search_index": 1 - }, - { - "depends_on": "eval:doc.extends_another_page == 1 || doc.for_user", - "fieldname": "extends", - "fieldtype": "Link", - "in_standard_filter": 1, - "label": "Extends", - "options": "Workspace", - "search_index": 1 - }, { "fieldname": "for_user", "fieldtype": "Data", "label": "For User", "read_only": 1 }, - { - "fieldname": "onboarding", - "fieldtype": "Link", - "label": "Onboarding", - "options": "Module Onboarding" - }, { "default": "0", "description": "Checking this will hide custom doctypes and reports cards in Links section", @@ -199,7 +103,7 @@ }, { "fieldname": "icon", - "fieldtype": "Data", + "fieldtype": "Icon", "label": "Icon" }, { @@ -209,19 +113,56 @@ "options": "Workspace Link" }, { - "default": "0", - "depends_on": "extends_another_page", - "description": "Sets the current page as default for all users", - "fieldname": "is_default", - "fieldtype": "Check", - "label": "Is Default" - } + "default": "0", + "fieldname": "public", + "fieldtype": "Check", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Public", + "search_index": 1 + }, + { + "fieldname": "title", + "fieldtype": "Data", + "label": "Title", + "reqd": 1 + }, + { + "fieldname": "parent_page", + "fieldtype": "Data", + "label": "Parent Page" + }, + { + "default": "[]", + "fieldname": "content", + "fieldtype": "Long Text", + "hidden": 1, + "label": "Content" + }, + { + "fieldname": "sequence_id", + "fieldtype": "Int", + "label": "Sequence Id" + }, + { + "fieldname": "roles", + "fieldtype": "Table", + "label": "Roles", + "options": "Has Role" + }, + { + "fieldname": "roles_section", + "fieldtype": "Section Break", + "label": "Roles" + } ], + "in_create": 1, "links": [], - "modified": "2021-01-21 12:09:36.156614", + "modified": "2021-09-16 12:01:06.450622", "modified_by": "Administrator", "module": "Desk", "name": "Workspace", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { @@ -232,7 +173,7 @@ "print": 1, "read": 1, "report": 1, - "role": "System Manager", + "role": "Workspace Manager", "share": 1, "write": 1 }, @@ -248,4 +189,4 @@ ], "sort_field": "modified", "sort_order": "DESC" -} +} \ No newline at end of file diff --git a/frappe/desk/doctype/workspace/workspace.py b/frappe/desk/doctype/workspace/workspace.py index 0b5babc8d9..94114e3918 100644 --- a/frappe/desk/doctype/workspace/workspace.py +++ b/frappe/desk/doctype/workspace/workspace.py @@ -1,61 +1,56 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe import _ from frappe.modules.export_file import export_to_files from frappe.model.document import Document +from frappe.desk.desktop import save_new_widget from frappe.desk.utils import validate_route_conflict from json import loads class Workspace(Document): def validate(self): - if (self.is_standard and not frappe.conf.developer_mode and not disable_saving_as_standard()): - frappe.throw(_("You need to be in developer mode to edit this document")) + if (self.public and not is_workspace_manager() and not disable_saving_as_public()): + frappe.throw(_("You need to be Workspace Manager to edit this document")) validate_route_conflict(self.doctype, self.name) - 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.")) + try: + if not isinstance(loads(self.content), list): + raise + except Exception: + frappe.throw(_("Content data shoud be a list")) def on_update(self): - if disable_saving_as_standard(): + if disable_saving_as_public(): return - if frappe.conf.developer_mode and self.is_standard: + if frappe.conf.developer_mode and self.module and self.public: export_to_files(record_list=[['Workspace', self.name]], record_module=self.module) @staticmethod def get_module_page_map(): - filters = { - 'extends_another_page': 0, - 'for_user': '', - } - - pages = frappe.get_all("Workspace", fields=["name", "module"], filters=filters, as_list=1) + pages = frappe.get_all("Workspace", fields=["name", "module"], filters={'for_user': ''}, as_list=1) return { page[1]: page[0] for page in pages if page[1] } def get_link_groups(self): cards = [] - current_card = { + current_card = frappe._dict({ "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 and (not current_card.only_for or current_card.only_for == frappe.get_system_settings('country')): + if card_links and (not current_card.get('only_for') or current_card.get('only_for') == frappe.get_system_settings('country')): current_card['links'] = card_links cards.append(current_card) @@ -69,21 +64,23 @@ class Workspace(Document): return cards - def build_links_table_from_cards(self, config): - # Empty links table - self.links = [] - order = config.get('order') - widgets = config.get('widgets') + def build_links_table_from_card(self, config): - for idx, name in enumerate(order): - card = widgets[name].copy() + for idx, card in enumerate(config): links = loads(card.get('links')) + # remove duplicate before adding + for idx, link in enumerate(self.links): + if link.label == card.get('label') and link.type == 'Card Break': + del self.links[idx : idx + link.link_count + 1] + self.append('links', { "label": card.get('label'), "type": "Card Break", "icon": card.get('icon'), - "hidden": card.get('hidden') or False + "hidden": card.get('hidden') or False, + "link_count": card.get('link_count'), + "idx": 1 if not self.links else self.links[-1].idx + 1 }) for link in links: @@ -95,11 +92,11 @@ class Workspace(Document): "onboard": link.get('onboard'), "only_for": link.get('only_for'), "dependencies": link.get('dependencies'), - "is_query_report": link.get('is_query_report') + "is_query_report": link.get('is_query_report'), + "idx": self.links[-1].idx + 1 }) - -def disable_saving_as_standard(): +def disable_saving_as_public(): return frappe.flags.in_install or \ frappe.flags.in_patch or \ frappe.flags.in_test or \ @@ -123,3 +120,87 @@ def get_link_type(key): def get_report_type(report): report_type = frappe.get_value("Report", report, "report_type") return report_type in ["Query Report", "Script Report", "Custom Report"] + + +@frappe.whitelist() +def save_page(title, icon, parent, public, sb_public_items, sb_private_items, deleted_pages, new_widgets, blocks, save): + save = frappe.parse_json(save) + public = frappe.parse_json(public) + if save: + doc = frappe.new_doc('Workspace') + doc.title = title + doc.icon = icon + doc.content = blocks + doc.parent_page = parent + + if public: + doc.label = title + doc.public = 1 + else: + doc.label = title + "-" + frappe.session.user + doc.for_user = frappe.session.user + doc.save(ignore_permissions=True) + else: + if public: + filters = { + 'public': public, + 'label': title + } + else: + filters = { + 'for_user': frappe.session.user, + 'label': title + "-" + frappe.session.user + } + pages = frappe.get_list("Workspace", filters=filters) + if pages: + doc = frappe.get_doc("Workspace", pages[0]) + + doc.content = blocks + doc.save(ignore_permissions=True) + + if loads(new_widgets): + save_new_widget(doc, title, blocks, new_widgets) + + if loads(sb_public_items) or loads(sb_private_items): + sort_pages(loads(sb_public_items), loads(sb_private_items)) + + if loads(deleted_pages): + return delete_pages(loads(deleted_pages)) + + return {"name": title, "public": public, "label": doc.label} + +def delete_pages(deleted_pages): + for page in deleted_pages: + if page.get("public") and not is_workspace_manager(): + return {"name": page.get("title"), "public": 1, "label": page.get("label")} + + if frappe.db.exists("Workspace", page.get("name")): + frappe.get_doc("Workspace", page.get("name")).delete(ignore_permissions=True) + + return {"name": "Home", "public": 1, "label": "Home"} + +def sort_pages(sb_public_items, sb_private_items): + wspace_public_pages = get_page_list(['name', 'title'], {'public': 1}) + wspace_private_pages = get_page_list(['name', 'title'], {'for_user': frappe.session.user}) + + if sb_private_items: + sort_page(wspace_private_pages, sb_private_items) + + if sb_public_items and is_workspace_manager(): + sort_page(wspace_public_pages, sb_public_items) + +def sort_page(wspace_pages, pages): + for seq, d in enumerate(pages): + for page in wspace_pages: + if page.title == d.get('title'): + doc = frappe.get_doc('Workspace', page.name) + doc.sequence_id = seq + 1 + doc.parent_page = d.get('parent_page') or "" + doc.save(ignore_permissions=True) + break + +def get_page_list(fields, filters): + return frappe.get_list("Workspace", fields=fields, filters=filters, order_by='sequence_id asc') + +def is_workspace_manager(): + return "Workspace Manager" in frappe.get_roles() diff --git a/frappe/desk/doctype/workspace_chart/workspace_chart.py b/frappe/desk/doctype/workspace_chart/workspace_chart.py index 6ec7abfd3c..a3b66d99ab 100644 --- a/frappe/desk/doctype/workspace_chart/workspace_chart.py +++ b/frappe/desk/doctype/workspace_chart/workspace_chart.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2021, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/desk/doctype/workspace_link/workspace_link.json b/frappe/desk/doctype/workspace_link/workspace_link.json index 53dadad83d..a7b217be9e 100644 --- a/frappe/desk/doctype/workspace_link/workspace_link.json +++ b/frappe/desk/doctype/workspace_link/workspace_link.json @@ -8,15 +8,16 @@ "type", "label", "icon", - "only_for", "hidden", "link_details_section", "link_type", "link_to", "column_break_7", "dependencies", + "only_for", "onboard", - "is_query_report" + "is_query_report", + "link_count" ], "fields": [ { @@ -99,12 +100,19 @@ "fieldname": "is_query_report", "fieldtype": "Check", "label": "Is Query Report" + }, + { + "depends_on": "eval:doc.type == \"Card Break\"", + "fieldname": "link_count", + "fieldtype": "Int", + "hidden": 1, + "label": "Link Count" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-05-13 13:10:18.128512", + "modified": "2021-06-01 11:23:28.990593", "modified_by": "Administrator", "module": "Desk", "name": "Workspace Link", diff --git a/frappe/desk/doctype/workspace_link/workspace_link.py b/frappe/desk/doctype/workspace_link/workspace_link.py index d6ccc5306a..72256ba490 100644 --- a/frappe/desk/doctype/workspace_link/workspace_link.py +++ b/frappe/desk/doctype/workspace_link/workspace_link.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2021, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/desk/doctype/workspace_shortcut/workspace_shortcut.py b/frappe/desk/doctype/workspace_shortcut/workspace_shortcut.py index 83b446e454..1dad4cca05 100644 --- a/frappe/desk/doctype/workspace_shortcut/workspace_shortcut.py +++ b/frappe/desk/doctype/workspace_shortcut/workspace_shortcut.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2021, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/desk/form/__init__.py b/frappe/desk/form/__init__.py index 0e57cb68c3..eb5ba62e5c 100644 --- a/frappe/desk/form/__init__.py +++ b/frappe/desk/form/__init__.py @@ -1,3 +1,3 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE diff --git a/frappe/desk/form/assign_to.py b/frappe/desk/form/assign_to.py index 3eda291d1e..bf77170eeb 100644 --- a/frappe/desk/form/assign_to.py +++ b/frappe/desk/form/assign_to.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE """assign/unassign to ToDo""" diff --git a/frappe/desk/form/document_follow.py b/frappe/desk/form/document_follow.py index 7f65f76a58..14970092d0 100644 --- a/frappe/desk/form/document_follow.py +++ b/frappe/desk/form/document_follow.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe import frappe.utils diff --git a/frappe/desk/form/linked_with.py b/frappe/desk/form/linked_with.py index ae48b7fc6b..4550fdf0e6 100644 --- a/frappe/desk/form/linked_with.py +++ b/frappe/desk/form/linked_with.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import json from collections import defaultdict @@ -77,7 +77,7 @@ def get_submitted_linked_docs(doctype, name, docs=None, visited=None): @frappe.whitelist() -def cancel_all_linked_docs(docs, ignore_doctypes_on_cancel_all=[]): +def cancel_all_linked_docs(docs, ignore_doctypes_on_cancel_all=None): """ Cancel all linked doctype, optionally ignore doctypes specified in a list. @@ -85,6 +85,8 @@ def cancel_all_linked_docs(docs, ignore_doctypes_on_cancel_all=[]): docs (json str) - It contains list of dictionaries of a linked documents. ignore_doctypes_on_cancel_all (list) - List of doctypes to ignore while cancelling. """ + if ignore_doctypes_on_cancel_all is None: + ignore_doctypes_on_cancel_all = [] docs = json.loads(docs) if isinstance(ignore_doctypes_on_cancel_all, str): @@ -96,7 +98,7 @@ def cancel_all_linked_docs(docs, ignore_doctypes_on_cancel_all=[]): frappe.publish_progress(percent=i/len(docs) * 100, title=_("Cancelling documents")) -def validate_linked_doc(docinfo, ignore_doctypes_on_cancel_all=[]): +def validate_linked_doc(docinfo, ignore_doctypes_on_cancel_all=None): """ Validate a document to be submitted and non-exempted from auto-cancel. @@ -109,7 +111,7 @@ def validate_linked_doc(docinfo, ignore_doctypes_on_cancel_all=[]): """ #ignore doctype to cancel - if docinfo.get("doctype") in ignore_doctypes_on_cancel_all: + if docinfo.get("doctype") in (ignore_doctypes_on_cancel_all or []): return False # skip non-submittable doctypes since they don't need to be cancelled diff --git a/frappe/desk/form/load.py b/frappe/desk/form/load.py index a62bfd01d0..89e6598859 100644 --- a/frappe/desk/form/load.py +++ b/frappe/desk/form/load.py @@ -1,6 +1,7 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE +from typing import Dict, List, Union import frappe, json import frappe.utils import frappe.share @@ -12,7 +13,7 @@ from frappe.desk.form.document_follow import is_document_followed from frappe import _ from urllib.parse import quote -@frappe.whitelist(allow_guest=True) +@frappe.whitelist() def getdoc(doctype, name, user=None): """ Loads a doclist for a given document. This method is called directly from the client. @@ -51,7 +52,7 @@ def getdoc(doctype, name, user=None): frappe.response.docs.append(doc) -@frappe.whitelist(allow_guest=True) +@frappe.whitelist() def getdoctype(doctype, with_parent=False, cached_timestamp=None): """load doctype""" @@ -105,9 +106,10 @@ def get_docinfo(doc=None, doctype=None, name=None): "assignment_logs": get_comments(doc.doctype, doc.name, 'assignment'), "permissions": get_doc_permissions(doc), "shared": frappe.share.get_users(doc.doctype, doc.name), - "info_logs": get_comments(doc.doctype, doc.name, 'Info'), + "info_logs": get_comments(doc.doctype, doc.name, comment_type=['Info', 'Edit', 'Label']), "share_logs": get_comments(doc.doctype, doc.name, 'share'), "like_logs": get_comments(doc.doctype, doc.name, 'Like'), + "workflow_logs": get_comments(doc.doctype, doc.name, comment_type="Workflow"), "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), @@ -138,10 +140,11 @@ def get_communications(doctype, name, start=0, limit=20): return _get_communications(doctype, name, start, limit) -def get_comments(doctype, name, comment_type='Comment'): - comment_types = [comment_type] +def get_comments(doctype: str, name: str, comment_type : Union[str, List[str]] = "Comment") -> List[frappe._dict]: + if isinstance(comment_type, list): + comment_types = comment_type - if comment_type == 'share': + elif comment_type == 'share': comment_types = ['Shared', 'Unshared'] elif comment_type == 'assignment': @@ -150,15 +153,21 @@ def get_comments(doctype, name, comment_type='Comment'): 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, - comment_type = ['in', comment_types] - )) + else: + comment_types = [comment_type] + + comments = frappe.get_all("Comment", + fields=["name", "creation", "content", "owner", "comment_type"], + filters={ + "reference_doctype": doctype, + "reference_name": name, + "comment_type": ['in', comment_types], + } + ) # convert to markdown (legacy ?) - if comment_type == 'Comment': - for c in comments: + for c in comments: + if c.comment_type == "Comment": c.content = frappe.utils.markdown(c.content) return comments diff --git a/frappe/desk/form/meta.py b/frappe/desk/form/meta.py index cf3606e785..b91dd3d481 100644 --- a/frappe/desk/form/meta.py +++ b/frappe/desk/form/meta.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import io import os diff --git a/frappe/desk/form/save.py b/frappe/desk/form/save.py index a7a4b829d8..b580e2c769 100644 --- a/frappe/desk/form/save.py +++ b/frappe/desk/form/save.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe, json from frappe.desk.form.load import run_onload diff --git a/frappe/desk/form/test_form.py b/frappe/desk/form/test_form.py index f3c4132777..86c3aba29a 100644 --- a/frappe/desk/form/test_form.py +++ b/frappe/desk/form/test_form.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe, unittest diff --git a/frappe/desk/form/utils.py b/frappe/desk/form/utils.py index bfceee6ea2..291767de10 100644 --- a/frappe/desk/form/utils.py +++ b/frappe/desk/form/utils.py @@ -1,11 +1,11 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe, json import frappe.desk.form.meta import frappe.desk.form.load from frappe.desk.form.document_follow import follow_document -from frappe.utils.file_manager import extract_images_from_html +from frappe.core.doctype.file.file import extract_images_from_html from frappe import _ @@ -16,44 +16,6 @@ def remove_attach(): file_name = frappe.form_dict.get('file_name') frappe.delete_doc('File', fid) -@frappe.whitelist() -def validate_link(): - """validate link when updated by user""" - import frappe - import frappe.utils - - value, options, fetch = frappe.form_dict.get('value'), frappe.form_dict.get('options'), frappe.form_dict.get('fetch') - - # no options, don't validate - if not options or options=='null' or options=='undefined': - frappe.response['message'] = 'Ok' - return - - valid_value = frappe.db.get_all(options, filters=dict(name=value), as_list=1, limit=1) - - if valid_value: - valid_value = valid_value[0][0] - - # get fetch values - if fetch: - # escape with "`" - fetch = ", ".join(("`{0}`".format(f.strip()) for f in fetch.split(","))) - fetch_value = None - try: - fetch_value = frappe.db.sql("select %s from `tab%s` where name=%s" - % (fetch, options, '%s'), (value,))[0] - except Exception as e: - error_message = str(e).split("Unknown column '") - fieldname = None if len(error_message)<=1 else error_message[1].split("'")[0] - frappe.msgprint(_("Wrong fieldname {0} in add_fetch configuration of custom client script").format(fieldname)) - frappe.errprint(frappe.get_traceback()) - - if fetch_value: - frappe.response['fetch_values'] = [frappe.utils.parse_val(c) for c in fetch_value] - - frappe.response['valid_value'] = valid_value - frappe.response['message'] = 'Ok' - @frappe.whitelist() def add_comment(reference_doctype, reference_name, content, comment_email, comment_by): @@ -66,7 +28,8 @@ def add_comment(reference_doctype, reference_name, content, comment_email, comme comment_type='Comment', comment_by=comment_by )) - doc.content = extract_images_from_html(doc, content) + reference_doc = frappe.get_doc(reference_doctype, reference_name) + doc.content = extract_images_from_html(reference_doc, content, is_private=True) doc.insert(ignore_permissions=True) follow_document(doc.reference_doctype, doc.reference_name, frappe.session.user) diff --git a/frappe/desk/gantt.py b/frappe/desk/gantt.py index 7f0889c751..58ef3b836e 100644 --- a/frappe/desk/gantt.py +++ b/frappe/desk/gantt.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe, json diff --git a/frappe/desk/like.py b/frappe/desk/like.py index d44d58a761..4480ed8a1e 100644 --- a/frappe/desk/like.py +++ b/frappe/desk/like.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE """Allow adding of likes to documents""" diff --git a/frappe/desk/link_preview.py b/frappe/desk/link_preview.py index 9b4471aa8d..03f8368a3a 100644 --- a/frappe/desk/link_preview.py +++ b/frappe/desk/link_preview.py @@ -40,6 +40,10 @@ def get_preview_data(doctype, docname): for key, val in preview_data.items(): if val and meta.has_field(key) and key not in [image_field, title_field, 'name']: - formatted_preview_data[meta.get_field(key).label] = frappe.format(val, meta.get_field(key).fieldtype) + formatted_preview_data[meta.get_field(key).label] = frappe.format( + val, + meta.get_field(key).fieldtype, + translated=True, + ) return formatted_preview_data diff --git a/frappe/desk/listview.py b/frappe/desk/listview.py index d2c84d36bf..43ad104f0d 100644 --- a/frappe/desk/listview.py +++ b/frappe/desk/listview.py @@ -1,8 +1,8 @@ # Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe -@frappe.whitelist(allow_guest=True) +@frappe.whitelist() def get_list_settings(doctype): try: return frappe.get_cached_doc("List View Settings", doctype) @@ -26,7 +26,7 @@ def get_group_by_count(doctype, current_filters, field): current_filters = frappe.parse_json(current_filters) subquery_condition = '' - subquery = frappe.get_all(doctype, filters=current_filters, return_query = True) + subquery = frappe.get_all(doctype, filters=current_filters, run=False) if field == 'assigned_to': subquery_condition = ' and `tabToDo`.reference_name in ({subquery})'.format(subquery = subquery) return frappe.db.sql("""select `tabToDo`.owner as name, count(*) as count diff --git a/frappe/desk/moduleview.py b/frappe/desk/moduleview.py index 021698ac92..e2e2c4c155 100644 --- a/frappe/desk/moduleview.py +++ b/frappe/desk/moduleview.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe import json diff --git a/frappe/desk/notifications.py b/frappe/desk/notifications.py index c84027928e..3fa41790b4 100644 --- a/frappe/desk/notifications.py +++ b/frappe/desk/notifications.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe from frappe.desk.doctype.notification_settings.notification_settings import get_subscribed_documents @@ -216,7 +216,7 @@ def get_filters_for(doctype): @frappe.whitelist() @frappe.read_only() -def get_open_count(doctype, name, items=[]): +def get_open_count(doctype, name, items=None): '''Get open count for given transactions and filters :param doctype: Reference DocType @@ -235,7 +235,8 @@ def get_open_count(doctype, name, items=[]): links = meta.get_dashboard_data() # compile all items in a list - if not items: + if items is None: + items = [] for group in links.transactions: items.extend(group.get("items")) diff --git a/frappe/desk/page/activity/activity.py b/frappe/desk/page/activity/activity.py index 3abc8e0ea5..71130f2304 100644 --- a/frappe/desk/page/activity/activity.py +++ b/frappe/desk/page/activity/activity.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: See license.txt +# License: MIT. See LICENSE import frappe from frappe.utils import cint diff --git a/frappe/desk/page/backups/backups.html b/frappe/desk/page/backups/backups.html index e63481487c..ff10f1bd06 100644 --- a/frappe/desk/page/backups/backups.html +++ b/frappe/desk/page/backups/backups.html @@ -1,20 +1,27 @@
- {% for f in files %} - - {% endfor %} + {% for f in files %} + + {% endfor %}
\ No newline at end of file diff --git a/frappe/desk/page/backups/backups.js b/frappe/desk/page/backups/backups.js index 337ad33f43..d6cab750f0 100644 --- a/frappe/desk/page/backups/backups.js +++ b/frappe/desk/page/backups/backups.js @@ -1,4 +1,4 @@ -frappe.pages['backups'].on_page_load = function(wrapper) { +frappe.pages['backups'].on_page_load = function (wrapper) { var page = frappe.ui.make_app_page({ parent: wrapper, title: __('Download Backups'), @@ -11,12 +11,35 @@ frappe.pages['backups'].on_page_load = function(wrapper) { page.add_inner_button(__("Download Files Backup"), function () { frappe.call({ - method:"frappe.desk.page.backups.backups.schedule_files_backup", - args: {"user_email": frappe.session.user_email} + method: "frappe.desk.page.backups.backups.schedule_files_backup", + args: { "user_email": frappe.session.user_email } }); }); + page.add_inner_button(__("Get Backup Encryption Key"), function () { + if (frappe.user.has_role("System Manager")) { + frappe.verify_password(function () { + frappe.call({ + method: "frappe.utils.backups.get_backup_encryption_key", + callback: function (r) { + frappe.msgprint({ + title: __('Backup Encryption Key'), + message: __(r.message), + indicator: 'blue' + }); + } + }); + }); + } else { + frappe.msgprint({ + title: __('Error'), + message: __('System Manager privileges required.'), + indicator: 'red' + }); + } + }); + frappe.breadcrumbs.add("Setup"); $(frappe.render_template("backups")).appendTo(page.body.addClass("no-border")); -} +}; diff --git a/frappe/desk/page/backups/backups.py b/frappe/desk/page/backups/backups.py index 2229a6d89e..14ed025e08 100644 --- a/frappe/desk/page/backups/backups.py +++ b/frappe/desk/page/backups/backups.py @@ -11,6 +11,10 @@ def get_context(context): dt = os.path.getmtime(path) return convert_utc_to_user_timezone(datetime.datetime.utcfromtimestamp(dt)).strftime('%a %b %d %H:%M %Y') + def get_encrytion_status(path): + if "-enc" in path: + return True + def get_size(path): size = os.path.getsize(path) if size > 1048576: @@ -26,8 +30,9 @@ def get_context(context): cleanup_old_backups(path, files, backup_limit) files = [('/backups/' + _file, - get_time(os.path.join(path, _file)), - get_size(os.path.join(path, _file))) for _file in files if _file.endswith('sql.gz')] + get_time(os.path.join(path, _file)), + get_encrytion_status(os.path.join(path, _file)), + get_size(os.path.join(path, _file))) for _file in files if _file.endswith('sql.gz')] files.sort(key=lambda x: x[1], reverse=True) return {"files": files[:backup_limit]} diff --git a/frappe/desk/page/leaderboard/leaderboard.js b/frappe/desk/page/leaderboard/leaderboard.js index b3fccf84f9..076d672db5 100644 --- a/frappe/desk/page/leaderboard/leaderboard.js +++ b/frappe/desk/page/leaderboard/leaderboard.js @@ -141,7 +141,7 @@ 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({ diff --git a/frappe/desk/page/leaderboard/leaderboard.py b/frappe/desk/page/leaderboard/leaderboard.py index 9469096f50..ad22eb9199 100644 --- a/frappe/desk/page/leaderboard/leaderboard.py +++ b/frappe/desk/page/leaderboard/leaderboard.py @@ -1,5 +1,5 @@ # Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe @frappe.whitelist() diff --git a/frappe/desk/page/setup_wizard/install_fixtures.py b/frappe/desk/page/setup_wizard/install_fixtures.py index 06301cdeaf..1ef83f7ba0 100644 --- a/frappe/desk/page/setup_wizard/install_fixtures.py +++ b/frappe/desk/page/setup_wizard/install_fixtures.py @@ -1,5 +1,5 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# License: MIT. See LICENSE import frappe from frappe import _ diff --git a/frappe/desk/page/setup_wizard/setup_wizard.py b/frappe/desk/page/setup_wizard/setup_wizard.py index 5edb44e182..c729c1d78b 100755 --- a/frappe/desk/page/setup_wizard/setup_wizard.py +++ b/frappe/desk/page/setup_wizard/setup_wizard.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: See license.txt +# License: MIT. See LICENSE import frappe, json, os from frappe.utils import strip, cint diff --git a/frappe/desk/query_report.py b/frappe/desk/query_report.py index 3c0ebf11c1..97bceeb725 100644 --- a/frappe/desk/query_report.py +++ b/frappe/desk/query_report.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe import os @@ -59,6 +59,20 @@ def get_report_doc(report_name): return doc +def get_report_result(report, filters): + if report.report_type == "Query Report": + res = report.execute_query_report(filters) + + elif report.report_type == "Script Report": + res = report.execute_script_report(filters) + + elif report.report_type == "Custom Report": + ref_report = get_report_doc(report.report_name) + res = get_report_result(ref_report, filters) + + return res + +@frappe.read_only() def generate_report_result(report, filters=None, user=None, custom_columns=None): user = user or frappe.session.user filters = filters or [] @@ -66,13 +80,7 @@ def generate_report_result(report, filters=None, user=None, custom_columns=None) if filters and isinstance(filters, str): filters = json.loads(filters) - res = [] - - if report.report_type == "Query Report": - res = report.execute_query_report(filters) - - elif report.report_type == "Script Report": - res = report.execute_script_report(filters) + res = get_report_result(report, filters) or [] columns, result, message, chart, report_summary, skip_total_row = ljust_list(res, 6) columns = [get_column_as_dict(col) for col in columns] @@ -177,11 +185,13 @@ def get_script(report_name): if os.path.exists(script_path): with open(script_path, "r") as f: script = f.read() + script += f"\n\n//# sourceURL={scrub(report.name)}.js" html_format = get_html_format(print_path) if not script and report.javascript: script = report.javascript + script += f"\n\n//# sourceURL={scrub(report.name)}__custom" if not script: script = "frappe.query_reports['%s']={}" % report_name @@ -389,14 +399,14 @@ def handle_duration_fieldtype_values(result, columns): return result -def build_xlsx_data(columns, data, visible_idx, include_indentation): +def build_xlsx_data(columns, data, visible_idx, include_indentation, ignore_visible_idx=False): result = [[]] column_widths = [] for column in data.columns: if column.get("hidden"): continue - result[0].append(column["label"]) + result[0].append(column.get("label")) column_width = cint(column.get('width', 0)) # to convert into scale accepted by openpyxl column_width /= 10 @@ -405,7 +415,7 @@ def build_xlsx_data(columns, data, visible_idx, include_indentation): # build table from result for row_idx, row in enumerate(data.result): # only pick up rows that are visible in the report - if row_idx in visible_idx: + if ignore_visible_idx or row_idx in visible_idx: row_data = [] if isinstance(row, dict): for col_idx, column in enumerate(data.columns): diff --git a/frappe/desk/report/todo/todo.py b/frappe/desk/report/todo/todo.py index 6bd22b843e..b1e49bc95d 100644 --- a/frappe/desk/report/todo/todo.py +++ b/frappe/desk/report/todo/todo.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe from frappe import _ diff --git a/frappe/desk/report_dump.py b/frappe/desk/report_dump.py index b2d3ca3443..f57ed97fa5 100644 --- a/frappe/desk/report_dump.py +++ b/frappe/desk/report_dump.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe diff --git a/frappe/desk/reportview.py b/frappe/desk/reportview.py index 55515856f1..fb150e4bea 100644 --- a/frappe/desk/reportview.py +++ b/frappe/desk/reportview.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE """build query for doclistview and return results""" @@ -14,7 +14,7 @@ from frappe.utils import cstr, format_duration from frappe.model.base_document import get_controller -@frappe.whitelist(allow_guest=True) +@frappe.whitelist() @frappe.read_only() def get(): args = get_form_params() @@ -121,12 +121,14 @@ def validate_filters(data, filters): def setup_group_by(data): '''Add columns for aggregated values e.g. count(name)''' - if data.group_by: + if data.group_by and data.aggregate_function: if data.aggregate_function.lower() not in ('count', 'sum', 'avg'): frappe.throw(_('Invalid aggregate function')) if frappe.db.has_column(data.aggregate_on_doctype, data.aggregate_on_field): data.fields.append('{aggregate_function}(`tab{aggregate_on_doctype}`.`{aggregate_on_field}`) AS _aggregate_column'.format(**data)) + if data.aggregate_on_field: + data.fields.append(f"`tab{data.aggregate_on_doctype}`.`{data.aggregate_on_field}`") else: raise_invalid_field(data.aggregate_on_field) @@ -178,15 +180,16 @@ def update_wildcard_field_param(data): def clean_params(data): - data.pop('cmd', None) - data.pop('data', None) - data.pop('ignore_permissions', None) - data.pop('view', None) - data.pop('user', None) - - if "csrf_token" in data: - del data["csrf_token"] - + for param in ( + "cmd", + "data", + "ignore_permissions", + "view", + "user", + "csrf_token", + "join" + ): + data.pop(param, None) def parse_json(data): if isinstance(data.get("filters"), str): @@ -212,11 +215,13 @@ def get_parenttype_and_fieldname(field, data): return parenttype, fieldname -def compress(data, args = {}): +def compress(data, args=None): """separate keys and values""" from frappe.desk.query_report import add_total_row if not data: return data + if args is None: + args = {} values = [] keys = list(data[0]) for row in data: @@ -421,15 +426,20 @@ def delete_bulk(doctype, items): @frappe.whitelist() @frappe.read_only() -def get_sidebar_stats(stats, doctype, filters=[]): +def get_sidebar_stats(stats, doctype, filters=None): + if filters is None: + filters = [] return {"stats": get_stats(stats, doctype, filters)} @frappe.whitelist() @frappe.read_only() -def get_stats(stats, doctype, filters=[]): +def get_stats(stats, doctype, filters=None): """get tag info""" import json + + if filters is None: + filters = [] tags = json.loads(stats) if filters: filters = json.loads(filters) @@ -445,33 +455,44 @@ def get_stats(stats, doctype, filters=[]): for tag in tags: if not tag in columns: continue try: - tagcount = frappe.get_list(doctype, fields=[tag, "count(*)"], - #filters=["ifnull(`%s`,'')!=''" % tag], group_by=tag, as_list=True) - filters = filters + ["ifnull(`%s`,'')!=''" % tag], group_by = tag, as_list = True) + tag_count = frappe.get_list(doctype, + fields=[tag, "count(*)"], + filters=filters + [[tag, '!=', '']], + group_by=tag, + as_list=True, + distinct=1, + ) - if tag=='_user_tags': - stats[tag] = scrub_user_tags(tagcount) - stats[tag].append([_("No Tags"), frappe.get_list(doctype, + if tag == '_user_tags': + stats[tag] = scrub_user_tags(tag_count) + no_tag_count = frappe.get_list(doctype, fields=[tag, "count(*)"], - filters=filters +["({0} = ',' or {0} = '' or {0} is null)".format(tag)], as_list=True)[0][1]]) + filters=filters + [[tag, "in", ('', ',')]], + as_list=True, + group_by=tag, + order_by=tag, + ) + + no_tag_count = no_tag_count[0][1] if no_tag_count else 0 + + stats[tag].append([_("No Tags"), no_tag_count]) else: - stats[tag] = tagcount + stats[tag] = tag_count except frappe.db.SQLError: - # does not work for child tables pass - except frappe.db.InternalError: + except frappe.db.InternalError as e: # raised when _user_tags column is added on the fly pass + return stats @frappe.whitelist() -def get_filter_dashboard_data(stats, doctype, filters=[]): +def get_filter_dashboard_data(stats, doctype, filters=None): """get tags info""" import json tags = json.loads(stats) - if filters: - filters = json.loads(filters) + filters = json.loads(filters or []) stats = {} columns = frappe.db.get_table_columns(doctype) diff --git a/frappe/desk/search.py b/frappe/desk/search.py index 040a8c2118..db88e6ec52 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE # Search import frappe, json @@ -168,7 +168,18 @@ def search_widget(doctype, txt, query=None, searchfield=None, start=0, strict=False) if doctype in UNTRANSLATED_DOCTYPES: - values = tuple([v for v in list(values) if re.search(re.escape(txt)+".*", (_(v.name) if as_dict else _(v[0])), re.IGNORECASE)]) + # Filtering the values array so that query is included in very element + values = ( + v for v in values + if re.search( + f"{re.escape(txt)}.*", _(v.name if as_dict else v[0]), re.IGNORECASE + ) + ) + + # Sorting the values array so that relevant results always come first + # This will first bring elements on top in which query is a prefix of element + # Then it will bring the rest of the elements and sort them in lexicographical order + values = sorted(values, key=lambda x: relevance_sorter(x, txt, as_dict)) # remove _relevance from results if as_dict: @@ -208,6 +219,13 @@ def scrub_custom_query(query, key, txt): query = query.replace('%s', ((txt or '') + '%')) return query +def relevance_sorter(key, query, as_dict): + value = _(key.name if as_dict else key[0]) + return ( + value.lower().startswith(query.lower()) is not True, + value + ) + @wrapt.decorator def validate_and_sanitize_search_inputs(fn, instance, args, kwargs): kwargs.update(dict(zip(fn.__code__.co_varnames, args))) @@ -247,6 +265,7 @@ def get_users_for_mentions(): 'name': ['not in', ('Administrator', 'Guest')], 'allowed_in_mentions': True, 'user_type': 'System User', + 'enabled': True, }) def get_user_groups(): diff --git a/frappe/desk/treeview.py b/frappe/desk/treeview.py index 66acde4cb2..f40c135653 100644 --- a/frappe/desk/treeview.py +++ b/frappe/desk/treeview.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe from frappe import _ @@ -69,13 +69,11 @@ def make_tree_args(**kwarg): doctype = kwarg['doctype'] parent_field = 'parent_' + doctype.lower().replace(' ', '_') - name_field = kwarg.get('name_field', doctype.lower().replace(' ', '_') + '_name') if kwarg['is_root'] == 'false': kwarg['is_root'] = False if kwarg['is_root'] == 'true': kwarg['is_root'] = True kwarg.update({ - name_field: kwarg[name_field], parent_field: kwarg.get("parent") or kwarg.get(parent_field) }) diff --git a/frappe/desk/utils.py b/frappe/desk/utils.py index 01b47ac106..5908277386 100644 --- a/frappe/desk/utils.py +++ b/frappe/desk/utils.py @@ -1,5 +1,5 @@ # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe diff --git a/frappe/email/__init__.py b/frappe/email/__init__.py index 3fb539398a..79dec977b7 100644 --- a/frappe/email/__init__.py +++ b/frappe/email/__init__.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe from frappe.desk.reportview import build_match_conditions 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 f30279e308..34728375cd 100644 --- a/frappe/email/doctype/auto_email_report/auto_email_report.py +++ b/frappe/email/doctype/auto_email_report/auto_email_report.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import calendar from datetime import timedelta @@ -13,6 +13,7 @@ from frappe.utils import (format_time, get_link_to_form, get_url_to_report, from frappe.model.naming import append_number_if_name_exists from frappe.utils.csvutils import to_csv from frappe.utils.xlsxutils import make_xlsx +from frappe.desk.query_report import build_xlsx_data max_reports_per_user = frappe.local.conf.max_reports_per_user or 3 @@ -99,13 +100,21 @@ class AutoEmailReport(Document): return self.get_html_table(columns, data) elif self.format == 'XLSX': - spreadsheet_data = self.get_spreadsheet_data(columns, data) - xlsx_file = make_xlsx(spreadsheet_data, "Auto Email Report") + report_data = frappe._dict() + report_data['columns'] = columns + report_data['result'] = data + + xlsx_data, column_widths = build_xlsx_data(columns, report_data, [], 1, ignore_visible_idx=True) + xlsx_file = make_xlsx(xlsx_data, "Auto Email Report", column_widths=column_widths) return xlsx_file.getvalue() elif self.format == 'CSV': - spreadsheet_data = self.get_spreadsheet_data(columns, data) - return to_csv(spreadsheet_data) + report_data = frappe._dict() + report_data['columns'] = columns + report_data['result'] = data + + xlsx_data, column_widths = build_xlsx_data(columns, report_data, [], 1, ignore_visible_idx=True) + return to_csv(xlsx_data) else: frappe.throw(_('Invalid Output Format')) @@ -126,18 +135,6 @@ class AutoEmailReport(Document): 'edit_report_settings': get_link_to_form('Auto Email Report', self.name) }) - @staticmethod - def get_spreadsheet_data(columns, data): - out = [[_(df.label) for df in columns], ] - for row in data: - new_row = [] - out.append(new_row) - for df in columns: - if df.fieldname not in row: continue - new_row.append(frappe.format(row[df.fieldname], df, row)) - - return out - def get_file_name(self): return "{0}.{1}".format(self.report.replace(" ", "-").replace("/", "-"), self.format.lower()) @@ -245,14 +242,17 @@ def make_links(columns, data): for row in data: doc_name = row.get('name') for col in columns: - if col.fieldtype == "Link" and col.options != "Currency": - if col.options and row.get(col.fieldname): + if not row.get(col.fieldname): + continue + + if col.fieldtype == "Link": + if col.options and col.options != "Currency": row[col.fieldname] = get_link_to_form(col.options, row[col.fieldname]) elif col.fieldtype == "Dynamic Link": - if col.options and row.get(col.fieldname) and row.get(col.options): + if col.options and row.get(col.options): row[col.fieldname] = get_link_to_form(row[col.options], row[col.fieldname]) - elif col.fieldtype == "Currency" and row.get(col.fieldname): - doc = frappe.get_doc(col.parent, doc_name) if doc_name else None + elif col.fieldtype == "Currency": + doc = frappe.get_doc(col.parent, doc_name) if doc_name and col.parent else None # Pass the Document to get the currency based on docfield option row[col.fieldname] = frappe.format_value(row[col.fieldname], col, doc=doc) return columns, data diff --git a/frappe/email/doctype/auto_email_report/test_auto_email_report.py b/frappe/email/doctype/auto_email_report/test_auto_email_report.py index 211a141ec0..559adfbe1a 100644 --- a/frappe/email/doctype/auto_email_report/test_auto_email_report.py +++ b/frappe/email/doctype/auto_email_report/test_auto_email_report.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import json import unittest diff --git a/frappe/email/doctype/document_follow/document_follow.py b/frappe/email/doctype/document_follow/document_follow.py index a04f8ef4c2..97f8237736 100644 --- a/frappe/email/doctype/document_follow/document_follow.py +++ b/frappe/email/doctype/document_follow/document_follow.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE from frappe.model.document import Document diff --git a/frappe/email/doctype/document_follow/test_document_follow.py b/frappe/email/doctype/document_follow/test_document_follow.py index 456c0931f8..050add65e9 100644 --- a/frappe/email/doctype/document_follow/test_document_follow.py +++ b/frappe/email/doctype/document_follow/test_document_follow.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest import frappe.desk.form.document_follow as document_follow diff --git a/frappe/email/doctype/email_account/email_account.js b/frappe/email/doctype/email_account/email_account.js index 83896e0af7..277bf43eb6 100644 --- a/frappe/email/doctype/email_account/email_account.js +++ b/frappe/email/doctype/email_account/email_account.js @@ -151,18 +151,6 @@ frappe.ui.form.on("Email Account", { callback: function (r) { if (r.message) { frm.events.set_domain_fields(frm, r.message); - } else { - frm.set_value("domain", ""); - frappe.confirm(__('Email Domain not configured for this account, Create one?'), - function () { - frappe.model.with_doctype("Email Domain", function() { - frappe.route_options = { email_id: frm.doc.email_id }; - frappe.route_flags.return_to_email_account = 1; - var doc = frappe.model.get_new_doc("Email Domain"); - frappe.set_route("Form", "Email Domain", doc.name); - }); - } - ); } } }); diff --git a/frappe/email/doctype/email_account/email_account.json b/frappe/email/doctype/email_account/email_account.json index 6d811b801f..e20f38c74a 100644 --- a/frappe/email/doctype/email_account/email_account.json +++ b/frappe/email/doctype/email_account/email_account.json @@ -7,30 +7,34 @@ "document_type": "Setup", "engine": "InnoDB", "field_order": [ + "account_section", "email_id", - "login_id_is_different", - "login_id", + "email_account_name", + "column_break_3", + "domain", + "service", + "authentication_column", "password", "awaiting_password", "ascii_encode_password", - "email_account_name", - "email_settings", - "domain", - "service", + "column_break_10", + "login_id_is_different", + "login_id", "mailbox_settings", "enable_incoming", - "use_imap", - "email_server", - "use_ssl", - "append_emails_to_sent_folder", - "incoming_port", - "attachment_limit", - "append_to", "default_incoming", + "use_imap", + "use_ssl", + "email_server", + "incoming_port", + "column_break_18", + "attachment_limit", "email_sync_option", "initial_sync_count", - "create_contact", "section_break_12", + "append_emails_to_sent_folder", + "append_to", + "create_contact", "enable_automatic_linking", "section_break_13", "notify_if_unreplied", @@ -42,6 +46,7 @@ "use_tls", "use_ssl_for_outgoing", "smtp_port", + "column_break_38", "default_outgoing", "always_use_account_email_id_as_sender", "always_use_account_name_as_sender_name", @@ -80,7 +85,7 @@ "fieldtype": "Check", "hide_days": 1, "hide_seconds": 1, - "label": "Use Different Email Login ID" + "label": "Use different login" }, { "depends_on": "login_id_is_different", @@ -122,12 +127,6 @@ "label": "Email Account Name", "unique": 1 }, - { - "fieldname": "email_settings", - "fieldtype": "Section Break", - "hide_days": 1, - "hide_seconds": 1 - }, { "depends_on": "eval:!doc.service", "fieldname": "domain", @@ -136,7 +135,7 @@ "hide_seconds": 1, "in_list_view": 1, "in_standard_filter": 1, - "label": "Domain", + "label": "Domain (optional)", "options": "Email Domain" }, { @@ -145,18 +144,18 @@ "fieldtype": "Select", "hide_days": 1, "hide_seconds": 1, - "label": "Service", + "label": "Service (optional)", "options": "\nGMail\nSendgrid\nSparkPost\nYahoo Mail\nOutlook.com\nYandex.Mail" }, { "fieldname": "mailbox_settings", "fieldtype": "Section Break", "hide_days": 1, - "hide_seconds": 1 + "hide_seconds": 1, + "label": "Incoming (POP/IMAP) Settings" }, { "default": "0", - "description": "Check this to pull emails from your mailbox", "fieldname": "enable_incoming", "fieldtype": "Check", "hide_days": 1, @@ -227,7 +226,7 @@ }, { "default": "UNSEEN", - "depends_on": "eval: doc.enable_incoming", + "depends_on": "eval: doc.enable_incoming && doc.use_imap", "fieldname": "email_sync_option", "fieldtype": "Select", "hide_days": 1, @@ -237,6 +236,7 @@ }, { "default": "250", + "depends_on": "eval: doc.enable_incoming && doc.use_imap", "description": "Total number of emails to sync in initial sync process ", "fieldname": "initial_sync_count", "fieldtype": "Select", @@ -248,7 +248,7 @@ { "depends_on": "enable_incoming", "fieldname": "section_break_13", - "fieldtype": "Section Break", + "fieldtype": "Column Break", "hide_days": 1, "hide_seconds": 1 }, @@ -282,7 +282,8 @@ "fieldname": "outgoing_mail_settings", "fieldtype": "Section Break", "hide_days": 1, - "hide_seconds": 1 + "hide_seconds": 1, + "label": "Outgoing (SMTP) Settings" }, { "default": "0", @@ -336,22 +337,20 @@ { "default": "0", "depends_on": "enable_outgoing", - "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" + "label": "Always use this email address as sender address" }, { "default": "0", "depends_on": "enable_outgoing", - "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" + "label": "Always use this name as sender name" }, { "default": "1", @@ -379,10 +378,13 @@ "label": "Disable SMTP server authentication" }, { + "collapsible": 1, + "collapsible_depends_on": "add_signature", "fieldname": "signature_section", "fieldtype": "Section Break", "hide_days": 1, - "hide_seconds": 1 + "hide_seconds": 1, + "label": "Signature" }, { "default": "0", @@ -401,10 +403,13 @@ "label": "Signature" }, { + "collapsible": 1, + "collapsible_depends_on": "enable_auto_reply", "fieldname": "auto_reply", "fieldtype": "Section Break", "hide_days": 1, - "hide_seconds": 1 + "hide_seconds": 1, + "label": "Auto Reply" }, { "default": "0", @@ -424,17 +429,20 @@ "label": "Auto Reply Message" }, { + "collapsible": 1, + "collapsible_depends_on": "eval:frappe.utils.html2text(doc.footer || '')!=''", "fieldname": "set_footer", "fieldtype": "Section Break", "hide_days": 1, - "hide_seconds": 1 + "hide_seconds": 1, + "label": "Footer" }, { "fieldname": "footer", "fieldtype": "Text Editor", "hide_days": 1, "hide_seconds": 1, - "label": "Footer" + "label": "Footer Content" }, { "fieldname": "uidvalidity", @@ -477,7 +485,8 @@ "fieldname": "section_break_12", "fieldtype": "Section Break", "hide_days": 1, - "hide_seconds": 1 + "hide_seconds": 1, + "label": "Document Linking" }, { "default": "0", @@ -527,12 +536,38 @@ "fieldname": "brand_logo", "fieldtype": "Attach Image", "label": "Brand Logo" + }, + { + "fieldname": "authentication_column", + "fieldtype": "Section Break", + "label": "Authentication" + }, + { + "fieldname": "column_break_10", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_18", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_38", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fieldname": "account_section", + "fieldtype": "Section Break", + "label": "Account" } ], "icon": "fa fa-inbox", "index_web_pages_for_search": 1, "links": [], - "modified": "2021-01-21 10:05:24.820597", + "modified": "2021-09-21 16:44:25.728637", "modified_by": "Administrator", "module": "Email", "name": "Email Account", @@ -554,4 +589,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 ecd59f42bb..d90c56d90d 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import email.utils import functools import imaplib @@ -137,8 +137,6 @@ class EmailAccount(Document): def on_update(self): """Check there is only one default of each type.""" - from frappe.core.doctype.user.user import setup_user_email_inbox - self.check_automatic_linking_email_account() self.there_must_be_only_one_default() setup_user_email_inbox(email_account=self.name, awaiting_password=self.awaiting_password, @@ -532,8 +530,6 @@ class EmailAccount(Document): def on_trash(self): """Clear communications where email account is linked""" - from frappe.core.doctype.user.user import remove_user_email_inbox - frappe.db.sql("update `tabCommunication` set email_account='' where email_account=%s", self.name) remove_user_email_inbox(email_account=self.name) @@ -724,3 +720,84 @@ def get_max_email_uid(email_account): else: max_uid = cint(result[0].get("uid", 0)) + 1 return max_uid + + +def setup_user_email_inbox(email_account, awaiting_password, email_id, enable_outgoing): + """ setup email inbox for user """ + from frappe.core.doctype.user.user import ask_pass_update + + def add_user_email(user): + user = frappe.get_doc("User", user) + row = user.append("user_emails", {}) + + row.email_id = email_id + row.email_account = email_account + row.awaiting_password = awaiting_password or 0 + row.enable_outgoing = enable_outgoing or 0 + + user.save(ignore_permissions=True) + + update_user_email_settings = False + if not all([email_account, email_id]): + return + + user_names = frappe.db.get_values("User", {"email": email_id}, as_dict=True) + if not user_names: + return + + for user in user_names: + user_name = user.get("name") + + # check if inbox is alreay configured + user_inbox = frappe.db.get_value("User Email", { + "email_account": email_account, + "parent": user_name + }, ["name"]) or None + + if not user_inbox: + add_user_email(user_name) + else: + # update awaiting password for email account + update_user_email_settings = True + + if update_user_email_settings: + frappe.db.sql("""UPDATE `tabUser Email` SET awaiting_password = %(awaiting_password)s, + enable_outgoing = %(enable_outgoing)s WHERE email_account = %(email_account)s""", { + "email_account": email_account, + "enable_outgoing": enable_outgoing, + "awaiting_password": awaiting_password or 0 + }) + else: + users = " and ".join([frappe.bold(user.get("name")) for user in user_names]) + frappe.msgprint(_("Enabled email inbox for user {0}").format(users)) + ask_pass_update() + +def remove_user_email_inbox(email_account): + """ remove user email inbox settings if email account is deleted """ + if not email_account: + return + + users = frappe.get_all("User Email", filters={ + "email_account": email_account + }, fields=["parent as name"]) + + for user in users: + doc = frappe.get_doc("User", user.get("name")) + to_remove = [row for row in doc.user_emails if row.email_account == email_account] + [doc.remove(row) for row in to_remove] + + doc.save(ignore_permissions=True) + +@frappe.whitelist(allow_guest=False) +def set_email_password(email_account, user, password): + account = frappe.get_doc("Email Account", email_account) + if account.awaiting_password: + account.awaiting_password = 0 + account.password = password + try: + account.save(ignore_permissions=True) + except Exception: + frappe.db.rollback() + return False + + return True \ No newline at end of file diff --git a/frappe/email/doctype/email_account/test_email_account.py b/frappe/email/doctype/email_account/test_email_account.py index 35cacac45a..21dc4b84c4 100644 --- a/frappe/email/doctype/email_account/test_email_account.py +++ b/frappe/email/doctype/email_account/test_email_account.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt +# License: MIT. See LICENSE import os import email @@ -34,8 +34,8 @@ class TestEmailAccount(unittest.TestCase): def setUp(self): frappe.flags.mute_emails = False frappe.flags.sent_mail = None - frappe.db.sql('delete from `tabEmail Queue`') - frappe.db.sql('delete from `tabUnhandled Email`') + frappe.db.delete("Email Queue") + frappe.db.delete("Unhandled Email") def get_test_mail(self, fname): with open(os.path.join(os.path.dirname(__file__), "test_mails", fname), "r") as f: @@ -60,7 +60,7 @@ class TestEmailAccount(unittest.TestCase): comm = frappe.get_doc("Communication", {"sender": "test_sender@example.com"}) comm.db_set("creation", datetime.now() - timedelta(seconds = 30 * 60)) - frappe.db.sql("DELETE FROM `tabEmail Queue`") + frappe.db.delete("Email Queue") notify_unreplied() self.assertTrue(frappe.db.get_value("Email Queue", {"reference_doctype": comm.reference_doctype, "reference_name": comm.reference_name, "status":"Not Sent"})) @@ -183,7 +183,7 @@ class TestEmailAccount(unittest.TestCase): def test_threading_by_message_id(self): cleanup() - frappe.db.sql("""delete from `tabEmail Queue`""") + frappe.db.delete("Email Queue") # reference document for testing event = frappe.get_doc(dict(doctype='Event', subject='test-message')).insert() @@ -242,8 +242,8 @@ class TestInboundMail(unittest.TestCase): def setUp(self): cleanup() - frappe.db.sql('delete from `tabEmail Queue`') - frappe.db.sql('delete from `tabToDo`') + frappe.db.delete("Email Queue") + frappe.db.delete("ToDo") def get_test_mail(self, fname): with open(os.path.join(os.path.dirname(__file__), "test_mails", fname), "r") as f: diff --git a/frappe/email/doctype/email_domain/email_domain.py b/frappe/email/doctype/email_domain/email_domain.py index 0856549eb7..1611d32351 100644 --- a/frappe/email/doctype/email_domain/email_domain.py +++ b/frappe/email/doctype/email_domain/email_domain.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe import _ diff --git a/frappe/email/doctype/email_domain/test_email_domain.py b/frappe/email/doctype/email_domain/test_email_domain.py index 8607151ca8..1064c7684a 100644 --- a/frappe/email/doctype/email_domain/test_email_domain.py +++ b/frappe/email/doctype/email_domain/test_email_domain.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest from frappe.test_runner import make_test_objects diff --git a/frappe/email/doctype/email_flag_queue/email_flag_queue.py b/frappe/email/doctype/email_flag_queue/email_flag_queue.py index 9bb30f08b2..886cf3c24b 100644 --- a/frappe/email/doctype/email_flag_queue/email_flag_queue.py +++ b/frappe/email/doctype/email_flag_queue/email_flag_queue.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/email/doctype/email_flag_queue/test_email_flag_queue.py b/frappe/email/doctype/email_flag_queue/test_email_flag_queue.py index d09b823ce6..b0e17b3b85 100644 --- a/frappe/email/doctype/email_flag_queue/test_email_flag_queue.py +++ b/frappe/email/doctype/email_flag_queue/test_email_flag_queue.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/email/doctype/email_group/email_group.json b/frappe/email/doctype/email_group/email_group.json index c49de841e6..cb74249143 100644 --- a/frappe/email/doctype/email_group/email_group.json +++ b/frappe/email/doctype/email_group/email_group.json @@ -1,6 +1,7 @@ { "actions": [], "allow_import": 1, + "allow_rename": 1, "autoname": "field:title", "creation": "2015-03-18 06:08:32.729800", "doctype": "DocType", @@ -50,7 +51,7 @@ "link_fieldname": "email_group" } ], - "modified": "2020-09-24 16:41:55.286377", + "modified": "2021-06-15 11:25:13.556201", "modified_by": "Administrator", "module": "Email", "name": "Email Group", diff --git a/frappe/email/doctype/email_group/email_group.py b/frappe/email/doctype/email_group/email_group.py index 2679353edf..ad52d9a9ec 100755 --- a/frappe/email/doctype/email_group/email_group.py +++ b/frappe/email/doctype/email_group/email_group.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe import _ diff --git a/frappe/email/doctype/email_group/test_email_group.py b/frappe/email/doctype/email_group/test_email_group.py index 3e894118df..06341c128e 100644 --- a/frappe/email/doctype/email_group/test_email_group.py +++ b/frappe/email/doctype/email_group/test_email_group.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/email/doctype/email_group_member/email_group_member.py b/frappe/email/doctype/email_group_member/email_group_member.py index 1f9303b83e..a9fd26f710 100644 --- a/frappe/email/doctype/email_group_member/email_group_member.py +++ b/frappe/email/doctype/email_group_member/email_group_member.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/email/doctype/email_group_member/test_email_group_member.py b/frappe/email/doctype/email_group_member/test_email_group_member.py index 829d686400..de006dccb9 100644 --- a/frappe/email/doctype/email_group_member/test_email_group_member.py +++ b/frappe/email/doctype/email_group_member/test_email_group_member.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/email/doctype/email_queue/email_queue.py b/frappe/email/doctype/email_queue/email_queue.py index e1e332f978..4489a68cac 100644 --- a/frappe/email/doctype/email_queue/email_queue.py +++ b/frappe/email/doctype/email_queue/email_queue.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import traceback import json diff --git a/frappe/email/doctype/email_queue/test_email_queue.py b/frappe/email/doctype/email_queue/test_email_queue.py index b76d6347b9..8ebcb68a38 100644 --- a/frappe/email/doctype/email_queue/test_email_queue.py +++ b/frappe/email/doctype/email_queue/test_email_queue.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/email/doctype/email_queue_recipient/email_queue_recipient.py b/frappe/email/doctype/email_queue_recipient/email_queue_recipient.py index 055bdb3fc1..95b8593c4c 100644 --- a/frappe/email/doctype/email_queue_recipient/email_queue_recipient.py +++ b/frappe/email/doctype/email_queue_recipient/email_queue_recipient.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/email/doctype/email_rule/email_rule.py b/frappe/email/doctype/email_rule/email_rule.py index 9807724ef1..b2a4be5421 100644 --- a/frappe/email/doctype/email_rule/email_rule.py +++ b/frappe/email/doctype/email_rule/email_rule.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/email/doctype/email_rule/test_email_rule.py b/frappe/email/doctype/email_rule/test_email_rule.py index b2213f7405..eef5448e57 100644 --- a/frappe/email/doctype/email_rule/test_email_rule.py +++ b/frappe/email/doctype/email_rule/test_email_rule.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/email/doctype/email_template/email_template.py b/frappe/email/doctype/email_template/email_template.py index 4711451fd2..c51c46d72d 100644 --- a/frappe/email/doctype/email_template/email_template.py +++ b/frappe/email/doctype/email_template/email_template.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe, json from frappe.model.document import Document diff --git a/frappe/email/doctype/email_template/test_email_template.py b/frappe/email/doctype/email_template/test_email_template.py index 5a9ee969c6..a92ee9f9c3 100644 --- a/frappe/email/doctype/email_template/test_email_template.py +++ b/frappe/email/doctype/email_template/test_email_template.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2018, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import unittest class TestEmailTemplate(unittest.TestCase): diff --git a/frappe/email/doctype/email_unsubscribe/email_unsubscribe.py b/frappe/email/doctype/email_unsubscribe/email_unsubscribe.py index 6c47d8c538..d2ee828a55 100644 --- a/frappe/email/doctype/email_unsubscribe/email_unsubscribe.py +++ b/frappe/email/doctype/email_unsubscribe/email_unsubscribe.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/email/doctype/email_unsubscribe/test_email_unsubscribe.py b/frappe/email/doctype/email_unsubscribe/test_email_unsubscribe.py index 602840fe3b..fdea802fdf 100644 --- a/frappe/email/doctype/email_unsubscribe/test_email_unsubscribe.py +++ b/frappe/email/doctype/email_unsubscribe/test_email_unsubscribe.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/email/doctype/newsletter/exceptions.py b/frappe/email/doctype/newsletter/exceptions.py new file mode 100644 index 0000000000..a6c688dbe8 --- /dev/null +++ b/frappe/email/doctype/newsletter/exceptions.py @@ -0,0 +1,13 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See LICENSE + +from frappe.exceptions import ValidationError + +class NewsletterAlreadySentError(ValidationError): + pass + +class NoRecipientFoundError(ValidationError): + pass + +class NewsletterNotSavedError(ValidationError): + pass diff --git a/frappe/email/doctype/newsletter/newsletter.py b/frappe/email/doctype/newsletter/newsletter.py index 97d77549b7..a118240488 100755 --- a/frappe/email/doctype/newsletter/newsletter.py +++ b/frappe/email/doctype/newsletter/newsletter.py @@ -1,241 +1,323 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See LICENSE + +from typing import Dict, List import frappe import frappe.utils -from frappe import throw, _ + +from frappe import _ from frappe.website.website_generator import WebsiteGenerator from frappe.utils.verified_command import get_signed_params, verify_request from frappe.email.doctype.email_group.email_group import add_subscribers -from frappe.utils import parse_addr, now_datetime, markdown, validate_email_address + +from .exceptions import NewsletterAlreadySentError, NoRecipientFoundError, NewsletterNotSavedError + class Newsletter(WebsiteGenerator): def onload(self): - if self.email_sent: - self.get("__onload").status_count = dict(frappe.db.sql("""select status, count(name) - from `tabEmail Queue` where reference_doctype=%s and reference_name=%s - group by status""", (self.doctype, self.name))) or None + self.setup_newsletter_status() def validate(self): - self.route = "newsletters/" + self.name - if self.send_from: - validate_email_address(self.send_from, True) + self.route = f"newsletters/{self.name}" + self.validate_sender_address() + self.validate_recipient_address() + + @property + def newsletter_recipients(self) -> List[str]: + if getattr(self, "_recipients", None) is None: + self._recipients = self.get_recipients() + return self._recipients @frappe.whitelist() - def test_send(self, doctype="Lead"): - self.recipients = frappe.utils.split_emails(self.test_email_id) - self.queue_all(test_email=True) + def test_send(self): + test_emails = frappe.utils.split_emails(self.test_email_id) + self.queue_all(test_emails=test_emails) frappe.msgprint(_("Test email sent to {0}").format(self.test_email_id)) @frappe.whitelist() def send_emails(self): """send emails to leads and customers""" + self.queue_all() + frappe.msgprint(_("Email queued to {0} recipients").format(len(self.newsletter_recipients))) + + def setup_newsletter_status(self): + """Setup analytical status for current Newsletter. Can be accessible from desk. + """ if self.email_sent: - throw(_("Newsletter has already been sent")) - - self.recipients = self.get_recipients() - - if self.recipients: - self.queue_all() - frappe.msgprint(_("Email queued to {0} recipients").format(len(self.recipients))) - - else: - frappe.msgprint(_("Newsletter should have atleast one recipient")) - - def queue_all(self, test_email=False): - if not self.get("recipients"): - # in case it is called via worker - self.recipients = self.get_recipients() - - self.validate_send() - - sender = self.send_from or frappe.utils.get_formatted_email(self.owner) - - if not frappe.flags.in_test: - frappe.db.auto_commit_on_many_writes = True - - attachments = [] - if self.send_attachments: - files = frappe.get_all("File", fields=["name"], filters={"attached_to_doctype": "Newsletter", - "attached_to_name": self.name}, order_by="creation desc") - - for file in files: - try: - # these attachments will be attached on-demand - # and won't be stored in the message - attachments.append({"fid": file.name}) - except IOError: - frappe.throw(_("Unable to find attachment {0}").format(file.name)) - - args = { - "message": self.get_message(), - "name": self.name - } - frappe.sendmail(recipients=self.recipients, sender=sender, - subject=self.subject, message=self.get_message(), template="newsletter", - reference_doctype=self.doctype, reference_name=self.name, - add_unsubscribe_link=self.send_unsubscribe_link, attachments=attachments, - unsubscribe_method="/unsubscribe", - unsubscribe_params={"name": self.name}, - send_priority=0, queue_separately=True, args=args) - - if not frappe.flags.in_test: - frappe.db.auto_commit_on_many_writes = False - - if not test_email: - self.db_set("email_sent", 1) - self.db_set("schedule_send", now_datetime()) - self.db_set("scheduled_to_send", len(self.recipients)) - - def get_message(self): - if self.content_type == "HTML": - return frappe.render_template(self.message_html, {"doc": self.as_dict()}) - return { - 'Rich Text': self.message, - 'Markdown': markdown(self.message_md) - }[self.content_type or 'Rich Text'] - - def get_recipients(self): - """Get recipients from Email Group""" - recipients_list = [] - for email_group in get_email_groups(self.name): - for d in frappe.db.get_all("Email Group Member", ["email"], - {"unsubscribed": 0, "email_group": email_group.email_group}): - recipients_list.append(d.email) - return list(set(recipients_list)) + status_count = frappe.get_all("Email Queue", + filters={"reference_doctype": self.doctype, "reference_name": self.name}, + fields=["status", "count(name)"], + group_by="status", + order_by="status", + as_list=True, + ) + self.get("__onload").status_count = dict(status_count) def validate_send(self): - if self.get("__islocal"): - throw(_("Please save the Newsletter before sending")) + """Validate if Newsletter can be sent. + """ + self.validate_newsletter_status() + self.validate_newsletter_recipients() - if not self.recipients: - frappe.throw(_("Newsletter should have at least one recipient")) + def validate_newsletter_status(self): + if self.email_sent: + frappe.throw(_("Newsletter has already been sent"), exc=NewsletterAlreadySentError) + + if self.get("__islocal"): + frappe.throw(_("Please save the Newsletter before sending"), exc=NewsletterNotSavedError) + + def validate_newsletter_recipients(self): + if not self.newsletter_recipients: + frappe.throw(_("Newsletter should have atleast one recipient"), exc=NoRecipientFoundError) + self.validate_recipient_address() + + def validate_sender_address(self): + """Validate self.send_from is a valid email address or not. + """ + if self.send_from: + frappe.utils.validate_email_address(self.send_from, throw=True) + + def validate_recipient_address(self): + """Validate if self.newsletter_recipients are all valid email addresses or not. + """ + for recipient in self.newsletter_recipients: + frappe.utils.validate_email_address(recipient, throw=True) + + def get_linked_email_queue(self) -> List[str]: + """Get list of email queue linked to this newsletter. + """ + return frappe.get_all("Email Queue", + filters={ + "reference_doctype": self.doctype, + "reference_name": self.name, + }, + pluck="name", + ) + + def get_success_recipients(self) -> List[str]: + """Recipients who have already recieved the newsletter. + + Couldn't think of a better name ;) + """ + return frappe.get_all("Email Queue Recipient", + filters={ + "status": ("in", ["Not Sent", "Sending", "Sent"]), + "parentfield": ("in", self.get_linked_email_queue()), + }, + pluck="recipient", + ) + + def get_pending_recipients(self) -> List[str]: + """Get list of pending recipients of the newsletter. These + recipients may not have receive the newsletter in the previous iteration. + """ + return [ + x for x in self.newsletter_recipients if x not in self.get_success_recipients() + ] + + def queue_all(self, test_emails: List[str] = None): + """Queue Newsletter to all the recipients generated from the `Email Group` + table + + Args: + test_email (List[str], optional): Send test Newsletter to the passed set of emails. + Defaults to None. + """ + if test_emails: + for test_email in test_emails: + frappe.utils.validate_email_address(test_email, throw=True) + else: + self.validate() + self.validate_send() + + newsletter_recipients = test_emails or self.get_pending_recipients() + self.send_newsletter(emails=newsletter_recipients) + + if not test_emails: + self.email_sent = True + self.schedule_send = frappe.utils.now_datetime() + self.scheduled_to_send = len(newsletter_recipients) + self.save() + + def get_newsletter_attachments(self) -> List[Dict[str, str]]: + """Get list of attachments on current Newsletter + """ + attachments = [] + + if self.send_attachments: + files = frappe.get_all( + "File", + filters={"attached_to_doctype": "Newsletter", "attached_to_name": self.name}, + order_by="creation desc", + pluck="name", + ) + attachments.extend({"fid": file} for file in files) + + return attachments + + def send_newsletter(self, emails: List[str]): + """Trigger email generation for `emails` and add it in Email Queue. + """ + # TODO: get rid of this maybe? + message = self.get_message() + attachments = self.get_newsletter_attachments() + sender = self.send_from or frappe.utils.get_formatted_email(self.owner) + args = {"message": message, "name": self.name} + + is_auto_commit_set = bool(frappe.db.auto_commit_on_many_writes) + frappe.db.auto_commit_on_many_writes = not frappe.flags.in_test + + frappe.sendmail( + subject=self.subject, + sender=sender, + recipients=emails, + message=message, + attachments=attachments, + template="newsletter", + add_unsubscribe_link=self.send_unsubscribe_link, + unsubscribe_method="/unsubscribe", + unsubscribe_params={"name": self.name}, + reference_doctype=self.doctype, + reference_name=self.name, + queue_separately=True, + send_priority=0, + args=args, + ) + + frappe.db.auto_commit_on_many_writes = is_auto_commit_set + + def get_message(self) -> str: + if self.content_type == "HTML": + return frappe.render_template(self.message_html, {"doc": self.as_dict()}) + if self.content_type == "Markdown": + return frappe.utils.markdown(self.message_md) + # fallback to Rich Text + return self.message + + def get_recipients(self) -> List[str]: + """Get recipients from Email Group""" + emails = frappe.get_all( + "Email Group Member", + filters={"unsubscribed": 0, "email_group": ("in", self.get_email_groups())}, + pluck="email", + ) + return list(set(emails)) + + def get_email_groups(self) -> List[str]: + # wondering why the 'or'? i can't figure out why both aren't equivalent - @gavin + return [ + x.email_group for x in self.email_group + ] or frappe.get_all( + "Newsletter Email Group", + filters={"parent": self.name, "parenttype": "Newsletter"}, + pluck="email_group", + ) + + def get_attachments(self) -> List[Dict[str, str]]: + return frappe.get_all( + "File", + fields=["name", "file_name", "file_url", "is_private"], + filters={ + "attached_to_name": self.name, + "attached_to_doctype": "Newsletter", + "is_private": 0, + }, + ) def get_context(self, context): newsletters = get_newsletter_list("Newsletter", None, None, 0) if newsletters: newsletter_list = [d.name for d in newsletters] if self.name not in newsletter_list: - frappe.redirect_to_message(_('Permission Error'), - _("You are not permitted to view the newsletter.")) + frappe.redirect_to_message( + _("Permission Error"), _("You are not permitted to view the newsletter.") + ) frappe.local.flags.redirect_location = frappe.local.response.location raise frappe.Redirect else: - context.attachments = get_attachments(self.name) + context.attachments = self.get_attachments() context.no_cache = 1 context.show_sidebar = True -def get_attachments(name): - return frappe.get_all("File", - fields=["name", "file_name", "file_url", "is_private"], - filters = {"attached_to_name": name, "attached_to_doctype": "Newsletter", "is_private":0}) - - -def get_email_groups(name): - return frappe.db.get_all("Newsletter Email Group", ["email_group"],{"parent":name, "parenttype":"Newsletter"}) - - @frappe.whitelist(allow_guest=True) def confirmed_unsubscribe(email, group): """ unsubscribe the email(user) from the mailing list(email_group) """ - frappe.flags.ignore_permissions=True + frappe.flags.ignore_permissions = True doc = frappe.get_doc("Email Group Member", {"email": email, "email_group": group}) if not doc.unsubscribed: doc.unsubscribed = 1 - doc.save(ignore_permissions = True) - -def create_lead(email_id): - """create a lead if it does not exist""" - from frappe.model.naming import get_default_naming_series - full_name, email_id = parse_addr(email_id) - if frappe.db.get_value("Lead", {"email_id": email_id}): - return - - lead = frappe.get_doc({ - "doctype": "Lead", - "email_id": email_id, - "lead_name": full_name or email_id, - "status": "Lead", - "naming_series": get_default_naming_series("Lead"), - "company": frappe.db.get_default("Company"), - "source": "Email" - }) - lead.insert() + doc.save(ignore_permissions=True) @frappe.whitelist(allow_guest=True) -def subscribe(email, email_group=_('Website')): - url = frappe.utils.get_url("/api/method/frappe.email.doctype.newsletter.newsletter.confirm_subscription") +\ - "?" + get_signed_params({"email": email, "email_group": email_group}) +def subscribe(email, email_group=_("Website")): + """API endpoint to subscribe an email to a particular email group. Triggers a confirmation email. + """ - email_template = frappe.db.get_value('Email Group', email_group, ['confirmation_email_template']) + # build subscription confirmation URL + api_endpoint = frappe.utils.get_url( + "/api/method/frappe.email.doctype.newsletter.newsletter.confirm_subscription" + ) + signed_params = get_signed_params({"email": email, "email_group": email_group}) + confirm_subscription_url = f"{api_endpoint}?{signed_params}" - content='' - if email_template: - args = dict( - email=email, - confirmation_url=url, - email_group=email_group - ) + # fetch custom template if available + email_confirmation_template = frappe.db.get_value( + "Email Group", email_group, "confirmation_email_template" + ) - email_template = frappe.get_doc("Email Template", email_template) + # build email and send + if email_confirmation_template: + args = {"email": email, "confirmation_url": confirm_subscription_url, "email_group": email_group} + email_template = frappe.get_doc("Email Template", email_confirmation_template) + email_subject = email_template.subject content = frappe.render_template(email_template.response, args) - - if not content: - messages = ( + else: + email_subject = _("Confirm Your Email") + translatable_content = ( _("Thank you for your interest in subscribing to our updates"), _("Please verify your Email Address"), - url, - _("Click here to verify") + confirm_subscription_url, + _("Click here to verify"), ) - content = """ -

{0}. {1}.

-

{3}

- """.format(*messages) +

{0}. {1}.

+

{3}

+ """.format(*translatable_content) + + frappe.sendmail( + email, + subject=email_subject, + content=content, + now=True, + ) - frappe.sendmail(email, subject=getattr('email_template', 'subject', '') or _("Confirm Your Email"), content=content, now=True) @frappe.whitelist(allow_guest=True) -def confirm_subscription(email, email_group=_('Website')): +def confirm_subscription(email, email_group=_("Website")): + """API endpoint to confirm email subscription. + This endpoint is called when user clicks on the link sent to their mail. + """ if not verify_request(): return if not frappe.db.exists("Email Group", email_group): - frappe.get_doc({ - "doctype": "Email Group", - "title": email_group - }).insert(ignore_permissions=True) + frappe.get_doc({"doctype": "Email Group", "title": email_group}).insert( + ignore_permissions=True + ) frappe.flags.ignore_permissions = True add_subscribers(email_group, email) frappe.db.commit() - frappe.respond_as_web_page(_("Confirmed"), + frappe.respond_as_web_page( + _("Confirmed"), _("{0} has been successfully added to the Email Group.").format(email), - indicator_color='green') - - -def send_newsletter(newsletter): - try: - doc = frappe.get_doc("Newsletter", newsletter) - doc.queue_all() - - except: - frappe.db.rollback() - - # wasn't able to send emails :( - doc.db_set("email_sent", 0) - frappe.db.commit() - - frappe.log_error(title='Send Newsletter') - - raise - - else: - frappe.db.commit() + indicator_color="green", + ) def get_list_context(context=None): @@ -268,12 +350,35 @@ def get_newsletter_list(doctype, txt, filters, limit_start, limit_page_length=20 '''.format(','.join(['%s'] * len(email_group_list)), limit_page_length, limit_start), email_group_list, as_dict=1) + def send_scheduled_email(): """Send scheduled newsletter to the recipients.""" - scheduled_newsletter = frappe.get_all('Newsletter', filters = { - 'schedule_send': ('<=', now_datetime()), - 'email_sent': 0, - 'schedule_sending': 1 - }, fields = ['name'], ignore_ifnull=True) + scheduled_newsletter = frappe.get_all( + "Newsletter", + filters={ + "schedule_send": ("<=", frappe.utils.now_datetime()), + "email_sent": False, + "schedule_sending": True, + }, + ignore_ifnull=True, + pluck="name", + ) + for newsletter in scheduled_newsletter: - send_newsletter(newsletter.name) + try: + frappe.get_doc("Newsletter", newsletter).queue_all() + + except Exception: + frappe.db.rollback() + + # wasn't able to send emails :( + frappe.db.set_value("Newsletter", newsletter, "email_sent", 0) + message = ( + f"Newsletter {newsletter} failed to send" + "\n\n" + f"Traceback: {frappe.get_traceback()}" + ) + frappe.log_error(title="Send Newsletter", message=message) + + if not frappe.flags.in_test: + frappe.db.commit() diff --git a/frappe/email/doctype/newsletter/test_newsletter.py b/frappe/email/doctype/newsletter/test_newsletter.py index 3abd339ed9..abbcc6440c 100644 --- a/frappe/email/doctype/newsletter/test_newsletter.py +++ b/frappe/email/doctype/newsletter/test_newsletter.py @@ -1,17 +1,26 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See LICENSE + import unittest from random import choice +from typing import Union +from unittest.mock import MagicMock, PropertyMock, patch import frappe -from frappe.email.doctype.newsletter.newsletter import ( - confirmed_unsubscribe, - send_scheduled_email, +from frappe.desk.form.load import run_onload +from frappe.email.doctype.newsletter.exceptions import ( + NewsletterAlreadySentError, NoRecipientFoundError +) +from frappe.email.doctype.newsletter.newsletter import ( + Newsletter, + confirmed_unsubscribe, + get_newsletter_list, + send_scheduled_email ) -from frappe.email.doctype.newsletter.newsletter import get_newsletter_list from frappe.email.queue import flush from frappe.utils import add_days, getdate + test_dependencies = ["Email Group"] emails = [ "test_subscriber1@example.com", @@ -19,23 +28,107 @@ emails = [ "test_subscriber3@example.com", "test1@example.com", ] +newsletters = [] -class TestNewsletter(unittest.TestCase): +def get_dotted_path(obj: type) -> str: + klass = obj.__class__ + module = klass.__module__ + if module == 'builtins': + return klass.__qualname__ # avoid outputs like 'builtins.str' + return f"{module}.{klass.__qualname__}" + + +class TestNewsletterMixin: def setUp(self): frappe.set_user("Administrator") - frappe.db.sql("delete from `tabEmail Group Member`") + self.setup_email_group() + def tearDown(self): + frappe.set_user("Administrator") + for newsletter in newsletters: + frappe.db.delete("Email Queue", { + "reference_doctype": "Newsletter", + "reference_name": newsletter, + }) + frappe.delete_doc("Newsletter", newsletter) + frappe.db.delete("Newsletter Email Group", newsletter) + newsletters.remove(newsletter) + + def setup_email_group(self): if not frappe.db.exists("Email Group", "_Test Email Group"): - frappe.get_doc({"doctype": "Email Group", "title": "_Test Email Group"}).insert() - - for email in emails: frappe.get_doc({ - "doctype": "Email Group Member", - "email": email, - "email_group": "_Test Email Group" + "doctype": "Email Group", + "title": "_Test Email Group" }).insert() + for email in emails: + doctype = "Email Group Member" + email_filters = { + "email": email, + "email_group": "_Test Email Group" + } + try: + frappe.get_doc({ + "doctype": doctype, + **email_filters, + }).insert() + except Exception: + frappe.db.update(doctype, email_filters, "unsubscribed", 0) + + def send_newsletter(self, published=0, schedule_send=None) -> Union[str, None]: + frappe.db.delete("Email Queue") + frappe.db.delete("Email Queue Recipient") + frappe.db.delete("Newsletter") + + newsletter_options = { + "published": published, + "schedule_sending": bool(schedule_send), + "schedule_send": schedule_send + } + newsletter = self.get_newsletter(**newsletter_options) + + if schedule_send: + send_scheduled_email() + else: + newsletter.send_emails() + return newsletter.name + + @staticmethod + def get_newsletter(**kwargs) -> "Newsletter": + """Generate and return Newsletter object + """ + doctype = "Newsletter" + newsletter_content = { + "subject": "_Test Newsletter", + "send_from": "Test Sender ", + "content_type": "Rich Text", + "message": "Testing my news.", + } + similar_newsletters = frappe.db.get_all(doctype, newsletter_content, pluck="name") + + for similar_newsletter in similar_newsletters: + frappe.delete_doc(doctype, similar_newsletter) + + newsletter = frappe.get_doc({"doctype": doctype, **newsletter_content, **kwargs}) + newsletter.append("email_group", {"email_group": "_Test Email Group"}) + newsletter.save(ignore_permissions=True) + newsletter.reload() + newsletters.append(newsletter.name) + + attached_files = frappe.get_all("File", { + "attached_to_doctype": newsletter.doctype, + "attached_to_name": newsletter.name, + }, + pluck="name", + ) + for file in attached_files: + frappe.delete_doc("File", file) + + return newsletter + + +class TestNewsletter(TestNewsletterMixin, unittest.TestCase): def test_send(self): self.send_newsletter() @@ -64,40 +157,15 @@ class TestNewsletter(unittest.TestCase): if email != to_unsubscribe: self.assertTrue(email in recipients) - @staticmethod - def send_newsletter(published=0, schedule_send=None): - frappe.db.sql("delete from `tabEmail Queue`") - frappe.db.sql("delete from `tabEmail Queue Recipient`") - frappe.db.sql("delete from `tabNewsletter`") - newsletter = frappe.get_doc({ - "doctype": "Newsletter", - "subject": "_Test Newsletter", - "send_from": "Test Sender ", - "content_type": "Rich Text", - "message": "Testing my news.", - "published": published, - "schedule_sending": bool(schedule_send), - "schedule_send": schedule_send - }).insert(ignore_permissions=True) - - newsletter.append("email_group", {"email_group": "_Test Email Group"}) - newsletter.save() - if schedule_send: - send_scheduled_email() - return - - newsletter.send_emails() - return newsletter.name - def test_portal(self): - self.send_newsletter(1) + self.send_newsletter(published=1) frappe.set_user("test1@example.com") - newsletters = get_newsletter_list("Newsletter", None, None, 0) - self.assertEqual(len(newsletters), 1) + newsletter_list = get_newsletter_list("Newsletter", None, None, 0) + self.assertEqual(len(newsletter_list), 1) def test_newsletter_context(self): context = frappe._dict() - newsletter_name = self.send_newsletter(1) + newsletter_name = self.send_newsletter(published=1) frappe.set_user("test2@example.com") doc = frappe.get_doc("Newsletter", newsletter_name) doc.get_context(context) @@ -112,3 +180,68 @@ class TestNewsletter(unittest.TestCase): recipients = [e.recipients[0].recipient for e in email_queue_list] for email in emails: self.assertTrue(email in recipients) + + def test_newsletter_test_send(self): + """Test "Test Send" functionality of Newsletter + """ + newsletter = self.get_newsletter() + newsletter.test_email_id = choice(emails) + newsletter.test_send() + + self.assertFalse(newsletter.email_sent) + newsletter.save = MagicMock() + self.assertFalse(newsletter.save.called) + + def test_newsletter_status(self): + """Test for Newsletter's stats on onload event + """ + newsletter = self.get_newsletter() + newsletter.email_sent = True + # had to use run_onload as calling .onload directly bought weird errors + # like TestNewsletter has no attribute "_TestNewsletter__onload" + run_onload(newsletter) + self.assertIsInstance(newsletter.get("__onload").status_count, dict) + + def test_already_sent_newsletter(self): + newsletter = self.get_newsletter() + newsletter.send_emails() + + with self.assertRaises(NewsletterAlreadySentError): + newsletter.send_emails() + + def test_newsletter_with_no_recipient(self): + newsletter = self.get_newsletter() + property_path = f"{get_dotted_path(newsletter)}.newsletter_recipients" + + with patch(property_path, new_callable=PropertyMock) as mock_newsletter_recipients: + mock_newsletter_recipients.return_value = [] + with self.assertRaises(NoRecipientFoundError): + newsletter.send_emails() + + def test_send_newsletter_with_attachments(self): + newsletter = self.get_newsletter() + newsletter.reload() + file_attachment = frappe.get_doc({ + "doctype": "File", + "file_name": "test1.txt", + "attached_to_doctype": newsletter.doctype, + "attached_to_name": newsletter.name, + "content": frappe.mock("paragraph") + }) + file_attachment.save() + newsletter.send_attachments = True + newsletter_attachments = newsletter.get_newsletter_attachments() + self.assertEqual(len(newsletter_attachments), 1) + self.assertEqual(newsletter_attachments[0]["fid"], file_attachment.name) + + def test_send_scheduled_email_error_handling(self): + newsletter = self.get_newsletter(schedule_send=add_days(getdate(), -1)) + job_path = "frappe.email.doctype.newsletter.newsletter.Newsletter.queue_all" + m = MagicMock(side_effect=frappe.OutgoingEmailError) + + with self.assertRaises(frappe.OutgoingEmailError): + with patch(job_path, new_callable=m): + send_scheduled_email() + + newsletter.reload() + self.assertEqual(newsletter.email_sent, 0) diff --git a/frappe/email/doctype/newsletter_email_group/newsletter_email_group.py b/frappe/email/doctype/newsletter_email_group/newsletter_email_group.py index a453dda9e4..89476c4d53 100644 --- a/frappe/email/doctype/newsletter_email_group/newsletter_email_group.py +++ b/frappe/email/doctype/newsletter_email_group/newsletter_email_group.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/email/doctype/notification/notification.py b/frappe/email/doctype/notification/notification.py index 57418515f5..6b4ee92043 100644 --- a/frappe/email/doctype/notification/notification.py +++ b/frappe/email/doctype/notification/notification.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2018, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe import json, os @@ -146,6 +146,7 @@ def get_context(context): if doc.meta.get_field(fieldname).fieldtype in frappe.model.numeric_fieldtypes: value = frappe.utils.cint(value) + doc.reload() doc.set(fieldname, value) doc.flags.updater_reference = { 'doctype': self.doctype, diff --git a/frappe/email/doctype/notification/test_notification.py b/frappe/email/doctype/notification/test_notification.py index d6358ccbbe..f05d35be3e 100644 --- a/frappe/email/doctype/notification/test_notification.py +++ b/frappe/email/doctype/notification/test_notification.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2018, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe, frappe.utils, frappe.utils.scheduler from frappe.desk.form import assign_to import unittest @@ -9,7 +9,7 @@ test_dependencies = ["User", "Notification"] class TestNotification(unittest.TestCase): def setUp(self): - frappe.db.sql("""delete from `tabEmail Queue`""") + frappe.db.delete("Email Queue") frappe.set_user("test@example.com") if not frappe.db.exists('Notification', {'name': 'ToDo Status Update'}, 'name'): @@ -20,6 +20,8 @@ class TestNotification(unittest.TestCase): notification.event = 'Value Change' notification.value_changed = 'status' notification.send_to_all_assignees = 1 + notification.set_property_after_alert = 'description' + notification.property_value = 'Changed by Notification' notification.save() if not frappe.db.exists('Notification', {'name': 'Contact Status Update'}, 'name'): @@ -50,7 +52,7 @@ class TestNotification(unittest.TestCase): self.assertTrue(frappe.db.get_value("Email Queue", {"reference_doctype": "Communication", "reference_name": communication.name, "status":"Not Sent"})) - frappe.db.sql("""delete from `tabEmail Queue`""") + frappe.db.delete("Email Queue") communication.reload() communication.content = "test 2" @@ -189,9 +191,9 @@ class TestNotification(unittest.TestCase): def test_cc_jinja(self): - frappe.db.sql("""delete from `tabUser` where email='test_jinja@example.com'""") - frappe.db.sql("""delete from `tabEmail Queue`""") - frappe.db.sql("""delete from `tabEmail Queue Recipient`""") + frappe.db.delete("User", {"email": "test_jinja@example.com"}) + frappe.db.delete("Email Queue") + frappe.db.delete("Email Queue Recipient") test_user = frappe.new_doc("User") test_user.name = 'test_jinja' @@ -205,9 +207,9 @@ class TestNotification(unittest.TestCase): self.assertTrue(frappe.db.get_value("Email Queue Recipient", {"recipient": "test_jinja@example.com"})) - frappe.db.sql("""delete from `tabUser` where email='test_jinja@example.com'""") - frappe.db.sql("""delete from `tabEmail Queue`""") - frappe.db.sql("""delete from `tabEmail Queue Recipient`""") + frappe.db.delete("User", {"email": "test_jinja@example.com"}) + frappe.db.delete("Email Queue") + frappe.db.delete("Email Queue Recipient") def test_notification_to_assignee(self): todo = frappe.new_doc('ToDo') @@ -237,6 +239,9 @@ class TestNotification(unittest.TestCase): self.assertTrue(email_queue) + # check if description is changed after alert since set_property_after_alert is set + self.assertEquals(todo.description, 'Changed by Notification') + recipients = [d.recipient for d in email_queue.recipients] self.assertTrue('test2@example.com' in recipients) self.assertTrue('test1@example.com' in recipients) @@ -269,4 +274,7 @@ class TestNotification(unittest.TestCase): self.assertTrue('test2@example.com' in recipients) self.assertTrue('test1@example.com' in recipients) - + @classmethod + def tearDownClass(cls): + frappe.delete_doc_if_exists("Notification", "ToDo Status Update") + frappe.delete_doc_if_exists("Notification", "Contact Status Update") \ No newline at end of file diff --git a/frappe/email/doctype/notification_recipient/notification_recipient.py b/frappe/email/doctype/notification_recipient/notification_recipient.py index d8480c5455..68871e5047 100644 --- a/frappe/email/doctype/notification_recipient/notification_recipient.py +++ b/frappe/email/doctype/notification_recipient/notification_recipient.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2018, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/email/doctype/unhandled_email/test_unhandled_email.py b/frappe/email/doctype/unhandled_email/test_unhandled_email.py index 5606b8ff30..37c65584e0 100644 --- a/frappe/email/doctype/unhandled_email/test_unhandled_email.py +++ b/frappe/email/doctype/unhandled_email/test_unhandled_email.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/email/doctype/unhandled_email/unhandled_email.py b/frappe/email/doctype/unhandled_email/unhandled_email.py index 6414dbece3..db14a50d09 100644 --- a/frappe/email/doctype/unhandled_email/unhandled_email.py +++ b/frappe/email/doctype/unhandled_email/unhandled_email.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document @@ -10,5 +10,6 @@ class UnhandledEmail(Document): def remove_old_unhandled_emails(): - frappe.db.sql("""DELETE FROM `tabUnhandled Email` - WHERE creation < %s""", frappe.utils.add_days(frappe.utils.nowdate(), -30)) + frappe.db.delete("Unhandled Email", { + "creation": ("<", frappe.utils.add_days(frappe.utils.nowdate(), -30)) + }) \ No newline at end of file diff --git a/frappe/email/email_body.py b/frappe/email/email_body.py index ffb44d3412..c25e996bd3 100755 --- a/frappe/email/email_body.py +++ b/frappe/email/email_body.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe, re, os from frappe.utils.pdf import get_pdf @@ -13,8 +13,8 @@ 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, - inline_images=[], header=None): + content=None, reply_to=None, cc=None, bcc=None, email_account=None, expose_recipients=None, + inline_images=None, header=None): """ Prepare an email with the following format: - multipart/mixed - multipart/alternative @@ -25,6 +25,14 @@ def get_email(recipients, sender='', msg='', subject='[No Subject]', - attachment """ content = content or msg + + if cc is None: + cc = [] + if bcc is None: + bcc = [] + if inline_images is None: + inline_images = [] + emailobj = EMail(sender, recipients, subject, reply_to=reply_to, cc=cc, bcc=bcc, email_account=email_account, expose_recipients=expose_recipients) if not content.strip().startswith("<"): diff --git a/frappe/email/queue.py b/frappe/email/queue.py index 885a306cfb..16e3fecf48 100755 --- a/frappe/email/queue.py +++ b/frappe/email/queue.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe from frappe import msgprint, _ @@ -173,13 +173,8 @@ def clear_outbox(days=None): 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( - ','.join(['%s']*len(email_queues) - )), tuple(email_queues)) - - frappe.db.sql("""DELETE FROM `tabEmail Queue Recipient` WHERE `parent` IN ({0})""".format( - ','.join(['%s']*len(email_queues) - )), tuple(email_queues)) + frappe.db.delete("Email Queue", {"name": ("in", email_queues)}) + frappe.db.delete("Email Queue Recipient", {"parent": ("in", email_queues)}) def set_expiry_for_email_queue(): ''' Mark emails as expire that has not sent for 7 days. diff --git a/frappe/email/receive.py b/frappe/email/receive.py index 9ad560aa4a..7fab90bee3 100644 --- a/frappe/email/receive.py +++ b/frappe/email/receive.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import datetime import email @@ -340,7 +340,7 @@ class EmailServer: return error_msg - def update_flag(self, uid_list={}): + def update_flag(self, uid_list=None): """ set all uids mails the flag as seen """ if not uid_list: @@ -802,7 +802,7 @@ class InboundMail(Email): except frappe.DuplicateEntryError: # try and find matching parent parent_name = frappe.db.get_value(self.email_account.append_to, - {email_fileds.sender_field: email.from_email} + {email_fileds.sender_field: self.from_email} ) if parent_name: parent.name = parent_name diff --git a/frappe/email/smtp.py b/frappe/email/smtp.py index 74492c09c3..6f73a73f11 100644 --- a/frappe/email/smtp.py +++ b/frappe/email/smtp.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe import smtplib diff --git a/frappe/email/test_email_body.py b/frappe/email/test_email_body.py index 8e637273ed..c542bc2578 100644 --- a/frappe/email/test_email_body.py +++ b/frappe/email/test_email_body.py @@ -1,5 +1,6 @@ -# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# License: MIT. See LICENSE + import unittest, os, base64 from frappe import safe_decode from frappe.email.receive import Email @@ -127,7 +128,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)) diff --git a/frappe/email/utils.py b/frappe/email/utils.py index 24ce77b922..1138698491 100644 --- a/frappe/email/utils.py +++ b/frappe/email/utils.py @@ -1,5 +1,5 @@ # Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import imaplib, poplib from frappe.utils import cint diff --git a/frappe/event_streaming/doctype/document_type_field_mapping/document_type_field_mapping.py b/frappe/event_streaming/doctype/document_type_field_mapping/document_type_field_mapping.py index fc8164d8a4..3019d70035 100644 --- a/frappe/event_streaming/doctype/document_type_field_mapping/document_type_field_mapping.py +++ b/frappe/event_streaming/doctype/document_type_field_mapping/document_type_field_mapping.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/event_streaming/doctype/document_type_mapping/document_type_mapping.py b/frappe/event_streaming/doctype/document_type_mapping/document_type_mapping.py index 2cf7282a5a..8f1e5504da 100644 --- a/frappe/event_streaming/doctype/document_type_mapping/document_type_mapping.py +++ b/frappe/event_streaming/doctype/document_type_mapping/document_type_mapping.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe import json from frappe import _ diff --git a/frappe/event_streaming/doctype/document_type_mapping/test_document_type_mapping.py b/frappe/event_streaming/doctype/document_type_mapping/test_document_type_mapping.py index b1bb322855..a277139985 100644 --- a/frappe/event_streaming/doctype/document_type_mapping/test_document_type_mapping.py +++ b/frappe/event_streaming/doctype/document_type_mapping/test_document_type_mapping.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE # import frappe import unittest diff --git a/frappe/event_streaming/doctype/event_consumer/event_consumer.py b/frappe/event_streaming/doctype/event_consumer/event_consumer.py index 00d304f7f4..e8b84d1345 100644 --- a/frappe/event_streaming/doctype/event_consumer/event_consumer.py +++ b/frappe/event_streaming/doctype/event_consumer/event_consumer.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe import json diff --git a/frappe/event_streaming/doctype/event_consumer/test_event_consumer.py b/frappe/event_streaming/doctype/event_consumer/test_event_consumer.py index b8072ecabd..11c69e7ba3 100644 --- a/frappe/event_streaming/doctype/event_consumer/test_event_consumer.py +++ b/frappe/event_streaming/doctype/event_consumer/test_event_consumer.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE # import frappe import unittest diff --git a/frappe/event_streaming/doctype/event_consumer_document_type/event_consumer_document_type.py b/frappe/event_streaming/doctype/event_consumer_document_type/event_consumer_document_type.py index cf5d18edfd..b33313087f 100644 --- a/frappe/event_streaming/doctype/event_consumer_document_type/event_consumer_document_type.py +++ b/frappe/event_streaming/doctype/event_consumer_document_type/event_consumer_document_type.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/event_streaming/doctype/event_producer/event_producer.py b/frappe/event_streaming/doctype/event_producer/event_producer.py index 4836276734..05771a89d3 100644 --- a/frappe/event_streaming/doctype/event_producer/event_producer.py +++ b/frappe/event_streaming/doctype/event_producer/event_producer.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import json import time @@ -408,8 +408,9 @@ def sync_dependencies(document, producer_site): child_table = doc.get(df.fieldname) for entry in child_table: child_doc = producer_site.get_doc(entry.doctype, entry.name) - child_doc = frappe._dict(child_doc) - set_dependencies(child_doc, frappe.get_meta(entry.doctype).get_link_fields(), producer_site) + if child_doc: + child_doc = frappe._dict(child_doc) + set_dependencies(child_doc, frappe.get_meta(entry.doctype).get_link_fields(), producer_site) def sync_link_dependencies(doc, link_fields, producer_site): set_dependencies(doc, link_fields, producer_site) 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 883f4f2df2..3d697ceb3a 100644 --- a/frappe/event_streaming/doctype/event_producer/test_event_producer.py +++ b/frappe/event_streaming/doctype/event_producer/test_event_producer.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest import json diff --git a/frappe/event_streaming/doctype/event_producer_document_type/event_producer_document_type.py b/frappe/event_streaming/doctype/event_producer_document_type/event_producer_document_type.py index 9ae70e0f97..3e9623f56f 100644 --- a/frappe/event_streaming/doctype/event_producer_document_type/event_producer_document_type.py +++ b/frappe/event_streaming/doctype/event_producer_document_type/event_producer_document_type.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/event_streaming/doctype/event_producer_last_update/event_producer_last_update.py b/frappe/event_streaming/doctype/event_producer_last_update/event_producer_last_update.py index 391cf79c27..0868e86253 100644 --- a/frappe/event_streaming/doctype/event_producer_last_update/event_producer_last_update.py +++ b/frappe/event_streaming/doctype/event_producer_last_update/event_producer_last_update.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/event_streaming/doctype/event_producer_last_update/test_event_producer_last_update.py b/frappe/event_streaming/doctype/event_producer_last_update/test_event_producer_last_update.py index 62ea71edab..c2d943a463 100644 --- a/frappe/event_streaming/doctype/event_producer_last_update/test_event_producer_last_update.py +++ b/frappe/event_streaming/doctype/event_producer_last_update/test_event_producer_last_update.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE # import frappe import unittest diff --git a/frappe/event_streaming/doctype/event_sync_log/event_sync_log.py b/frappe/event_streaming/doctype/event_sync_log/event_sync_log.py index 1d255a5c30..c26ca46e05 100644 --- a/frappe/event_streaming/doctype/event_sync_log/event_sync_log.py +++ b/frappe/event_streaming/doctype/event_sync_log/event_sync_log.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/event_streaming/doctype/event_sync_log/test_event_sync_log.py b/frappe/event_streaming/doctype/event_sync_log/test_event_sync_log.py index ef55dc0f16..b901f92ef8 100644 --- a/frappe/event_streaming/doctype/event_sync_log/test_event_sync_log.py +++ b/frappe/event_streaming/doctype/event_sync_log/test_event_sync_log.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE # import frappe import unittest 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 ae851c70d1..f4871be312 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 @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/event_streaming/doctype/event_update_log/test_event_update_log.py b/frappe/event_streaming/doctype/event_update_log/test_event_update_log.py index 99ced3c209..752f4bbb44 100644 --- a/frappe/event_streaming/doctype/event_update_log/test_event_update_log.py +++ b/frappe/event_streaming/doctype/event_update_log/test_event_update_log.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt +# License: MIT. See LICENSE # import frappe import unittest diff --git a/frappe/event_streaming/doctype/event_update_log_consumer/event_update_log_consumer.py b/frappe/event_streaming/doctype/event_update_log_consumer/event_update_log_consumer.py index 80a59e4c31..47180db74e 100644 --- a/frappe/event_streaming/doctype/event_update_log_consumer/event_update_log_consumer.py +++ b/frappe/event_streaming/doctype/event_update_log_consumer/event_update_log_consumer.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/exceptions.py b/frappe/exceptions.py index 13abd8f4f8..8449425bc1 100644 --- a/frappe/exceptions.py +++ b/frappe/exceptions.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE # BEWARE don't put anything in this file except exceptions from werkzeug.exceptions import NotFound @@ -99,8 +99,10 @@ class IncompatibleApp(ValidationError): pass class InvalidDates(ValidationError): pass class DataTooLongException(ValidationError): pass class FileAlreadyAttachedException(Exception): pass -class DocumentAlreadyRestored(Exception): pass -class AttachmentLimitReached(Exception): pass +class DocumentAlreadyRestored(ValidationError): pass +class AttachmentLimitReached(ValidationError): pass +class QueryTimeoutError(Exception): pass +class QueryDeadlockError(Exception): pass # OAuth exceptions class InvalidAuthorizationHeader(CSRFTokenError): pass class InvalidAuthorizationPrefix(CSRFTokenError): pass diff --git a/frappe/frappeclient.py b/frappe/frappeclient.py index e57f82b60a..ab58979203 100644 --- a/frappe/frappeclient.py +++ b/frappe/frappeclient.py @@ -286,12 +286,16 @@ class FrappeClient(object): doc.modified = frappe.db.get_single_value(doctype, "modified") frappe.get_doc(doc).insert() - def get_api(self, method, params={}): + def get_api(self, method, params=None): + if params is None: + params = {} res = self.session.get(self.url + "/api/method/" + method + "/", params=params, verify=self.verify, headers=self.headers) return self.post_process(res) - def post_api(self, method, params={}): + def post_api(self, method, params=None): + if params is None: + params = {} res = self.session.post(self.url + "/api/method/" + method + "/", params=params, verify=self.verify, headers=self.headers) return self.post_process(res) diff --git a/frappe/geo/country_info.py b/frappe/geo/country_info.py index ddebd1fb0e..86f1d9bc2f 100644 --- a/frappe/geo/country_info.py +++ b/frappe/geo/country_info.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE # all country info import os, json, frappe diff --git a/frappe/geo/doctype/country/country.py b/frappe/geo/doctype/country/country.py index 54935e6eaf..a648744058 100644 --- a/frappe/geo/doctype/country/country.py +++ b/frappe/geo/doctype/country/country.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: See license.txt +# License: MIT. See LICENSE import frappe diff --git a/frappe/geo/doctype/country/test_country.py b/frappe/geo/doctype/country/test_country.py index e00d6ecf37..b4d15f81b3 100644 --- a/frappe/geo/doctype/country/test_country.py +++ b/frappe/geo/doctype/country/test_country.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: See license.txt +# License: MIT. See LICENSE import frappe test_records = frappe.get_test_records('Country') \ No newline at end of file diff --git a/frappe/geo/doctype/currency/currency.py b/frappe/geo/doctype/currency/currency.py index b3ce67cc67..fbe37e73bd 100644 --- a/frappe/geo/doctype/currency/currency.py +++ b/frappe/geo/doctype/currency/currency.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: See license.txt +# License: MIT. See LICENSE import frappe from frappe import throw, _ diff --git a/frappe/geo/doctype/currency/test_currency.py b/frappe/geo/doctype/currency/test_currency.py index 5552e675ec..71b963cc86 100644 --- a/frappe/geo/doctype/currency/test_currency.py +++ b/frappe/geo/doctype/currency/test_currency.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: See license.txt +# License: MIT. See LICENSE # pre loaded diff --git a/frappe/geo/utils.py b/frappe/geo/utils.py index 89de176f0b..9b44a2f3d8 100644 --- a/frappe/geo/utils.py +++ b/frappe/geo/utils.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe diff --git a/frappe/handler.py b/frappe/handler.py index de86c15c8f..42c17261b4 100755 --- a/frappe/handler.py +++ b/frappe/handler.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE from werkzeug.wrappers import Response @@ -10,6 +10,8 @@ from frappe.utils import cint from frappe import _, is_whitelisted from frappe.utils.response import build_response from frappe.utils.csvutils import build_csv_response +from frappe.utils.image import optimize_image +from mimetypes import guess_type from frappe.core.doctype.server_script.server_script_utils import run_server_script_api @@ -25,7 +27,7 @@ def handle(): cmd = frappe.local.form_dict.cmd data = None - if cmd!='login': + if cmd != 'login': data = execute_cmd(cmd) # data can be an empty string or list which are valid responses @@ -53,7 +55,7 @@ def execute_cmd(cmd, from_async=False): try: method = get_attr(cmd) except Exception as e: - frappe.throw(_('Invalid Method')) + frappe.throw(_('Failed to get method for command {0} with {1}').format(cmd, e)) if from_async: method = method.queue @@ -144,20 +146,32 @@ def upload_file(): file_url = frappe.form_dict.file_url folder = frappe.form_dict.folder or 'Home' method = frappe.form_dict.method + filename = frappe.form_dict.file_name + optimize = frappe.form_dict.optimize content = None - filename = None if 'file' in files: file = files['file'] content = file.stream.read() filename = file.filename + content_type = guess_type(filename)[0] + if optimize and content_type.startswith("image/"): + args = { + "content": content, + "content_type": content_type + } + if frappe.form_dict.max_width: + args["max_width"] = int(frappe.form_dict.max_width) + if frappe.form_dict.max_height: + args["max_height"] = int(frappe.form_dict.max_height) + content = optimize_image(**args) + frappe.local.uploaded_file = content frappe.local.uploaded_filename = filename - if frappe.session.user == 'Guest' or (user and not user.has_desk_access()): - import mimetypes - filetype = mimetypes.guess_type(filename)[0] + if not file_url and (frappe.session.user == "Guest" or (user and not user.has_desk_access())): + filetype = guess_type(filename)[0] if filetype not in ALLOWED_MIMETYPES: frappe.throw(_("You can only upload JPG, PNG, PDF, or Microsoft documents.")) @@ -209,7 +223,10 @@ def run_doc_method(method, docs=None, dt=None, dn=None, arg=None, args=None): doc = frappe.get_doc(dt, dn) else: - doc = frappe.get_doc(json.loads(docs)) + if isinstance(docs, str): + docs = json.loads(docs) + + doc = frappe.get_doc(docs) doc._original_modified = doc.modified doc.check_if_latest() diff --git a/frappe/hooks.py b/frappe/hooks.py index ac42a03461..8bca5c066c 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -12,11 +12,11 @@ source_link = "https://github.com/frappe/frappe" app_license = "MIT" app_logo_url = '/assets/frappe/images/frappe-framework-logo.svg' -develop_version = '13.x.x-develop' +develop_version = '14.x.x-develop' -app_email = "info@frappe.io" +app_email = "developers@frappe.io" -docs_app = "frappe_io" +docs_app = "frappe_docs" translator_url = "https://translate.erpnext.com" @@ -76,8 +76,6 @@ before_tests = "frappe.utils.install.before_tests" email_append_to = ["Event", "ToDo", "Communication"] -get_rooms = 'frappe.chat.doctype.chat_room.chat_room.get_rooms' - calendars = ["Event"] leaderboards = "frappe.desk.leaderboard.get_leaderboards" @@ -164,13 +162,17 @@ doc_events = { "after_rename": "frappe.desk.notifications.clear_doctype_notifications", "on_cancel": [ "frappe.desk.notifications.clear_doctype_notifications", - "frappe.workflow.doctype.workflow_action.workflow_action.process_workflow_actions" + "frappe.workflow.doctype.workflow_action.workflow_action.process_workflow_actions", + "frappe.event_streaming.doctype.event_update_log.event_update_log.notify_consumers" ], "on_trash": [ "frappe.desk.notifications.clear_doctype_notifications", "frappe.workflow.doctype.workflow_action.workflow_action.process_workflow_actions", "frappe.event_streaming.doctype.event_update_log.event_update_log.notify_consumers" ], + "on_update_after_submit": [ + "frappe.workflow.doctype.workflow_action.workflow_action.process_workflow_actions" + ], "on_change": [ "frappe.social.doctype.energy_point_rule.energy_point_rule.process_energy_points", "frappe.automation.doctype.milestone_tracker.milestone_tracker.evaluate_milestone" @@ -277,11 +279,6 @@ sounds = [ {"name": "error", "src": "/assets/frappe/sounds/error.mp3", "volume": 0.1}, {"name": "alert", "src": "/assets/frappe/sounds/alert.mp3", "volume": 0.2}, # {"name": "chime", "src": "/assets/frappe/sounds/chime.mp3"}, - - # frappe.chat sounds - { "name": "chat-message", "src": "/assets/frappe/sounds/chat-message.mp3", "volume": 0.1 }, - { "name": "chat-notification", "src": "/assets/frappe/sounds/chat-notification.mp3", "volume": 0.1 } - # frappe.chat sounds ] bot_parsers = [ diff --git a/frappe/installer.py b/frappe/installer.py index acdb91e7cf..d1a13fdaab 100755 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -1,9 +1,11 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import json import os import sys +from collections import OrderedDict +from typing import List, Dict import frappe from frappe.defaults import _clear_cache @@ -29,6 +31,10 @@ def _new_site( ): """Install a new Frappe site""" + from frappe.commands.scheduler import _is_scheduler_enabled + from frappe.utils import get_site_path, scheduler, touch_file + + if not force and os.path.exists(site): print("Site {0} already exists".format(site)) sys.exit(1) @@ -37,14 +43,11 @@ def _new_site( 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 + if not db_name: + import hashlib + db_name = "_" + hashlib.sha1(os.path.realpath(frappe.get_site_path()).encode()).hexdigest()[:16] try: # enable scheduler post install? @@ -157,7 +160,7 @@ def install_app(name, verbose=False, set_as_patched=True): if name != "frappe": add_module_defs(name) - sync_for(name, force=True, sync_everything=True, verbose=verbose, reset_permissions=True) + sync_for(name, force=True, reset_permissions=True) add_to_installed_apps(name) @@ -229,49 +232,11 @@ def remove_app(app_name, dry_run=False, yes=False, no_backup=False, force=False) scheduled_backup(ignore_files=True) frappe.flags.in_uninstall = True - drop_doctypes = [] modules = frappe.get_all("Module Def", filters={"app_name": app_name}, pluck="name") - for module_name in modules: - print(f"Deleting Module '{module_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, 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 = ["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_all(doctype, filters={"module": module_name}, pluck="name"): - print(f"* removing {doctype} '{record}'...") - if not dry_run: - frappe.delete_doc(doctype, record, ignore_on_trash=True, force=True) - - print(f"* removing Module Def '{module_name}'...") - if not dry_run: - 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}`") + drop_doctypes = _delete_modules(modules, dry_run=dry_run) + _delete_doctypes(drop_doctypes, dry_run=dry_run) if not dry_run: remove_from_installed_apps(app_name) @@ -281,11 +246,91 @@ def remove_app(app_name, dry_run=False, yes=False, no_backup=False, force=False) frappe.flags.in_uninstall = False +def _delete_modules(modules: List[str], dry_run: bool) -> List[str]: + """ Delete modules belonging to the app and all related doctypes. + + Note: All record linked linked to Module Def are also deleted. + + Returns: list of deleted doctypes.""" + drop_doctypes = [] + + doctype_link_field_map = _get_module_linked_doctype_field_map() + for module_name in modules: + print(f"Deleting Module '{module_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: + if doctype.issingle: + frappe.delete_doc("DocType", doctype.name, ignore_on_trash=True) + else: + drop_doctypes.append(doctype.name) + + _delete_linked_documents(module_name, doctype_link_field_map, dry_run=dry_run) + + print(f"* removing Module Def '{module_name}'...") + if not dry_run: + frappe.delete_doc("Module Def", module_name, ignore_on_trash=True, force=True) + + return drop_doctypes + + +def _delete_linked_documents( + module_name: str, + doctype_linkfield_map: Dict[str, str], + dry_run: bool + ) -> None: + + """Deleted all records linked with module def""" + for doctype, fieldname in doctype_linkfield_map.items(): + for record in frappe.get_all(doctype, filters={fieldname: module_name}, pluck="name"): + print(f"* removing {doctype} '{record}'...") + if not dry_run: + frappe.delete_doc(doctype, record, ignore_on_trash=True, force=True) + +def _get_module_linked_doctype_field_map() -> Dict[str, str]: + """ Get all the doctypes which have module linked with them. + + returns ordered dictionary with doctype->link field mapping.""" + + # Hardcoded to change order of deletion + ordered_doctypes = [ + ("Workspace", "module"), + ("Report", "module"), + ("Page", "module"), + ("Web Form", "module") + ] + doctype_to_field_map = OrderedDict(ordered_doctypes) + + linked_doctypes = frappe.get_all( + "DocField", filters={"fieldtype": "Link", "options": "Module Def"}, fields=["parent", "fieldname"] + ) + existing_linked_doctypes = [d for d in linked_doctypes if frappe.db.exists("DocType", d.parent)] + + for d in existing_linked_doctypes: + # DocType deletion is handled separately in the end + if d.parent not in doctype_to_field_map and d.parent != "DocType": + doctype_to_field_map[d.parent] = d.fieldname + + return doctype_to_field_map + + +def _delete_doctypes(doctypes: List[str], dry_run: bool) -> None: + for doctype in set(doctypes): + print(f"* dropping Table for '{doctype}'...") + if not dry_run: + frappe.delete_doc("DocType", doctype, ignore_on_trash=True) + frappe.db.sql_ddl(f"drop table `tab{doctype}`") + + def post_install(rebuild_website=False): - from frappe.website import render + from frappe.website.utils import clear_website_cache if rebuild_website: - render.clear_cache() + clear_website_cache() init_singles() frappe.db.commit() @@ -445,9 +490,32 @@ def extract_sql_from_archive(sql_file_path): else: decompressed_file_name = sql_file_path + # convert archive sql to latest compatible + convert_archive_content(decompressed_file_name) + return decompressed_file_name +def convert_archive_content(sql_file_path): + if frappe.conf.db_type == "mariadb": + # ever since mariaDB 10.6, row_format COMPRESSED has been deprecated and removed + # this step is added to ease restoring sites depending on older mariaDB servers + from frappe.utils import random_string + from pathlib import Path + + old_sql_file_path = Path(f"{sql_file_path}_{random_string(10)}") + sql_file_path = Path(sql_file_path) + + os.rename(sql_file_path, old_sql_file_path) + sql_file_path.touch() + + with open(old_sql_file_path) as r, open(sql_file_path, "a") as w: + for line in r: + w.write(line.replace("ROW_FORMAT=COMPRESSED", "ROW_FORMAT=DYNAMIC")) + + old_sql_file_path.unlink() + + def extract_sql_gzip(sql_gz_path): import subprocess @@ -457,7 +525,7 @@ def extract_sql_gzip(sql_gz_path): decompressed_file = original_file.rstrip(".gz") cmd = 'gzip -dvf < {0} > {1}'.format(original_file, decompressed_file) subprocess.check_call(cmd, shell=True) - except: + except Exception: raise return decompressed_file diff --git a/frappe/integrations/doctype/braintree_settings/braintree_settings.py b/frappe/integrations/doctype/braintree_settings/braintree_settings.py index 9dc9778bee..59751185b9 100644 --- a/frappe/integrations/doctype/braintree_settings/braintree_settings.py +++ b/frappe/integrations/doctype/braintree_settings/braintree_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2018, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/integrations/doctype/braintree_settings/test_braintree_settings.py b/frappe/integrations/doctype/braintree_settings/test_braintree_settings.py index 72a678a92c..721158fb4a 100644 --- a/frappe/integrations/doctype/braintree_settings/test_braintree_settings.py +++ b/frappe/integrations/doctype/braintree_settings/test_braintree_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2018, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import unittest class TestBraintreeSettings(unittest.TestCase): diff --git a/frappe/integrations/doctype/connected_app/connected_app.py b/frappe/integrations/doctype/connected_app/connected_app.py index 449e30f6d0..fcb5fe7ee9 100644 --- a/frappe/integrations/doctype/connected_app/connected_app.py +++ b/frappe/integrations/doctype/connected_app/connected_app.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import os from urllib.parse import urljoin diff --git a/frappe/integrations/doctype/connected_app/test_connected_app.py b/frappe/integrations/doctype/connected_app/test_connected_app.py index d1ff19ecb2..eff7104ce0 100644 --- a/frappe/integrations/doctype/connected_app/test_connected_app.py +++ b/frappe/integrations/doctype/connected_app/test_connected_app.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# See license.txt +# License: MIT. See LICENSE import unittest import requests from urllib.parse import urljoin diff --git a/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py b/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py index 53f0935c80..9ccd1c0210 100644 --- a/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py +++ b/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import json import os @@ -336,7 +336,6 @@ def dropbox_auth_finish(return_access_token=False): _("Dropbox access is approved!") + close, indicator_color='green') -@frappe.whitelist(allow_guest=True) def set_dropbox_access_token(access_token): frappe.db.set_value("Dropbox Settings", None, 'dropbox_access_token', access_token) frappe.db.commit() diff --git a/frappe/integrations/doctype/dropbox_settings/test_dropbox_settings.py b/frappe/integrations/doctype/dropbox_settings/test_dropbox_settings.py index d34e65de50..458f876444 100644 --- a/frappe/integrations/doctype/dropbox_settings/test_dropbox_settings.py +++ b/frappe/integrations/doctype/dropbox_settings/test_dropbox_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE # import frappe import unittest diff --git a/frappe/integrations/doctype/google_calendar/google_calendar.py b/frappe/integrations/doctype/google_calendar/google_calendar.py index f93be35aa7..0d4c5bbe5c 100644 --- a/frappe/integrations/doctype/google_calendar/google_calendar.py +++ b/frappe/integrations/doctype/google_calendar/google_calendar.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE from datetime import datetime, timedelta diff --git a/frappe/integrations/doctype/google_contacts/google_contacts.py b/frappe/integrations/doctype/google_contacts/google_contacts.py index 1705f98e91..a63b0b6d80 100644 --- a/frappe/integrations/doctype/google_contacts/google_contacts.py +++ b/frappe/integrations/doctype/google_contacts/google_contacts.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import google.oauth2.credentials diff --git a/frappe/integrations/doctype/google_drive/google_drive.py b/frappe/integrations/doctype/google_drive/google_drive.py index 93b6fa3f8d..beac7898a9 100644 --- a/frappe/integrations/doctype/google_drive/google_drive.py +++ b/frappe/integrations/doctype/google_drive/google_drive.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import os from urllib.parse import quote diff --git a/frappe/integrations/doctype/google_drive/test_google_drive.py b/frappe/integrations/doctype/google_drive/test_google_drive.py index 96e8577c7c..fbd9dce7f4 100644 --- a/frappe/integrations/doctype/google_drive/test_google_drive.py +++ b/frappe/integrations/doctype/google_drive/test_google_drive.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE # import frappe import unittest diff --git a/frappe/integrations/doctype/google_settings/google_settings.json b/frappe/integrations/doctype/google_settings/google_settings.json index 086c56c020..6f25fa4bf6 100644 --- a/frappe/integrations/doctype/google_settings/google_settings.json +++ b/frappe/integrations/doctype/google_settings/google_settings.json @@ -1,4 +1,5 @@ { + "actions": [], "creation": "2019-06-14 00:08:37.255003", "doctype": "DocType", "engine": "InnoDB", @@ -8,7 +9,10 @@ "client_id", "client_secret", "sb_01", - "api_key" + "api_key", + "section_break_7", + "google_drive_picker_enabled", + "app_id" ], "fields": [ { @@ -18,10 +22,12 @@ "label": "Enable" }, { + "description": "The Client ID obtained from the Google Cloud Console under \n\"APIs & Services\" > \"Credentials\"\n", "fieldname": "client_id", "fieldtype": "Data", "in_list_view": 1, - "label": "Client ID" + "label": "Client ID", + "mandatory_depends_on": "google_drive_picker_enabled" }, { "fieldname": "client_secret", @@ -30,10 +36,11 @@ "label": "Client Secret" }, { - "description": "Used For Google Maps Integration.", + "description": "The browser API key obtained from the Google Cloud Console under \n\"APIs & Services\" > \"Credentials\"\n", "fieldname": "api_key", "fieldtype": "Data", - "label": "API Key" + "label": "API Key", + "mandatory_depends_on": "google_drive_picker_enabled" }, { "depends_on": "enable", @@ -46,10 +53,30 @@ "fieldname": "sb_01", "fieldtype": "Section Break", "label": "API Key" + }, + { + "depends_on": "google_drive_picker_enabled", + "description": "The project number obtained from Google Cloud Console under \n\"IAM & Admin\" > \"Settings\"\n", + "fieldname": "app_id", + "fieldtype": "Data", + "label": "App ID", + "mandatory_depends_on": "google_drive_picker_enabled" + }, + { + "fieldname": "section_break_7", + "fieldtype": "Section Break", + "label": "Google Drive Picker" + }, + { + "default": "0", + "fieldname": "google_drive_picker_enabled", + "fieldtype": "Check", + "label": "Google Drive Picker Enabled" } ], "issingle": 1, - "modified": "2019-08-06 22:37:41.699703", + "links": [], + "modified": "2021-06-29 18:26:07.094851", "modified_by": "Administrator", "module": "Integrations", "name": "Google Settings", @@ -64,16 +91,6 @@ "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, diff --git a/frappe/integrations/doctype/google_settings/google_settings.py b/frappe/integrations/doctype/google_settings/google_settings.py index 9a3f3c8ae2..94df43e69c 100644 --- a/frappe/integrations/doctype/google_settings/google_settings.py +++ b/frappe/integrations/doctype/google_settings/google_settings.py @@ -1,12 +1,27 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE -# import frappe +import frappe from frappe.model.document import Document class GoogleSettings(Document): pass def get_auth_url(): - return "https://www.googleapis.com/oauth2/v4/token" \ No newline at end of file + return "https://www.googleapis.com/oauth2/v4/token" + + +@frappe.whitelist() +def get_file_picker_settings(): + """Return all the data FileUploader needs to start the Google Drive Picker.""" + google_settings = frappe.get_single("Google Settings") + if not (google_settings.enable and google_settings.google_drive_picker_enabled): + return {} + + return { + "enabled": True, + "appId": google_settings.app_id, + "developerKey": google_settings.api_key, + "clientId": google_settings.client_id + } diff --git a/frappe/integrations/doctype/google_settings/test_google_settings.py b/frappe/integrations/doctype/google_settings/test_google_settings.py new file mode 100644 index 0000000000..cddf9f3697 --- /dev/null +++ b/frappe/integrations/doctype/google_settings/test_google_settings.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Frappe Technologies and Contributors +# License: MIT. See LICENSE +from __future__ import unicode_literals + +import frappe +import unittest + +from .google_settings import get_file_picker_settings + +class TestGoogleSettings(unittest.TestCase): + + def setUp(self): + settings = frappe.get_single("Google Settings") + settings.client_id = "test_client_id" + settings.app_id = "test_app_id" + settings.api_key = "test_api_key" + settings.save() + + def test_picker_disabled(self): + """Google Drive Picker should be disabled if it is not enabled in Google Settings.""" + frappe.db.set_value("Google Settings", None, "enable", 1) + frappe.db.set_value("Google Settings", None, "google_drive_picker_enabled", 0) + settings = get_file_picker_settings() + + self.assertEqual(settings, {}) + + def test_google_disabled(self): + """Google Drive Picker should be disabled if Google integration is not enabled.""" + frappe.db.set_value("Google Settings", None, "enable", 0) + frappe.db.set_value("Google Settings", None, "google_drive_picker_enabled", 1) + settings = get_file_picker_settings() + + self.assertEqual(settings, {}) + + def test_picker_enabled(self): + """If picker is enabled, get_file_picker_settings should return the credentials.""" + frappe.db.set_value("Google Settings", None, "enable", 1) + frappe.db.set_value("Google Settings", None, "google_drive_picker_enabled", 1) + settings = get_file_picker_settings() + + self.assertEqual(True, settings.get("enabled", False)) + self.assertEqual("test_client_id", settings.get("clientId", "")) + self.assertEqual("test_app_id", settings.get("appId", "")) + self.assertEqual("test_api_key", settings.get("developerKey", "")) diff --git a/frappe/integrations/doctype/integration_request/integration_request.py b/frappe/integrations/doctype/integration_request/integration_request.py index 4c4961d96d..ae0e024f58 100644 --- a/frappe/integrations/doctype/integration_request/integration_request.py +++ b/frappe/integrations/doctype/integration_request/integration_request.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/integrations/doctype/integration_request/test_integration_request.py b/frappe/integrations/doctype/integration_request/test_integration_request.py index a26eb4ba93..e26ccabc96 100644 --- a/frappe/integrations/doctype/integration_request/test_integration_request.py +++ b/frappe/integrations/doctype/integration_request/test_integration_request.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/integrations/doctype/ldap_group_mapping/ldap_group_mapping.py b/frappe/integrations/doctype/ldap_group_mapping/ldap_group_mapping.py index b6bb77d964..b9838b996f 100644 --- a/frappe/integrations/doctype/ldap_group_mapping/ldap_group_mapping.py +++ b/frappe/integrations/doctype/ldap_group_mapping/ldap_group_mapping.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/integrations/doctype/ldap_settings/ldap_settings.json b/frappe/integrations/doctype/ldap_settings/ldap_settings.json index 5d30a873fb..d915ae2ad6 100644 --- a/frappe/integrations/doctype/ldap_settings/ldap_settings.json +++ b/frappe/integrations/doctype/ldap_settings/ldap_settings.json @@ -1,4 +1,5 @@ { + "actions": [], "creation": "2016-09-22 04:16:48.829658", "doctype": "DocType", "document_type": "System", @@ -6,18 +7,24 @@ "engine": "InnoDB", "field_order": [ "enabled", - "ldap_server_url", + "ldap_server_settings_section", + "ldap_directory_server", "column_break_4", + "ldap_server_url", + "ldap_auth_section", "base_dn", + "column_break_8", "password", - "section_break_5", - "organizational_unit", - "default_role", + "ldap_search_and_paths_section", + "ldap_search_path_user", "ldap_search_string", + "column_break_12", + "ldap_search_path_group", + "ldap_user_creation_and_mapping_section", "ldap_email_field", "ldap_username_field", - "column_break_11", "ldap_first_name_field", + "column_break_19", "ldap_middle_name_field", "ldap_last_name_field", "ldap_phone_field", @@ -25,13 +32,18 @@ "ldap_security", "ssl_tls_mode", "require_trusted_certificate", - "column_break_17", + "column_break_27", "local_private_key_file", "local_server_certificate_file", "local_ca_certs_file", + "ldap_custom_settings_section", + "ldap_group_objectclass", + "column_break_33", + "ldap_group_member_attribute", "ldap_group_mappings_section", - "ldap_group_field", - "ldap_groups" + "default_role", + "ldap_groups", + "ldap_group_field" ], "fields": [ { @@ -65,18 +77,6 @@ "label": "Password for Base DN", "reqd": 1 }, - { - "fieldname": "section_break_5", - "fieldtype": "Section Break", - "label": "LDAP User Creation and Mapping" - }, - { - "fieldname": "organizational_unit", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Organizational Unit for Users", - "reqd": 1 - }, { "fieldname": "default_role", "fieldtype": "Link", @@ -85,6 +85,7 @@ "reqd": 1 }, { + "description": "Must be enclosed in '()' and include '{0}', which is a placeholder for the user/login name. i.e. (&(objectclass=user)(uid={0}))", "fieldname": "ldap_search_string", "fieldtype": "Data", "label": "LDAP Search String", @@ -102,10 +103,6 @@ "label": "LDAP Username Field", "reqd": 1 }, - { - "fieldname": "column_break_11", - "fieldtype": "Column Break" - }, { "fieldname": "ldap_first_name_field", "fieldtype": "Data", @@ -152,10 +149,6 @@ "options": "No\nYes", "reqd": 1 }, - { - "fieldname": "column_break_17", - "fieldtype": "Column Break" - }, { "fieldname": "local_private_key_file", "fieldtype": "Data", @@ -177,6 +170,7 @@ "label": "LDAP Group Mappings" }, { + "description": "NOTE: This box is due for depreciation. Please re-setup LDAP to work with the newer settings", "fieldname": "ldap_group_field", "fieldtype": "Data", "label": "LDAP Group Field" @@ -186,11 +180,93 @@ "fieldtype": "Table", "label": "LDAP Group Mappings", "options": "LDAP Group Mapping" + }, + { + "fieldname": "ldap_server_settings_section", + "fieldtype": "Section Break", + "label": "LDAP Server Settings" + }, + { + "fieldname": "ldap_auth_section", + "fieldtype": "Section Break", + "label": "LDAP Auth" + }, + { + "fieldname": "column_break_8", + "fieldtype": "Column Break" + }, + { + "fieldname": "ldap_search_and_paths_section", + "fieldtype": "Section Break", + "label": "LDAP Search and Paths" + }, + { + "fieldname": "column_break_12", + "fieldtype": "Column Break" + }, + { + "fieldname": "ldap_user_creation_and_mapping_section", + "fieldtype": "Section Break", + "label": "LDAP User Creation and Mapping" + }, + { + "fieldname": "column_break_19", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_27", + "fieldtype": "Column Break" + }, + { + "description": "These settings are required if 'Custom' LDAP Directory is used", + "fieldname": "ldap_custom_settings_section", + "fieldtype": "Section Break", + "label": "LDAP Custom Settings" + }, + { + "fieldname": "column_break_33", + "fieldtype": "Column Break" + }, + { + "description": "string value, i.e. member", + "fieldname": "ldap_group_member_attribute", + "fieldtype": "Data", + "label": "LDAP Group Member attribute" + }, + { + "description": "Please select the LDAP Directory being used", + "fieldname": "ldap_directory_server", + "fieldtype": "Select", + "label": "Directory Server", + "options": "\nActive Directory\nOpenLDAP\nCustom", + "reqd": 1 + }, + { + "description": "string value, i.e. group", + "fieldname": "ldap_group_objectclass", + "fieldtype": "Data", + "label": "Group Object Class" + }, + { + "description": "Requires any valid fdn path. i.e. ou=users,dc=example,dc=com", + "fieldname": "ldap_search_path_user", + "fieldtype": "Data", + "in_list_view": 1, + "label": "LDAP search path for Users", + "reqd": 1 + }, + { + "description": "Requires any valid fdn path. i.e. ou=groups,dc=example,dc=com", + "fieldname": "ldap_search_path_group", + "fieldtype": "Data", + "label": "LDAP search path for Groups", + "reqd": 1 } ], "in_create": 1, "issingle": 1, - "modified": "2019-07-15 06:48:16.562109", + "links": [], + "modified": "2021-07-27 11:51:43.328271", "modified_by": "Administrator", "module": "Integrations", "name": "LDAP Settings", diff --git a/frappe/integrations/doctype/ldap_settings/ldap_settings.py b/frappe/integrations/doctype/ldap_settings/ldap_settings.py index acc8b96679..1c5abb454c 100644 --- a/frappe/integrations/doctype/ldap_settings/ldap_settings.py +++ b/frappe/integrations/doctype/ldap_settings/ldap_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe import _, safe_encode @@ -13,10 +13,44 @@ class LDAPSettings(Document): return if not self.flags.ignore_mandatory: - if self.ldap_search_string and self.ldap_search_string.endswith("={0}"): - self.connect_to_ldap(base_dn=self.base_dn, password=self.get_password(raise_exception=False)) + + if self.ldap_search_string.count('(') == self.ldap_search_string.count(')') and \ + self.ldap_search_string.startswith('(') and \ + self.ldap_search_string.endswith(')') and \ + self.ldap_search_string and \ + "{0}" in self.ldap_search_string: + + conn = self.connect_to_ldap(base_dn=self.base_dn, password=self.get_password(raise_exception=False)) + + try: + if conn.result['type'] == 'bindResponse' and self.base_dn: + import ldap3 + + conn.search( + search_base=self.ldap_search_path_user, + search_filter="(objectClass=*)", + attributes=self.get_ldap_attributes()) + + conn.search( + search_base=self.ldap_search_path_group, + search_filter="(objectClass=*)", + attributes=['cn']) + + except ldap3.core.exceptions.LDAPAttributeError as ex: + frappe.throw(_("LDAP settings incorrect. validation response was: {0}").format(ex), + title=_("Misconfigured")) + + except ldap3.core.exceptions.LDAPNoSuchObjectResult: + frappe.throw(_("Ensure the user and group search paths are correct."), + title=_("Misconfigured")) + + if self.ldap_directory_server.lower() == 'custom': + if not self.ldap_group_member_attribute or not self.ldap_group_mappings_section: + frappe.throw(_("Custom LDAP Directoy Selected, please ensure 'LDAP Group Member attribute' and 'LDAP Group Mappings' are entered"), + title=_("Misconfigured")) + else: - frappe.throw(_("LDAP Search String needs to end with a placeholder, eg sAMAccountName={0}")) + frappe.throw(_("LDAP Search String must be enclosed in '()' and needs to contian the user placeholder {0}, eg sAMAccountName={0}")) def connect_to_ldap(self, base_dn, password, read_only=True): try: @@ -118,8 +152,8 @@ class LDAPSettings(Document): user.insert(ignore_permissions=True) # always add default role. user.add_roles(self.default_role) - if self.ldap_group_field: - self.sync_roles(user, groups) + self.sync_roles(user, groups) + return user def get_ldap_attributes(self): @@ -142,6 +176,66 @@ class LDAPSettings(Document): return ldap_attributes + + def fetch_ldap_groups(self, user, conn): + import ldap3 + + if type(user) is not ldap3.abstract.entry.Entry: + raise TypeError("Invalid type, attribute {0} must be of type '{1}'".format('user', 'ldap3.abstract.entry.Entry')) + + if type(conn) is not ldap3.core.connection.Connection: + raise TypeError("Invalid type, attribute {0} must be of type '{1}'".format('conn', 'ldap3.Connection')) + + fetch_ldap_groups = None + + ldap_object_class = None + ldap_group_members_attribute = None + + + if self.ldap_directory_server.lower() == 'active directory': + + ldap_object_class = 'Group' + ldap_group_members_attribute = 'member' + user_search_str = user.entry_dn + + + elif self.ldap_directory_server.lower() == 'openldap': + + ldap_object_class = 'posixgroup' + ldap_group_members_attribute = 'memberuid' + user_search_str = getattr(user, self.ldap_username_field).value + + elif self.ldap_directory_server.lower() == 'custom': + + ldap_object_class = self.ldap_group_objectclass + ldap_group_members_attribute = self.ldap_group_member_attribute + user_search_str = getattr(user, self.ldap_username_field).value + + else: + # NOTE: depreciate this else path + # this path will be hit for everyone with preconfigured ldap settings. this must be taken into account so as not to break ldap for those users. + + if self.ldap_group_field: + + fetch_ldap_groups = getattr(user, self.ldap_group_field).values + + if ldap_object_class is not None: + conn.search( + search_base=self.ldap_search_path_group, + search_filter="(&(objectClass={0})({1}={2}))".format(ldap_object_class,ldap_group_members_attribute, user_search_str), + attributes=['cn']) # Build search query + + if len(conn.entries) >= 1: + + fetch_ldap_groups = [] + for group in conn.entries: + fetch_ldap_groups.append(group['cn'].value) + + return fetch_ldap_groups + + + + def authenticate(self, username, password): if not self.enabled: @@ -152,23 +246,33 @@ class LDAPSettings(Document): conn = self.connect_to_ldap(self.base_dn, self.get_password(raise_exception=False)) - conn.search( - search_base=self.organizational_unit, - search_filter="({0})".format(user_filter), - attributes=ldap_attributes) + try: + import ldap3 - if len(conn.entries) == 1 and conn.entries[0]: - user = conn.entries[0] - # only try and connect as the user, once we have their fqdn entry. - self.connect_to_ldap(base_dn=user.entry_dn, password=password) + conn.search( + search_base=self.ldap_search_path_user, + search_filter="{0}".format(user_filter), + attributes=ldap_attributes) - groups = None - if self.ldap_group_field: - groups = getattr(user, self.ldap_group_field).values - return self.create_or_update_user(self.convert_ldap_entry_to_dict(user), groups=groups) - else: + if len(conn.entries) == 1 and conn.entries[0]: + user = conn.entries[0] + + groups = self.fetch_ldap_groups(user, conn) + + # only try and connect as the user, once we have their fqdn entry. + if user.entry_dn and password and conn.rebind(user=user.entry_dn, password=password): + + return self.create_or_update_user(self.convert_ldap_entry_to_dict(user), groups=groups) + + raise ldap3.core.exceptions.LDAPInvalidCredentialsResult # even though nothing foundor failed authentication raise invalid credentials + + except ldap3.core.exceptions.LDAPInvalidFilterError: + frappe.throw(_("Please use a valid LDAP search filter"), title=_("Misconfigured")) + + except ldap3.core.exceptions.LDAPInvalidCredentialsResult: frappe.throw(_("Invalid username or password")) + def reset_password(self, user, password, logout_sessions=False): from ldap3 import HASHED_SALTED_SHA, MODIFY_REPLACE from ldap3.utils.hashed import hashed @@ -179,7 +283,7 @@ class LDAPSettings(Document): read_only=False) if conn.search( - search_base=self.organizational_unit, + search_base=self.ldap_search_path_user, search_filter=search_filter, attributes=self.get_ldap_attributes() ): diff --git a/frappe/integrations/doctype/ldap_settings/test_data_ldif_activedirectory.json b/frappe/integrations/doctype/ldap_settings/test_data_ldif_activedirectory.json new file mode 100644 index 0000000000..9777452af8 --- /dev/null +++ b/frappe/integrations/doctype/ldap_settings/test_data_ldif_activedirectory.json @@ -0,0 +1,338 @@ +{ + "entries": [ + { + "attributes": { + "cn": "base_dn_user", + "memberOf": [ + "cn=Domain Users,ou=Groups,dc=unit,dc=testing", + "cn=Enterprise Administrators,ou=Groups,dc=unit,dc=testing" + ], + "objectClass": [ + "user", + "top", + "person", + "organizationalPerson" + ], + "samaccountname": "cn=base_dn_user,dc=unit,dc=testing", + "sn": "user_sn", + "userPassword": [ + "my_password" + ] + }, + "dn": "cn=base_dn_user,dc=unit,dc=testing", + "raw": { + "cn": [ + "base_dn_user" + ], + "memberOf": [ + "cn=Domain Users,ou=Groups,dc=unit,dc=testing", + "cn=Enterprise Administrators,ou=Groups,dc=unit,dc=testing" + ], + "objectClass": [ + "user", + "top", + "person", + "organizationalPerson" + ], + "samaccountname": [ + "cn=base_dn_user,dc=unit,dc=testing" + ], + "sn": [ + "user_sn" + ], + "userPassword": [ + "my_password" + ] + } + }, + { + "attributes": { + "cn": "Posix User1", + "description": [ + "ACCESS:test1,ACCESS:test2" + ], + "givenname": "Posix", + "mail": "posix.user1@unit.testing", + "memberOf": [ + "cn=Domain Users,ou=Groups,dc=unit,dc=testing", + "cn=Domain Administrators,ou=Groups,dc=unit,dc=testing" + ], + "mobile": "0421 123 456", + "objectClass": [ + "user", + "top", + "person", + "organizationalPerson" + ], + "samaccountname": "posix.user", + "sn": "User1", + "telephonenumber": "08 8912 3456", + "userpassword": [ + "posix_user_password" + ] + }, + "dn": "cn=Posix User1,ou=Users,dc=unit,dc=testing", + "raw": { + "cn": [ + "Posix User1" + ], + "description": [ + "ACCESS:test1,ACCESS:test2" + ], + "givenname": [ + "Posix" + ], + "mail": [ + "posix.user1@unit.testing" + ], + "memberOf": [ + "cn=Domain Users,ou=Groups,dc=unit,dc=testing", + "cn=Domain Administrators,ou=Groups,dc=unit,dc=testing" + ], + "mobile": [ + "0421 123 456" + ], + "objectClass": [ + "user", + "top", + "person", + "organizationalPerson" + ], + "samaccountname": [ + "posix.user" + ], + "sn": [ + "User1" + ], + "telephonenumber": [ + "08 8912 3456" + ], + "userpassword": [ + "posix_user_password" + ] + } + }, + { + "attributes": { + "cn": "Posix User2", + "description": [ + "ACCESS:test1,ACCESS:test3" + ], + "givenname": "Posix", + "homedirectory": "/home/users/posix.user2", + "mail": "posix.user2@unit.testing", + "memberOf": [ + "cn=Domain Users,ou=Groups,dc=unit,dc=testing", + "cn=Enterprise Administrators,ou=Groups,dc=unit,dc=testing" + ], + "mobile": "0421 456 789", + "objectClass": [ + "user", + "top", + "person", + "organizationalPerson" + ], + "samaccountname": "posix.user2", + "sn": "User2", + "telephonenumber": "08 8978 1234", + "userpassword": [ + "posix_user2_password" + ] + }, + "dn": "cn=Posix User2,ou=Users,dc=unit,dc=testing", + "raw": { + "cn": [ + "Posix User2" + ], + "description": [ + "ACCESS:test1,ACCESS:test3" + ], + "givenname": [ + "Posix" + ], + "homedirectory": [ + "/home/users/posix.user2" + ], + "mail": [ + "posix.user2@unit.testing" + ], + "memberOf": [ + "cn=Domain Users,ou=Groups,dc=unit,dc=testing", + "cn=Enterprise Administrators,ou=Groups,dc=unit,dc=testing" + ], + "mobile": [ + "0421 456 789" + ], + "objectClass": [ + "user", + "top", + "person", + "organizationalPerson" + ], + "samaccountname": [ + "posix.user2" + ], + "sn": [ + "User2" + ], + "telephonenumber": [ + "08 8978 1234" + ], + "userpassword": [ + "posix_user2_password" + ] + } + }, + { + "attributes": { + "objectClass": [ + "top", + "organizationalUnit" + ], + "ou": [ + "Users" + ] + }, + "dn": "ou=Users,dc=unit,dc=testing", + "raw": { + "objectClass": [ + "top", + "organizationalUnit" + ], + "ou": [ + "Users" + ] + } + }, + { + "attributes": { + "Member": [ + "cn=Posix User2,ou=Users,dc=unit,dc=testing" + ], + "cn": "Enterprise Administrators", + "description": [ + "group contains only posix.user2" + ], + "groupType": 2147483652, + "objectClass": [ + "top", + "group" + ] + }, + "dn": "cn=Enterprise Administrators,ou=Groups,dc=unit,dc=testing", + "raw": { + "Member": [ + "cn=Posix User2,ou=Users,dc=unit,dc=testing" + ], + "cn": [ + "Enterprise Administrators" + ], + "description": [ + "group contains only posix.user2" + ], + "groupType": [ + "2147483652" + ], + "objectClass": [ + "top", + "group" + ] + } + }, + { + "attributes": { + "Member": [ + "cn=Posix User1,ou=Users,dc=unit,dc=testing", + "cn=Posix User2,ou=Users,dc=unit,dc=testing" + ], + "cn": "Domain Users", + "description": [ + "group2 Users contains only posix.user and posix.user2" + ], + "groupType": 2147483652, + "objectClass": [ + "top", + "group" + ] + }, + "dn": "cn=Domain Users,ou=Groups,dc=unit,dc=testing", + "raw": { + "Member": [ + "cn=Posix User1,ou=Users,dc=unit,dc=testing", + "cn=Posix User2,ou=Users,dc=unit,dc=testing" + ], + "cn": [ + "Domain Users" + ], + "description": [ + "group2 Users contains only posix.user and posix.user2" + ], + "groupType": [ + "2147483652" + ], + "objectClass": [ + "top", + "group" + ] + } + }, + { + "attributes": { + "Member": [ + "cn=Posix User1,ou=Users,dc=unit,dc=testing", + "cn=base_dn_user,dc=unit,dc=testing" + ], + "cn": "Domain Administrators", + "description": [ + "group1 Administrators contains only posix.user only" + ], + "groupType": 2147483652, + "objectClass": [ + "top", + "group" + ] + }, + "dn": "cn=Domain Administrators,ou=Groups,dc=unit,dc=testing", + "raw": { + "Member": [ + "cn=Posix User1,ou=Users,dc=unit,dc=testing", + "cn=base_dn_user,dc=unit,dc=testing" + ], + "cn": [ + "Domain Administrators" + ], + "description": [ + "group1 Administrators contains only posix.user only" + ], + "groupType": [ + "2147483652" + ], + "objectClass": [ + "top", + "group" + ] + } + }, + { + "attributes": { + "objectClass": [ + "top", + "organizationalUnit" + ], + "ou": [ + "Groups" + ] + }, + "dn": "ou=Groups,dc=unit,dc=testing", + "raw": { + "objectClass": [ + "top", + "organizationalUnit" + ], + "ou": [ + "Groups" + ] + } + } + ] +} \ No newline at end of file diff --git a/frappe/integrations/doctype/ldap_settings/test_data_ldif_openldap.json b/frappe/integrations/doctype/ldap_settings/test_data_ldif_openldap.json new file mode 100644 index 0000000000..86a76c1abc --- /dev/null +++ b/frappe/integrations/doctype/ldap_settings/test_data_ldif_openldap.json @@ -0,0 +1,400 @@ +{ + "entries": [ + { + "attributes": { + "cn": [ + "base_dn_user" + ], + "objectClass": [ + "simpleSecurityObject", + "organizationalRole", + "top" + ], + "sn": [ + "user_sn" + ], + "userPassword": [ + "my_password" + ] + }, + "dn": "cn=base_dn_user,dc=unit,dc=testing", + "raw": { + "cn": [ + "base_dn_user" + ], + "objectClass": [ + "simpleSecurityObject", + "organizationalRole", + "top" + ], + "sn": [ + "user_sn" + ], + "userPassword": [ + "my_password" + ] + } + }, + { + "attributes": { + "cn": [ + "Posix User2" + ], + "description": [ + "ACCESS:test1,ACCESS:test3" + ], + "gidnumber": 501, + "givenname": [ + "Posix2" + ], + "homedirectory": "/home/users/posix.user2", + "mail": [ + "posix.user2@unit.testing" + ], + "mobile": [ + "0421 456 789" + ], + "objectClass": [ + "posixAccount", + "top", + "inetOrgPerson", + "person", + "organizationalPerson" + ], + "sn": [ + "User2" + ], + "telephonenumber": [ + "08 8978 1234" + ], + "uid": [ + "posix.user2" + ], + "uidnumber": 1000, + "userpassword": [ + "posix_user2_password" + ] + }, + "dn": "cn=Posix User2,ou=users,dc=unit,dc=testing", + "raw": { + "cn": [ + "Posix User2" + ], + "description": [ + "ACCESS:test1,ACCESS:test3" + ], + "gidnumber": [ + "501" + ], + "givenname": [ + "Posix2" + ], + "homedirectory": [ + "/home/users/posix.user2" + ], + "mail": [ + "posix.user2@unit.testing" + ], + "mobile": [ + "0421 456 789" + ], + "objectClass": [ + "posixAccount", + "top", + "inetOrgPerson", + "person", + "organizationalPerson" + ], + "sn": [ + "User2" + ], + "telephonenumber": [ + "08 8978 1234" + ], + "uid": [ + "posix.user2" + ], + "uidnumber": [ + "1000" + ], + "userpassword": [ + "posix_user2_password" + ] + } + }, + { + "attributes": { + "cn": [ + "Posix User1" + ], + "description": [ + "ACCESS:test1,ACCESS:test2" + ], + "gidnumber": 501, + "givenname": [ + "Posix" + ], + "homedirectory": "/home/users/posix.user", + "mail": [ + "posix.user1@unit.testing" + ], + "mobile": [ + "0421 123 456" + ], + "objectClass": [ + "posixAccount", + "top", + "inetOrgPerson", + "person", + "organizationalPerson" + ], + "sn": [ + "User1" + ], + "telephonenumber": [ + "08 8912 3456" + ], + "uid": [ + "posix.user" + ], + "uidnumber": 1000, + "userpassword": [ + "posix_user_password" + ] + }, + "dn": "cn=Posix User1,ou=users,dc=unit,dc=testing", + "raw": { + "cn": [ + "Posix User1" + ], + "description": [ + "ACCESS:test1,ACCESS:test2" + ], + "gidnumber": [ + "501" + ], + "givenname": [ + "Posix" + ], + "homedirectory": [ + "/home/users/posix.user" + ], + "mail": [ + "posix.user1@unit.testing" + ], + "mobile": [ + "0421 123 456" + ], + "objectClass": [ + "posixAccount", + "top", + "inetOrgPerson", + "person", + "organizationalPerson" + ], + "sn": [ + "User1" + ], + "telephonenumber": [ + "08 8912 3456" + ], + "uid": [ + "posix.user" + ], + "uidnumber": [ + "1000" + ], + "userpassword": [ + "posix_user_password" + ] + } + }, + { + "attributes": { + "objectClass": [ + "top", + "organizationalUnit" + ], + "ou": [ + "Users", + "users" + ] + }, + "dn": "ou=users,dc=unit,dc=testing", + "raw": { + "objectClass": [ + "top", + "organizationalUnit" + ], + "ou": [ + "Users", + "users" + ] + } + }, + { + "attributes": { + "dc": "testing", + "o": [ + "Testing" + ], + "objectClass": [ + "top", + "organization", + "dcObject" + ] + }, + "dn": "dc=unit,dc=testing", + "raw": { + "dc": [ + "testing", + "unit" + ], + "o": [ + "Testing" + ], + "objectClass": [ + "top", + "organization", + "dcObject" + ] + } + }, + { + "attributes": { + "cn": [ + "Users" + ], + "description": [ + "group2 Users contains only posix.user and posix.user2" + ], + "gidnumber": 501, + "memberuid": [ + "posix.user2", + "posix.user" + ], + "objectClass": [ + "top", + "posixGroup" + ] + }, + "dn": "cn=Users,ou=groups,dc=unit,dc=testing", + "raw": { + "cn": [ + "Users" + ], + "description": [ + "group2 Users contains only posix.user and posix.user2" + ], + "gidnumber": [ + "501" + ], + "memberuid": [ + "posix.user2", + "posix.user" + ], + "objectClass": [ + "top", + "posixGroup" + ] + } + }, + { + "attributes": { + "cn": [ + "Administrators" + ], + "description": [ + "group1 Administrators contains only posix.user only" + ], + "gidnumber": 500, + "memberuid": [ + "posix.user" + ], + "objectClass": [ + "top", + "posixGroup" + ] + }, + "dn": "cn=Administrators,ou=groups,dc=unit,dc=testing", + "raw": { + "cn": [ + "Administrators" + ], + "description": [ + "group1 Administrators contains only posix.user only" + ], + "gidnumber": [ + "500" + ], + "memberuid": [ + "posix.user" + ], + "objectClass": [ + "top", + "posixGroup" + ] + } + }, + { + "attributes": { + "cn": [ + "Group3" + ], + "description": [ + "group3 Group3 contains only posix.user2 only" + ], + "gidnumber": 502, + "memberuid": [ + "posix.user2" + ], + "objectClass": [ + "top", + "posixGroup" + ] + }, + "dn": "cn=Group3,ou=groups,dc=unit,dc=testing", + "raw": { + "cn": [ + "Group3" + ], + "description": [ + "group3 Group3 contains only posix.user2 only" + ], + "gidnumber": [ + "502" + ], + "memberuid": [ + "posix.user2" + ], + "objectClass": [ + "top", + "posixGroup" + ] + } + }, + { + "attributes": { + "objectClass": [ + "top", + "organizationalUnit" + ], + "ou": [ + "Users", + "groups" + ] + }, + "dn": "ou=groups,dc=unit,dc=testing", + "raw": { + "objectClass": [ + "top", + "organizationalUnit" + ], + "ou": [ + "Users", + "groups" + ] + } + } + ] +} \ No newline at end of file diff --git a/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py b/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py index 113692b6c4..7b0638876b 100644 --- a/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py +++ b/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py @@ -1,8 +1,684 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and Contributors -# See license.txt -# import frappe +# License: MIT. See LICENSE +import frappe import unittest +import functools +import ldap3 +import ssl +import os + +from unittest import mock +from frappe.integrations.doctype.ldap_settings.ldap_settings import LDAPSettings +from ldap3 import Server, Connection, MOCK_SYNC, OFFLINE_SLAPD_2_4, OFFLINE_AD_2012_R2 + + +class LDAP_TestCase(): + TEST_LDAP_SERVER = None # must match the 'LDAP Settings' field option + TEST_LDAP_SEARCH_STRING = None + LDAP_USERNAME_FIELD = None + DOCUMENT_GROUP_MAPPINGS = [] + LDAP_SCHEMA = None + LDAP_LDIF_JSON = None + TEST_VALUES_LDAP_COMPLEX_SEARCH_STRING = None + + def mock_ldap_connection(f): + + @functools.wraps(f) + def wrapped(self, *args, **kwargs): + + with mock.patch('frappe.integrations.doctype.ldap_settings.ldap_settings.LDAPSettings.connect_to_ldap') as mock_connection: + mock_connection.return_value = self.connection + + self.test_class = LDAPSettings(self.doc) + + # Create a clean doc + localdoc = self.doc.copy() + frappe.get_doc(localdoc).save() + + rv = f(self, *args, **kwargs) + + + # Clean-up + self.test_class = None + + return rv + + return wrapped + + def clean_test_users(): + try: # clean up test user 1 + frappe.get_doc("User", 'posix.user1@unit.testing').delete() + except Exception: + pass + + try: # clean up test user 2 + frappe.get_doc("User", 'posix.user2@unit.testing').delete() + except Exception: + pass + + + @classmethod + def setUpClass(self, ldapServer='OpenLDAP'): + + self.clean_test_users() + # Save user data for restoration in tearDownClass() + self.user_ldap_settings = frappe.get_doc('LDAP Settings') + + # Create test user1 + self.user1doc = { + 'username': 'posix.user', + 'email': 'posix.user1@unit.testing', + 'first_name': 'posix' + } + self.user1doc.update({ + "doctype": "User", + "send_welcome_email": 0, + "language": "", + "user_type": "System User", + }) + + user = frappe.get_doc(self.user1doc) + user.insert(ignore_permissions=True) + + # Create test user1 + self.user2doc = { + 'username': 'posix.user2', + 'email': 'posix.user2@unit.testing', + 'first_name': 'posix' + } + self.user2doc.update({ + "doctype": "User", + "send_welcome_email": 0, + "language": "", + "user_type": "System User", + }) + + user = frappe.get_doc(self.user2doc) + user.insert(ignore_permissions=True) + + + # Setup Mock OpenLDAP Directory + self.ldap_dc_path = 'dc=unit,dc=testing' + self.ldap_user_path = 'ou=users,' + self.ldap_dc_path + self.ldap_group_path = 'ou=groups,' + self.ldap_dc_path + self.base_dn = 'cn=base_dn_user,' + self.ldap_dc_path + self.base_password = 'my_password' + self.ldap_server = 'ldap://my_fake_server:389' + + + self.doc = { + "doctype": "LDAP Settings", + "enabled": True, + "ldap_directory_server": self.TEST_LDAP_SERVER, + "ldap_server_url": self.ldap_server, + "base_dn": self.base_dn, + "password": self.base_password, + "ldap_search_path_user": self.ldap_user_path, + "ldap_search_string": self.TEST_LDAP_SEARCH_STRING, + "ldap_search_path_group": self.ldap_group_path, + "ldap_user_creation_and_mapping_section": '', + "ldap_email_field": 'mail', + "ldap_username_field": self.LDAP_USERNAME_FIELD, + "ldap_first_name_field": 'givenname', + "ldap_middle_name_field": '', + "ldap_last_name_field": 'sn', + "ldap_phone_field": 'telephonenumber', + "ldap_mobile_field": 'mobile', + "ldap_security": '', + "ssl_tls_mode": '', + "require_trusted_certificate": 'No', + "local_private_key_file": '', + "local_server_certificate_file": '', + "local_ca_certs_file": '', + "ldap_group_objectclass": '', + "ldap_group_member_attribute": '', + "default_role": 'Newsletter Manager', + "ldap_groups": self.DOCUMENT_GROUP_MAPPINGS, + "ldap_group_field": ''} + + self.server = Server(host=self.ldap_server, port=389, get_info=self.LDAP_SCHEMA) + + self.connection = Connection( + self.server, + user=self.base_dn, + password=self.base_password, + read_only=True, + client_strategy=MOCK_SYNC) + + self.connection.strategy.entries_from_json(os.path.abspath(os.path.dirname(__file__)) + '/' + self.LDAP_LDIF_JSON) + + self.connection.bind() + + + @classmethod + def tearDownClass(self): + try: + frappe.get_doc('LDAP Settings').delete() + + except Exception: + pass + + try: + # return doc back to user data + self.user_ldap_settings.save() + + except Exception: + pass + + # Clean-up test users + self.clean_test_users() + + # Clear OpenLDAP connection + self.connection = None + + + @mock_ldap_connection + def test_mandatory_fields(self): + + mandatory_fields = [ + 'ldap_server_url', + 'ldap_directory_server', + 'base_dn', + 'password', + 'ldap_search_path_user', + 'ldap_search_path_group', + 'ldap_search_string', + 'ldap_email_field', + 'ldap_username_field', + 'ldap_first_name_field', + 'require_trusted_certificate', + 'default_role' + ] # fields that are required to have ldap functioning need to be mandatory + + for mandatory_field in mandatory_fields: + + localdoc = self.doc.copy() + localdoc[mandatory_field] = '' + + try: + + frappe.get_doc(localdoc).save() + + self.fail('Document LDAP Settings field [{0}] is not mandatory'.format(mandatory_field)) + + except frappe.exceptions.MandatoryError: + pass + + except frappe.exceptions.ValidationError: + if mandatory_field == 'ldap_search_string': + # additional validation is done on this field, pass in this instance + pass + + + for non_mandatory_field in self.doc: # Ensure remaining fields have not been made mandatory + + if non_mandatory_field == 'doctype' or non_mandatory_field in mandatory_fields: + continue + + localdoc = self.doc.copy() + localdoc[non_mandatory_field] = '' + + try: + + frappe.get_doc(localdoc).save() + + except frappe.exceptions.MandatoryError: + self.fail('Document LDAP Settings field [{0}] should not be mandatory'.format(non_mandatory_field)) + + + @mock_ldap_connection + def test_validation_ldap_search_string(self): + + invalid_ldap_search_strings = [ + '', + 'uid={0}', + '(uid={0}', + 'uid={0})', + '(&(objectclass=posixgroup)(uid={0})', + '&(objectclass=posixgroup)(uid={0}))', + '(uid=no_placeholder)' + ] # ldap search string must be enclosed in '()' for ldap search to work for finding user and have the same number of opening and closing brackets. + + for invalid_search_string in invalid_ldap_search_strings: + + localdoc = self.doc.copy() + localdoc['ldap_search_string'] = invalid_search_string + + try: + frappe.get_doc(localdoc).save() + + self.fail("LDAP search string [{0}] should not validate".format(invalid_search_string)) + + except frappe.exceptions.ValidationError: + pass + + + def test_connect_to_ldap(self): + + # setup a clean doc with ldap disabled so no validation occurs (this is tested seperatly) + local_doc = self.doc.copy() + local_doc['enabled'] = False + self.test_class = LDAPSettings(self.doc) + + with mock.patch('ldap3.Server') as ldap3_server_method: + + with mock.patch('ldap3.Connection') as ldap3_connection_method: + ldap3_connection_method.return_value = self.connection + + with mock.patch('ldap3.Tls') as ldap3_Tls_method: + + function_return = self.test_class.connect_to_ldap(base_dn=self.base_dn, password=self.base_password) + + args, kwargs = ldap3_connection_method.call_args + + prevent_connection_parameters = { + # prevent these parameters for security or lack of the und user from being able to configure + 'mode': { + 'IP_V4_ONLY': 'Locks the user to IPv4 without frappe providing a way to configure', + 'IP_V6_ONLY': 'Locks the user to IPv6 without frappe providing a way to configure' + }, + 'auto_bind': { + 'NONE': 'ldap3.Connection must autobind with base_dn', + 'NO_TLS': 'ldap3.Connection must have TLS', + 'TLS_AFTER_BIND': '[Security] ldap3.Connection TLS bind must occur before bind' + } + } + + for connection_arg in kwargs: + + if connection_arg in prevent_connection_parameters and \ + kwargs[connection_arg] in prevent_connection_parameters[connection_arg]: + + self.fail('ldap3.Connection was called with {0}, failed reason: [{1}]'.format( + kwargs[connection_arg], + prevent_connection_parameters[connection_arg][kwargs[connection_arg]])) + + if local_doc['require_trusted_certificate'] == 'Yes': + tls_validate = ssl.CERT_REQUIRED + tls_version = ssl.PROTOCOL_TLSv1 + tls_configuration = ldap3.Tls(validate=tls_validate, version=tls_version) + + self.assertTrue(kwargs['auto_bind'] == ldap3.AUTO_BIND_TLS_BEFORE_BIND, + 'Security: [ldap3.Connection] autobind TLS before bind with value ldap3.AUTO_BIND_TLS_BEFORE_BIND') + + else: + tls_validate = ssl.CERT_NONE + tls_version = ssl.PROTOCOL_TLSv1 + tls_configuration = ldap3.Tls(validate=tls_validate, version=tls_version) + + self.assertTrue(kwargs['auto_bind'], + 'ldap3.Connection must autobind') + + + ldap3_Tls_method.assert_called_with(validate=tls_validate, version=tls_version) + + ldap3_server_method.assert_called_with(host=self.doc['ldap_server_url'], tls=tls_configuration) + + self.assertTrue(kwargs['password'] == self.base_password, + 'ldap3.Connection password does not match provided password') + + self.assertTrue(kwargs['raise_exceptions'], + 'ldap3.Connection must raise exceptions for error handling') + + self.assertTrue(kwargs['user'] == self.base_dn, + 'ldap3.Connection user does not match provided user') + + ldap3_connection_method.assert_called_with(server=ldap3_server_method.return_value, + auto_bind=True, + password=self.base_password, + raise_exceptions=True, + read_only=True, + user=self.base_dn) + + self.assertTrue(type(function_return) is ldap3.core.connection.Connection, + 'The return type must be of ldap3.Connection') + + function_return = self.test_class.connect_to_ldap(base_dn=self.base_dn, password=self.base_password, read_only=False) + + args, kwargs = ldap3_connection_method.call_args + + self.assertFalse(kwargs['read_only'], 'connect_to_ldap() read_only parameter supplied as False but does not match the ldap3.Connection() read_only named parameter') + + + + + @mock_ldap_connection + def test_get_ldap_client_settings(self): + + result = self.test_class.get_ldap_client_settings() + + self.assertIsInstance(result, dict) + + self.assertTrue(result['enabled'] == self.doc['enabled']) # settings should match doc + + localdoc = self.doc.copy() + localdoc['enabled'] = False + frappe.get_doc(localdoc).save() + + result = self.test_class.get_ldap_client_settings() + + self.assertFalse(result['enabled']) # must match the edited doc + + + @mock_ldap_connection + def test_update_user_fields(self): + + test_user_data = { + 'username': 'posix.user', + 'email': 'posix.user1@unit.testing', + 'first_name': 'posix', + 'middle_name': 'another', + 'last_name': 'user', + 'phone': '08 1234 5678', + 'mobile_no': '0421 123 456' + } + + test_user = frappe.get_doc("User", test_user_data['email']) + + self.test_class.update_user_fields(test_user, test_user_data) + + updated_user = frappe.get_doc("User", test_user_data['email']) + + self.assertTrue(updated_user.middle_name == test_user_data['middle_name']) + self.assertTrue(updated_user.last_name == test_user_data['last_name']) + self.assertTrue(updated_user.phone == test_user_data['phone']) + self.assertTrue(updated_user.mobile_no == test_user_data['mobile_no']) + + + @mock_ldap_connection + def test_sync_roles(self): + + if self.TEST_LDAP_SERVER.lower() == 'openldap': + test_user_data = { + 'posix.user1': ['Users', 'Administrators', 'default_role', 'frappe_default_all','frappe_default_guest'], + 'posix.user2': ['Users', 'Group3', 'default_role', 'frappe_default_all', 'frappe_default_guest'] + } + + elif self.TEST_LDAP_SERVER.lower() == 'active directory': + test_user_data = { + 'posix.user1': ['Domain Users', 'Domain Administrators', 'default_role', 'frappe_default_all','frappe_default_guest'], + 'posix.user2': ['Domain Users', 'Enterprise Administrators', 'default_role', 'frappe_default_all', 'frappe_default_guest'] + } + + + role_to_group_map = { + self.doc['ldap_groups'][0]['erpnext_role']: self.doc['ldap_groups'][0]['ldap_group'], + self.doc['ldap_groups'][1]['erpnext_role']: self.doc['ldap_groups'][1]['ldap_group'], + self.doc['ldap_groups'][2]['erpnext_role']: self.doc['ldap_groups'][2]['ldap_group'], + 'Newsletter Manager': 'default_role', + 'All': 'frappe_default_all', + 'Guest': 'frappe_default_guest', + + } + + # re-create user1 to ensure clean + frappe.get_doc("User", 'posix.user1@unit.testing').delete() + user = frappe.get_doc(self.user1doc) + user.insert(ignore_permissions=True) + + for test_user in test_user_data: + + test_user_doc = frappe.get_doc("User", test_user + '@unit.testing') + test_user_roles = frappe.get_roles(test_user + '@unit.testing') + + self.assertTrue(len(test_user_roles) == 2, + 'User should only be a part of the All and Guest roles') # check default frappe roles + + self.test_class.sync_roles(test_user_doc, test_user_data[test_user]) # update user roles + + frappe.get_doc("User", test_user + '@unit.testing') + updated_user_roles = frappe.get_roles(test_user + '@unit.testing') + + self.assertTrue(len(updated_user_roles) == len(test_user_data[test_user]), + 'syncing of the user roles failed. {0} != {1} for user {2}'.format(len(updated_user_roles), len(test_user_data[test_user]), test_user)) + + for user_role in updated_user_roles: # match each users role mapped to ldap groups + + self.assertTrue(role_to_group_map[user_role] in test_user_data[test_user], + 'during sync_roles(), the user was given role {0} which should not have occured'.format(user_role)) + + @mock_ldap_connection + def test_create_or_update_user(self): + + test_user_data = { + 'posix.user1': ['Users', 'Administrators', 'default_role', 'frappe_default_all','frappe_default_guest'], + } + + test_user = 'posix.user1' + + frappe.get_doc("User", test_user + '@unit.testing').delete() # remove user 1 + + with self.assertRaises(frappe.exceptions.DoesNotExistError): # ensure user deleted so function can be tested + frappe.get_doc("User", test_user + '@unit.testing') + + + with mock.patch('frappe.integrations.doctype.ldap_settings.ldap_settings.LDAPSettings.update_user_fields') \ + as update_user_fields_method: + + update_user_fields_method.return_value = None + + + with mock.patch('frappe.integrations.doctype.ldap_settings.ldap_settings.LDAPSettings.sync_roles') as sync_roles_method: + + sync_roles_method.return_value = None + + # New user + self.test_class.create_or_update_user(self.user1doc, test_user_data[test_user]) + + self.assertTrue(sync_roles_method.called, 'User roles need to be updated for a new user') + self.assertFalse(update_user_fields_method.called, + 'User roles are not required to be updated for a new user, this will occur during logon') + + + # Existing user + self.test_class.create_or_update_user(self.user1doc, test_user_data[test_user]) + + self.assertTrue(sync_roles_method.called, 'User roles need to be updated for an existing user') + self.assertTrue(update_user_fields_method.called, 'User fields need to be updated for an existing user') + + + @mock_ldap_connection + def test_get_ldap_attributes(self): + + method_return = self.test_class.get_ldap_attributes() + + self.assertTrue(type(method_return) is list) + + + + @mock_ldap_connection + def test_fetch_ldap_groups(self): + + if self.TEST_LDAP_SERVER.lower() == 'openldap': + test_users = { + 'posix.user': ['Users', 'Administrators'], + 'posix.user2': ['Users', 'Group3'] + + } + elif self.TEST_LDAP_SERVER.lower() == 'active directory': + test_users = { + 'posix.user': ['Domain Users', 'Domain Administrators'], + 'posix.user2': ['Domain Users', 'Enterprise Administrators'] + + } + + for test_user in test_users: + + self.connection.search( + search_base=self.ldap_user_path, + search_filter=self.TEST_LDAP_SEARCH_STRING.format(test_user), + attributes=self.test_class.get_ldap_attributes()) + + method_return = self.test_class.fetch_ldap_groups(self.connection.entries[0], self.connection) + + self.assertIsInstance(method_return, list) + self.assertTrue(len(method_return) == len(test_users[test_user])) + + for returned_group in method_return: + + self.assertTrue(returned_group in test_users[test_user]) + + + + @mock_ldap_connection + def test_authenticate(self): + + with mock.patch('frappe.integrations.doctype.ldap_settings.ldap_settings.LDAPSettings.fetch_ldap_groups') as \ + fetch_ldap_groups_function: + + fetch_ldap_groups_function.return_value = None + + self.assertTrue(self.test_class.authenticate('posix.user', 'posix_user_password')) + + self.assertTrue(fetch_ldap_groups_function.called, + 'As part of authentication function fetch_ldap_groups_function needs to be called') + + invalid_users = [ + {'prefix_posix.user': 'posix_user_password'}, + {'posix.user_postfix': 'posix_user_password'}, + {'posix.user': 'posix_user_password_postfix'}, + {'posix.user': 'prefix_posix_user_password'}, + {'posix.user': ''}, + {'': 'posix_user_password'}, + {'': ''} + ] # All invalid users should return 'invalid username or password' + + for username, password in enumerate(invalid_users): + + with self.assertRaises(frappe.exceptions.ValidationError) as display_massage: + + self.test_class.authenticate(username, password) + + self.assertTrue(str(display_massage.exception).lower() == 'invalid username or password', + 'invalid credentials passed authentication [user: {0}, password: {1}]'.format(username, password)) + + + @mock_ldap_connection + def test_complex_ldap_search_filter(self): + + ldap_search_filters = self.TEST_VALUES_LDAP_COMPLEX_SEARCH_STRING + + for search_filter in ldap_search_filters: + + self.test_class.ldap_search_string = search_filter + + if 'ACCESS:test3' in search_filter: # posix.user does not have str in ldap.description auth should fail + + with self.assertRaises(frappe.exceptions.ValidationError) as display_massage: + + self.test_class.authenticate('posix.user', 'posix_user_password') + + self.assertTrue(str(display_massage.exception).lower() == 'invalid username or password') + + else: + self.assertTrue(self.test_class.authenticate('posix.user', 'posix_user_password')) + + + def test_reset_password(self): + + self.test_class = LDAPSettings(self.doc) + + # Create a clean doc + localdoc = self.doc.copy() + + localdoc['enabled'] = False + frappe.get_doc(localdoc).save() + + with mock.patch('frappe.integrations.doctype.ldap_settings.ldap_settings.LDAPSettings.connect_to_ldap') as connect_to_ldap: + connect_to_ldap.return_value = self.connection + + with self.assertRaises(frappe.exceptions.ValidationError) as validation: # Fail if username string used + self.test_class.reset_password('posix.user', 'posix_user_password') + + self.assertTrue(str(validation.exception) == 'No LDAP User found for email: posix.user') + + try: + self.test_class.reset_password('posix.user1@unit.testing', 'posix_user_password') # Change Password + + except Exception: # An exception from the tested class is ok, as long as the connection to LDAP was made writeable + pass + + connect_to_ldap.assert_called_with(self.base_dn, self.base_password, read_only=False) + + + @mock_ldap_connection + def test_convert_ldap_entry_to_dict(self): + + self.connection.search( + search_base=self.ldap_user_path, + search_filter=self.TEST_LDAP_SEARCH_STRING.format("posix.user"), + attributes=self.test_class.get_ldap_attributes()) + + test_ldap_entry = self.connection.entries[0] + + method_return = self.test_class.convert_ldap_entry_to_dict(test_ldap_entry) + + self.assertTrue(type(method_return) is dict) # must be dict + self.assertTrue(len(method_return) == 6) # there are 6 fields in mock_ldap for use + + + +class Test_OpenLDAP(LDAP_TestCase, unittest.TestCase): + TEST_LDAP_SERVER = 'OpenLDAP' + TEST_LDAP_SEARCH_STRING = '(uid={0})' + DOCUMENT_GROUP_MAPPINGS = [ + { + "doctype": "LDAP Group Mapping", + "ldap_group": "Administrators", + "erpnext_role": "System Manager" + }, + { + "doctype": "LDAP Group Mapping", + "ldap_group": "Users", + "erpnext_role": "Blogger" + }, + { + "doctype": "LDAP Group Mapping", + "ldap_group": "Group3", + "erpnext_role": "Accounts User" + } + ] + LDAP_USERNAME_FIELD = 'uid' + LDAP_SCHEMA = OFFLINE_SLAPD_2_4 + LDAP_LDIF_JSON = 'test_data_ldif_openldap.json' + + TEST_VALUES_LDAP_COMPLEX_SEARCH_STRING = [ + '(uid={0})', + '(&(objectclass=posixaccount)(uid={0}))', + '(&(description=*ACCESS:test1*)(uid={0}))', # OpenLDAP has no member of group, use description to filter posix.user has equivilent of AD 'memberOf' + '(&(objectclass=posixaccount)(description=*ACCESS:test3*)(uid={0}))' # OpenLDAP has no member of group, use description to filter posix.user doesn't have. equivilent of AD 'memberOf' + ] + + +class Test_ActiveDirectory(LDAP_TestCase, unittest.TestCase): + TEST_LDAP_SERVER = 'Active Directory' + TEST_LDAP_SEARCH_STRING = '(samaccountname={0})' + DOCUMENT_GROUP_MAPPINGS = [ + { + "doctype": "LDAP Group Mapping", + "ldap_group": "Domain Administrators", + "erpnext_role": "System Manager" + }, + { + "doctype": "LDAP Group Mapping", + "ldap_group": "Domain Users", + "erpnext_role": "Blogger" + }, + { + "doctype": "LDAP Group Mapping", + "ldap_group": "Enterprise Administrators", + "erpnext_role": "Accounts User" + } + ] + LDAP_USERNAME_FIELD = 'samaccountname' + LDAP_SCHEMA = OFFLINE_AD_2012_R2 + LDAP_LDIF_JSON = 'test_data_ldif_activedirectory.json' + + TEST_VALUES_LDAP_COMPLEX_SEARCH_STRING = [ + '(samaccountname={0})', + '(&(objectclass=user)(samaccountname={0}))', + '(&(description=*ACCESS:test1*)(samaccountname={0}))', # OpenLDAP has no member of group, use description to filter posix.user has equivilent of AD 'memberOf' + '(&(objectclass=user)(description=*ACCESS:test3*)(samaccountname={0}))' # OpenLDAP has no member of group, use description to filter posix.user doesn't have. equivilent of AD 'memberOf' + ] -class TestLDAPSettings(unittest.TestCase): - pass diff --git a/frappe/integrations/doctype/oauth_authorization_code/oauth_authorization_code.py b/frappe/integrations/doctype/oauth_authorization_code/oauth_authorization_code.py index 0c7f02844c..5a3f380e84 100644 --- a/frappe/integrations/doctype/oauth_authorization_code/oauth_authorization_code.py +++ b/frappe/integrations/doctype/oauth_authorization_code/oauth_authorization_code.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/integrations/doctype/oauth_authorization_code/test_oauth_authorization_code.py b/frappe/integrations/doctype/oauth_authorization_code/test_oauth_authorization_code.py index 6084dd64b4..bc6d29cbdb 100644 --- a/frappe/integrations/doctype/oauth_authorization_code/test_oauth_authorization_code.py +++ b/frappe/integrations/doctype/oauth_authorization_code/test_oauth_authorization_code.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/integrations/doctype/oauth_bearer_token/oauth_bearer_token.py b/frappe/integrations/doctype/oauth_bearer_token/oauth_bearer_token.py index 916d0205d2..ff6f96cc4d 100644 --- a/frappe/integrations/doctype/oauth_bearer_token/oauth_bearer_token.py +++ b/frappe/integrations/doctype/oauth_bearer_token/oauth_bearer_token.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/integrations/doctype/oauth_bearer_token/test_oauth_bearer_token.py b/frappe/integrations/doctype/oauth_bearer_token/test_oauth_bearer_token.py index 6028cebcf9..965feb4f78 100644 --- a/frappe/integrations/doctype/oauth_bearer_token/test_oauth_bearer_token.py +++ b/frappe/integrations/doctype/oauth_bearer_token/test_oauth_bearer_token.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/integrations/doctype/oauth_client/oauth_client.py b/frappe/integrations/doctype/oauth_client/oauth_client.py index 0b449ff968..42fba07ecb 100644 --- a/frappe/integrations/doctype/oauth_client/oauth_client.py +++ b/frappe/integrations/doctype/oauth_client/oauth_client.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe import _ diff --git a/frappe/integrations/doctype/oauth_client/test_oauth_client.py b/frappe/integrations/doctype/oauth_client/test_oauth_client.py index a4e50e15d8..fa03fa06e7 100644 --- a/frappe/integrations/doctype/oauth_client/test_oauth_client.py +++ b/frappe/integrations/doctype/oauth_client/test_oauth_client.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/integrations/doctype/oauth_provider_settings/oauth_provider_settings.py b/frappe/integrations/doctype/oauth_provider_settings/oauth_provider_settings.py index 3ab5df92ac..ec1636659f 100644 --- a/frappe/integrations/doctype/oauth_provider_settings/oauth_provider_settings.py +++ b/frappe/integrations/doctype/oauth_provider_settings/oauth_provider_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/integrations/doctype/oauth_scope/oauth_scope.py b/frappe/integrations/doctype/oauth_scope/oauth_scope.py index ae579e6b51..cf5fa1f341 100644 --- a/frappe/integrations/doctype/oauth_scope/oauth_scope.py +++ b/frappe/integrations/doctype/oauth_scope/oauth_scope.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/integrations/doctype/paypal_settings/paypal_settings.py b/frappe/integrations/doctype/paypal_settings/paypal_settings.py index da045d2c6a..30ac905792 100644 --- a/frappe/integrations/doctype/paypal_settings/paypal_settings.py +++ b/frappe/integrations/doctype/paypal_settings/paypal_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE """ # Integrating PayPal diff --git a/frappe/integrations/doctype/paytm_settings/paytm_settings.py b/frappe/integrations/doctype/paytm_settings/paytm_settings.py index 9f15d73f09..5255360242 100644 --- a/frappe/integrations/doctype/paytm_settings/paytm_settings.py +++ b/frappe/integrations/doctype/paytm_settings/paytm_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import json import requests diff --git a/frappe/integrations/doctype/paytm_settings/test_paytm_settings.py b/frappe/integrations/doctype/paytm_settings/test_paytm_settings.py index a00ce86327..425fc87a3f 100644 --- a/frappe/integrations/doctype/paytm_settings/test_paytm_settings.py +++ b/frappe/integrations/doctype/paytm_settings/test_paytm_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE # import frappe import unittest diff --git a/frappe/integrations/doctype/query_parameters/query_parameters.py b/frappe/integrations/doctype/query_parameters/query_parameters.py index 13fb94dbe3..68e97e9071 100644 --- a/frappe/integrations/doctype/query_parameters/query_parameters.py +++ b/frappe/integrations/doctype/query_parameters/query_parameters.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/integrations/doctype/razorpay_settings/razorpay_settings.py b/frappe/integrations/doctype/razorpay_settings/razorpay_settings.py index d24e15f480..9bbab9db9b 100644 --- a/frappe/integrations/doctype/razorpay_settings/razorpay_settings.py +++ b/frappe/integrations/doctype/razorpay_settings/razorpay_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2015, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE """ # Integrating RazorPay @@ -371,6 +371,7 @@ def capture_payment(is_sandbox=False, sanbox_response=None): doc = frappe.get_doc("Integration Request", doc.name) doc.status = "Failed" doc.error = frappe.get_traceback() + doc.save() frappe.log_error(doc.error, '{0} Failed'.format(doc.name)) 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 1346811652..dc824e18b9 100755 --- a/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.py +++ b/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import os import os.path import frappe diff --git a/frappe/integrations/doctype/s3_backup_settings/test_s3_backup_settings.py b/frappe/integrations/doctype/s3_backup_settings/test_s3_backup_settings.py index 3aecdf3489..2a586c30d4 100755 --- a/frappe/integrations/doctype/s3_backup_settings/test_s3_backup_settings.py +++ b/frappe/integrations/doctype/s3_backup_settings/test_s3_backup_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import unittest class TestS3BackupSettings(unittest.TestCase): diff --git a/frappe/integrations/doctype/slack_webhook_url/slack_webhook_url.py b/frappe/integrations/doctype/slack_webhook_url/slack_webhook_url.py index a970fc1f11..a74c0a36ca 100644 --- a/frappe/integrations/doctype/slack_webhook_url/slack_webhook_url.py +++ b/frappe/integrations/doctype/slack_webhook_url/slack_webhook_url.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2018, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/integrations/doctype/slack_webhook_url/test_slack_webhook_url.py b/frappe/integrations/doctype/slack_webhook_url/test_slack_webhook_url.py index 4285c2c4bc..a256735f81 100644 --- a/frappe/integrations/doctype/slack_webhook_url/test_slack_webhook_url.py +++ b/frappe/integrations/doctype/slack_webhook_url/test_slack_webhook_url.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2018, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import unittest class TestSlackWebhookURL(unittest.TestCase): 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 4a4fcd44f4..195d6800be 100644 --- a/frappe/integrations/doctype/social_login_key/social_login_key.py +++ b/frappe/integrations/doctype/social_login_key/social_login_key.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe, json from frappe import _ @@ -80,7 +80,9 @@ class SocialLoginKey(Document): "redirect_url":"/api/method/frappe.www.login.login_via_github", "api_endpoint":"user", "api_endpoint_args":None, - "auth_url_data":None + "auth_url_data": json.dumps({ + "scope": "user:email" + }) } providers["Google"] = { 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 23effd6a44..73e6a072cb 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 @@ -1,9 +1,15 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe from frappe.integrations.doctype.social_login_key.social_login_key import BaseUrlNotSetError import unittest +from frappe.utils.oauth import login_via_oauth2 +from unittest.mock import patch, MagicMock +from rauth import OAuth2Service +from frappe.auth import LoginManager, CookieManager +from frappe.utils import set_request + class TestSocialLoginKey(unittest.TestCase): def test_adding_frappe_social_login_provider(self): @@ -14,6 +20,41 @@ class TestSocialLoginKey(unittest.TestCase): social_login_key.get_social_login_provider(provider_name, initialize=True) self.assertRaises(BaseUrlNotSetError, social_login_key.insert) + def test_github_login_with_private_email(self): + github_social_login_setup() + + mock_session = MagicMock() + mock_session.get.side_effect = github_response_for_private_email + + with patch.object(OAuth2Service, "get_auth_session", return_value=mock_session): + login_via_oauth2("github", "iwriu", {"token": "ewrwerwer"}) # Dummy code and state token + + def test_github_login_with_public_email(self): + github_social_login_setup() + + mock_session = MagicMock() + mock_session.get.side_effect = github_response_for_public_email + + with patch.object(OAuth2Service, "get_auth_session", return_value=mock_session): + login_via_oauth2("github", "iwriu", {"token": "ewrwerwer"}) # Dummy code and state token + + def test_normal_signup_and_github_login(self): + github_social_login_setup() + + if not frappe.db.exists("User", "githublogin@example.com"): + user = frappe.get_doc({ + "doctype": "User", + "email": "githublogin@example.com", + "first_name": "GitHub Login" + }) + user.save(ignore_permissions=True) + + mock_session = MagicMock() + mock_session.get.side_effect = github_response_for_login + + with patch.object(OAuth2Service, "get_auth_session", return_value=mock_session): + login_via_oauth2("github", "iwriu", {"token": "ewrwerwer"}) + def make_social_login_key(**kwargs): kwargs["doctype"] = "Social Login Key" if not "provider_name" in kwargs: @@ -34,3 +75,48 @@ def create_or_update_social_login_key(): frappe.db.commit() return social_login_key + +def create_github_social_login_key(): + if frappe.db.exists("Social Login Key", "github"): + return frappe.get_doc("Social Login Key", "github") + else: + provider_name = "GitHub" + social_login_key = make_social_login_key( + social_login_provider=provider_name + ) + social_login_key.get_social_login_provider(provider_name, initialize=True) + + # Dummy client_id and client_secret + social_login_key.client_id = "h6htd6q" + social_login_key.client_secret = "keoererk988ekkhf8w9e8ewrjhhkjer9889" + social_login_key.insert(ignore_permissions=True) + return social_login_key + +def github_response_for_private_email(url, *args, **kwargs): + if url == "user": + return_value = {"login": "dummy_username", "id": "223342", "email": None, "first_name": "Github Private"} + else: + return_value = [{"email": "github@example.com", "primary": True, "verified": True}] + + return MagicMock(status_code=200, json=MagicMock(return_value=return_value)) + +def github_response_for_public_email(url, *args, **kwargs): + if url == "user": + return_value = {"login": "dummy_username", "id": "223343", "email": "github_public@example.com", "first_name": "Github Public"} + + return MagicMock(status_code=200, json=MagicMock(return_value=return_value)) + +def github_response_for_login(url, *args, **kwargs): + if url == "user": + return_value = {"login": "dummy_username", "id": "223346", "email": None, "first_name": "Github Login"} + else: + return_value = [{"email": "githublogin@example.com", "primary": True, "verified": True}] + + return MagicMock(status_code=200, json=MagicMock(return_value=return_value)) + +def github_social_login_setup(): + set_request(path="/random") + frappe.local.cookie_manager = CookieManager() + frappe.local.login_manager = LoginManager() + + create_github_social_login_key() diff --git a/frappe/integrations/doctype/stripe_settings/stripe_settings.py b/frappe/integrations/doctype/stripe_settings/stripe_settings.py index 9bb9c60775..81e40fa72f 100644 --- a/frappe/integrations/doctype/stripe_settings/stripe_settings.py +++ b/frappe/integrations/doctype/stripe_settings/stripe_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/integrations/doctype/stripe_settings/test_stripe_settings.py b/frappe/integrations/doctype/stripe_settings/test_stripe_settings.py index ba11c3c38b..e7113d3bd9 100644 --- a/frappe/integrations/doctype/stripe_settings/test_stripe_settings.py +++ b/frappe/integrations/doctype/stripe_settings/test_stripe_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2018, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import unittest class TestStripeSettings(unittest.TestCase): diff --git a/frappe/integrations/doctype/token_cache/test_token_cache.py b/frappe/integrations/doctype/token_cache/test_token_cache.py index 2ffd57403b..5fe648d225 100644 --- a/frappe/integrations/doctype/token_cache/test_token_cache.py +++ b/frappe/integrations/doctype/token_cache/test_token_cache.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# See license.txt +# License: MIT. See LICENSE import unittest import frappe diff --git a/frappe/integrations/doctype/token_cache/token_cache.py b/frappe/integrations/doctype/token_cache/token_cache.py index 3001d12b2b..ea86100cc2 100644 --- a/frappe/integrations/doctype/token_cache/token_cache.py +++ b/frappe/integrations/doctype/token_cache/token_cache.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE from datetime import datetime, timedelta diff --git a/frappe/integrations/doctype/webhook/__init__.py b/frappe/integrations/doctype/webhook/__init__.py index b92497f16c..6dcc0218a3 100644 --- a/frappe/integrations/doctype/webhook/__init__.py +++ b/frappe/integrations/doctype/webhook/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe diff --git a/frappe/integrations/doctype/webhook/test_webhook.py b/frappe/integrations/doctype/webhook/test_webhook.py index 09ad56a190..a1176aa38b 100644 --- a/frappe/integrations/doctype/webhook/test_webhook.py +++ b/frappe/integrations/doctype/webhook/test_webhook.py @@ -1,17 +1,19 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import unittest import frappe -from frappe.integrations.doctype.webhook.webhook import get_webhook_headers, get_webhook_data +from frappe.integrations.doctype.webhook.webhook import get_webhook_headers, get_webhook_data, enqueue_webhook class TestWebhook(unittest.TestCase): @classmethod def setUpClass(cls): # delete any existing webhooks - frappe.db.sql("DELETE FROM tabWebhook") + frappe.db.delete("Webhook") + # Delete existing logs if any + frappe.db.delete("Webhook Request Log") # create test webhooks cls.create_sample_webhooks() @@ -44,7 +46,7 @@ class TestWebhook(unittest.TestCase): @classmethod def tearDownClass(cls): # delete any existing webhooks - frappe.db.sql("DELETE FROM tabWebhook") + frappe.db.delete("Webhook") def setUp(self): # retrieve or create a User webhook for `after_insert` @@ -162,3 +164,18 @@ class TestWebhook(unittest.TestCase): data = get_webhook_data(doc=self.user, webhook=self.webhook) self.assertEqual(data, {"name": self.user.name}) + + def test_webhook_req_log_creation(self): + if not frappe.db.get_value('User', 'user2@integration.webhooks.test.com'): + user = frappe.get_doc({ + 'doctype': 'User', + 'email': 'user2@integration.webhooks.test.com', + 'first_name': 'user2' + }).insert() + else: + user = frappe.get_doc('User', 'user2@integration.webhooks.test.com') + + webhook = frappe.get_doc('Webhook', {'webhook_doctype': 'User'}) + enqueue_webhook(user, webhook) + + self.assertTrue(frappe.db.get_all('Webhook Request Log', pluck='name')) \ No newline at end of file diff --git a/frappe/integrations/doctype/webhook/webhook.json b/frappe/integrations/doctype/webhook/webhook.json index 85895c052c..880874cb25 100644 --- a/frappe/integrations/doctype/webhook/webhook.json +++ b/frappe/integrations/doctype/webhook/webhook.json @@ -18,6 +18,7 @@ "html_condition", "sb_webhook", "request_url", + "request_method", "cb_webhook", "request_structure", "sb_security", @@ -154,10 +155,18 @@ "fieldname": "enabled", "fieldtype": "Check", "label": "Enabled" + }, + { + "default": "POST", + "fieldname": "request_method", + "fieldtype": "Select", + "label": "Request Method", + "options": "POST\nPUT\nDELETE", + "reqd": 1 } ], "links": [], - "modified": "2021-04-14 05:35:28.532049", + "modified": "2021-05-25 11:11:28.555291", "modified_by": "Administrator", "module": "Integrations", "name": "Webhook", diff --git a/frappe/integrations/doctype/webhook/webhook.py b/frappe/integrations/doctype/webhook/webhook.py index 1fb2bc6743..8546a9d2f8 100644 --- a/frappe/integrations/doctype/webhook/webhook.py +++ b/frappe/integrations/doctype/webhook/webhook.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import base64 import datetime @@ -59,7 +59,6 @@ class Webhook(Document): if self.request_structure == "Form URL-Encoded": self.webhook_json = None elif self.request_structure == "JSON": - validate_json(self.webhook_json) validate_template(self.webhook_json) self.webhook_data = [] @@ -83,18 +82,32 @@ def enqueue_webhook(doc, webhook): for i in range(3): try: - r = requests.post(webhook.request_url, data=json.dumps(data, default=str), headers=headers, timeout=5) + r = requests.request(method=webhook.request_method, url=webhook.request_url, + data=json.dumps(data, default=str), headers=headers, timeout=5) r.raise_for_status() frappe.logger().debug({"webhook_success": r.text}) + log_request(webhook.request_url, headers, data, r) break except Exception as e: frappe.logger().debug({"webhook_error": e, "try": i + 1}) + log_request(webhook.request_url, headers, data, r) sleep(3 * i + 1) if i != 2: continue else: raise e +def log_request(url, headers, data, res): + request_log = frappe.get_doc({ + "doctype": "Webhook Request Log", + "user": frappe.session.user if frappe.session.user else None, + "url": url, + "headers": json.dumps(headers, indent=4) if headers else None, + "data": json.dumps(data, indent=4) if isinstance(data, dict) else data, + "response": json.dumps(res.json(), indent=4) if res else None + }) + + request_log.save(ignore_permissions=True) def get_webhook_headers(doc, webhook): headers = {} @@ -129,10 +142,3 @@ def get_webhook_data(doc, webhook): data = json.loads(data) return data - - -def validate_json(string): - try: - json.loads(string) - except (TypeError, ValueError): - frappe.throw(_("Request Body consists of an invalid JSON structure"), title=_("Invalid JSON")) diff --git a/frappe/integrations/doctype/webhook_data/webhook_data.py b/frappe/integrations/doctype/webhook_data/webhook_data.py index dbd9328482..6037ed5390 100644 --- a/frappe/integrations/doctype/webhook_data/webhook_data.py +++ b/frappe/integrations/doctype/webhook_data/webhook_data.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/integrations/doctype/webhook_header/webhook_header.py b/frappe/integrations/doctype/webhook_header/webhook_header.py index 428b287db2..e1944c84bc 100644 --- a/frappe/integrations/doctype/webhook_header/webhook_header.py +++ b/frappe/integrations/doctype/webhook_header/webhook_header.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE # import frappe from frappe.model.document import Document diff --git a/frappe/core/doctype/data_import_legacy/__init__.py b/frappe/integrations/doctype/webhook_request_log/__init__.py similarity index 100% rename from frappe/core/doctype/data_import_legacy/__init__.py rename to frappe/integrations/doctype/webhook_request_log/__init__.py diff --git a/frappe/integrations/doctype/webhook_request_log/test_webhook_request_log.py b/frappe/integrations/doctype/webhook_request_log/test_webhook_request_log.py new file mode 100644 index 0000000000..5de26a35ed --- /dev/null +++ b/frappe/integrations/doctype/webhook_request_log/test_webhook_request_log.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies and Contributors +# License: MIT. See LICENSE + +# import frappe +import unittest + +class TestWebhookRequestLog(unittest.TestCase): + pass diff --git a/frappe/integrations/doctype/webhook_request_log/webhook_request_log.js b/frappe/integrations/doctype/webhook_request_log/webhook_request_log.js new file mode 100644 index 0000000000..9ec4f11536 --- /dev/null +++ b/frappe/integrations/doctype/webhook_request_log/webhook_request_log.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Webhook Request Log', { + // refresh: function(frm) { + + // } +}); diff --git a/frappe/integrations/doctype/webhook_request_log/webhook_request_log.json b/frappe/integrations/doctype/webhook_request_log/webhook_request_log.json new file mode 100644 index 0000000000..96690f6e8c --- /dev/null +++ b/frappe/integrations/doctype/webhook_request_log/webhook_request_log.json @@ -0,0 +1,81 @@ +{ + "actions": [], + "autoname": "WEBHOOK-REQ-.#####", + "creation": "2021-05-24 21:35:59.104776", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "user", + "headers", + "data", + "column_break_4", + "url", + "response" + ], + "fields": [ + { + "fieldname": "url", + "fieldtype": "Data", + "label": "URL", + "read_only": 1 + }, + { + "fieldname": "headers", + "fieldtype": "Code", + "label": "Headers", + "options": "JSON", + "read_only": 1 + }, + { + "fieldname": "response", + "fieldtype": "Code", + "label": "Response", + "options": "JSON", + "read_only": 1 + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fieldname": "data", + "fieldtype": "Code", + "label": "Data", + "options": "JSON", + "read_only": 1 + }, + { + "fieldname": "user", + "fieldtype": "Link", + "label": "User", + "options": "User", + "read_only": 1 + } + ], + "in_create": 1, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-05-26 23:57:58.495261", + "modified_by": "Administrator", + "module": "Integrations", + "name": "Webhook Request Log", + "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/integrations/doctype/webhook_request_log/webhook_request_log.py b/frappe/integrations/doctype/webhook_request_log/webhook_request_log.py new file mode 100644 index 0000000000..3f0558ce80 --- /dev/null +++ b/frappe/integrations/doctype/webhook_request_log/webhook_request_log.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies and contributors +# License: MIT. See LICENSE + +# import frappe +from frappe.model.document import Document + +class WebhookRequestLog(Document): + pass diff --git a/frappe/integrations/oauth2_logins.py b/frappe/integrations/oauth2_logins.py index c38b43beb7..b187d29b34 100644 --- a/frappe/integrations/oauth2_logins.py +++ b/frappe/integrations/oauth2_logins.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe import frappe.utils diff --git a/frappe/integrations/offsite_backup_utils.py b/frappe/integrations/offsite_backup_utils.py index 7a263e9d04..416d656d90 100644 --- a/frappe/integrations/offsite_backup_utils.py +++ b/frappe/integrations/offsite_backup_utils.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe import glob diff --git a/frappe/integrations/utils.py b/frappe/integrations/utils.py index 09c20568b5..bda45a765d 100644 --- a/frappe/integrations/utils.py +++ b/frappe/integrations/utils.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe import json,datetime @@ -8,35 +8,14 @@ from urllib.parse import parse_qs from frappe.utils import get_request_session from frappe import _ -def make_get_request(url, auth=None, headers=None, data=None): - if not auth: - auth = '' - if not data: - data = {} - if not headers: - headers = {} +def make_request(method, url, auth=None, headers=None, data=None): + auth = auth or '' + data = data or {} + headers = headers or {} try: s = get_request_session() - frappe.flags.integration_request = s.get(url, data={}, auth=auth, headers=headers) - frappe.flags.integration_request.raise_for_status() - return frappe.flags.integration_request.json() - - except Exception as exc: - frappe.log_error(frappe.get_traceback()) - raise exc - -def make_post_request(url, auth=None, headers=None, data=None): - if not auth: - auth = '' - if not data: - data = {} - if not headers: - headers = {} - - try: - s = get_request_session() - frappe.flags.integration_request = s.post(url, data=data, auth=auth, headers=headers) + frappe.flags.integration_request = s.request(method, url, data=data, auth=auth, headers=headers) frappe.flags.integration_request.raise_for_status() if frappe.flags.integration_request.headers.get("content-type") == "text/plain; charset=utf-8": @@ -47,6 +26,15 @@ def make_post_request(url, auth=None, headers=None, data=None): frappe.log_error() raise exc +def make_get_request(url, **kwargs): + return make_request('GET', url, **kwargs) + +def make_post_request(url, **kwargs): + return make_request('POST', url, **kwargs) + +def make_put_request(url, **kwargs): + return make_request('PUT', url, **kwargs) + def create_request_log(data, integration_type, service_name, name=None, error=None): if isinstance(data, str): data = json.loads(data) diff --git a/frappe/integrations/workspace/integrations/integrations.json b/frappe/integrations/workspace/integrations/integrations.json index db96304207..b85056e3ef 100644 --- a/frappe/integrations/workspace/integrations/integrations.json +++ b/frappe/integrations/workspace/integrations/integrations.json @@ -1,22 +1,20 @@ { - "category": "Administration", "charts": [], + "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Backup\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Google Services\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Authentication\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Payments\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Settings\", \"col\": 4}}]", "creation": "2020-03-02 15:16:18.714190", - "developer_mode_only": 0, - "disable_user_customization": 1, "docstatus": 0, "doctype": "Workspace", - "extends_another_page": 0, + "for_user": "", "hide_custom": 0, "icon": "integration", "idx": 0, - "is_standard": 1, "label": "Integrations", "links": [ { "hidden": 0, "is_query_report": 0, "label": "Backup", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -25,6 +23,7 @@ "hidden": 0, "is_query_report": 0, "label": "Dropbox Settings", + "link_count": 0, "link_to": "Dropbox Settings", "link_type": "DocType", "onboard": 0, @@ -35,6 +34,7 @@ "hidden": 0, "is_query_report": 0, "label": "S3 Backup Settings", + "link_count": 0, "link_to": "S3 Backup Settings", "link_type": "DocType", "onboard": 0, @@ -45,6 +45,7 @@ "hidden": 0, "is_query_report": 0, "label": "Google Drive", + "link_count": 0, "link_to": "Google Drive", "link_type": "DocType", "onboard": 0, @@ -54,6 +55,7 @@ "hidden": 0, "is_query_report": 0, "label": "Google Services", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -62,6 +64,7 @@ "hidden": 0, "is_query_report": 0, "label": "Google Settings", + "link_count": 0, "link_to": "Google Settings", "link_type": "DocType", "onboard": 0, @@ -72,6 +75,7 @@ "hidden": 0, "is_query_report": 0, "label": "Google Contacts", + "link_count": 0, "link_to": "Google Contacts", "link_type": "DocType", "onboard": 0, @@ -82,6 +86,7 @@ "hidden": 0, "is_query_report": 0, "label": "Google Calendar", + "link_count": 0, "link_to": "Google Calendar", "link_type": "DocType", "onboard": 0, @@ -92,6 +97,7 @@ "hidden": 0, "is_query_report": 0, "label": "Google Drive", + "link_count": 0, "link_to": "Google Drive", "link_type": "DocType", "onboard": 0, @@ -101,6 +107,7 @@ "hidden": 0, "is_query_report": 0, "label": "Authentication", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -109,6 +116,7 @@ "hidden": 0, "is_query_report": 0, "label": "Social Login Key", + "link_count": 0, "link_to": "Social Login Key", "link_type": "DocType", "onboard": 0, @@ -119,6 +127,7 @@ "hidden": 0, "is_query_report": 0, "label": "LDAP Settings", + "link_count": 0, "link_to": "LDAP Settings", "link_type": "DocType", "onboard": 0, @@ -129,6 +138,7 @@ "hidden": 0, "is_query_report": 0, "label": "OAuth Client", + "link_count": 0, "link_to": "OAuth Client", "link_type": "DocType", "onboard": 0, @@ -139,6 +149,7 @@ "hidden": 0, "is_query_report": 0, "label": "OAuth Provider Settings", + "link_count": 0, "link_to": "OAuth Provider Settings", "link_type": "DocType", "onboard": 0, @@ -148,6 +159,7 @@ "hidden": 0, "is_query_report": 0, "label": "Payments", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -156,6 +168,7 @@ "hidden": 0, "is_query_report": 0, "label": "Braintree Settings", + "link_count": 0, "link_to": "Braintree Settings", "link_type": "DocType", "onboard": 0, @@ -166,6 +179,7 @@ "hidden": 0, "is_query_report": 0, "label": "PayPal Settings", + "link_count": 0, "link_to": "PayPal Settings", "link_type": "DocType", "onboard": 0, @@ -176,6 +190,7 @@ "hidden": 0, "is_query_report": 0, "label": "Razorpay Settings", + "link_count": 0, "link_to": "Razorpay Settings", "link_type": "DocType", "onboard": 0, @@ -186,6 +201,7 @@ "hidden": 0, "is_query_report": 0, "label": "Stripe Settings", + "link_count": 0, "link_to": "Stripe Settings", "link_type": "DocType", "onboard": 0, @@ -196,6 +212,7 @@ "hidden": 0, "is_query_report": 0, "label": "Paytm Settings", + "link_count": 0, "link_to": "Paytm Settings", "link_type": "DocType", "onboard": 0, @@ -205,6 +222,7 @@ "hidden": 0, "is_query_report": 0, "label": "Settings", + "link_count": 0, "onboard": 0, "type": "Card Break" }, @@ -213,6 +231,7 @@ "hidden": 0, "is_query_report": 0, "label": "Webhook", + "link_count": 0, "link_to": "Webhook", "link_type": "DocType", "onboard": 0, @@ -223,38 +242,34 @@ "hidden": 0, "is_query_report": 0, "label": "Slack Webhook URL", + "link_count": 0, "link_to": "Slack Webhook URL", "link_type": "DocType", "onboard": 0, "type": "Link" }, - { - "dependencies": "", - "hidden": 0, - "is_query_report": 0, - "label": "Twilio Settings", - "link_to": "Twilio Settings", - "link_type": "DocType", - "onboard": 0, - "type": "Link" - }, { "dependencies": "", "hidden": 0, "is_query_report": 0, "label": "SMS Settings", + "link_count": 0, "link_to": "SMS Settings", "link_type": "DocType", "onboard": 0, "type": "Link" } ], - "modified": "2020-12-01 13:38:39.706680", + "modified": "2021-08-05 12:16:00.355268", "modified_by": "Administrator", "module": "Integrations", "name": "Integrations", "owner": "Administrator", - "pin_to_bottom": 0, - "pin_to_top": 0, - "shortcuts": [] + "parent_page": "", + "public": 1, + "restrict_to_domain": "", + "roles": [], + "sequence_id": 15, + "shortcuts": [], + "title": "Integrations" } \ No newline at end of file diff --git a/frappe/middlewares.py b/frappe/middlewares.py index 05944ec37a..38cb4cea21 100644 --- a/frappe/middlewares.py +++ b/frappe/middlewares.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe import os diff --git a/frappe/migrate.py b/frappe/migrate.py index d19e255639..6abc38796f 100644 --- a/frappe/migrate.py +++ b/frappe/migrate.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import json import os @@ -13,11 +13,12 @@ from frappe.utils.connections import check_connection from frappe.utils.dashboard import sync_dashboards from frappe.cache_manager import clear_global_cache from frappe.desk.notifications import clear_notifications -from frappe.website import render +from frappe.website.utils import clear_website_cache from frappe.core.doctype.language.language import sync_languages from frappe.modules.utils import sync_customizations from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs from frappe.search.website_search import build_index_for_all_routes +from frappe.database.schema import add_column def migrate(verbose=True, skip_failing=False, skip_search_index=False): @@ -26,9 +27,10 @@ def migrate(verbose=True, skip_failing=False, skip_search_index=False): - run patches - sync doctypes (schema) - sync dashboards + - sync jobs - sync fixtures - - sync desktop icons - - sync web pages (from /www) + - sync customizations + - sync languages - sync web pages (from /www) - run after migrate hooks ''' @@ -51,6 +53,7 @@ Otherwise, check the server logs and ensure that all the required services are r os.remove(touched_tables_file) try: + add_column(doctype="DocType", column_name="migration_hash", fieldtype="Data") frappe.flags.touched_tables = set() frappe.flags.in_migrate = True @@ -65,7 +68,7 @@ Otherwise, check the server logs and ensure that all the required services are r frappe.modules.patch_handler.run_all(skip_failing) # sync - frappe.model.sync.sync_all(verbose=verbose) + frappe.model.sync.sync_all() frappe.translate.clear_cache() sync_jobs() sync_fixtures() @@ -76,7 +79,7 @@ Otherwise, check the server logs and ensure that all the required services are r frappe.get_doc('Portal Settings', 'Portal Settings').sync_menu() # syncs statics - render.clear_cache() + clear_website_cache() # updating installed applications data frappe.get_single('Installed Applications').update_versions() diff --git a/frappe/model/__init__.py b/frappe/model/__init__.py index 75122f5aba..b460db29a7 100644 --- a/frappe/model/__init__.py +++ b/frappe/model/__init__.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE # model __init__.py import frappe @@ -34,12 +34,14 @@ data_fieldtypes = ( 'Color', 'Barcode', 'Geolocation', - 'Duration' + 'Duration', + 'Icon' ) no_value_fields = ( 'Section Break', 'Column Break', + 'Tab Break', 'HTML', 'Table', 'Table MultiSelect', @@ -52,6 +54,7 @@ no_value_fields = ( display_fieldtypes = ( 'Section Break', 'Column Break', + 'Tab Break', 'HTML', 'Button', 'Image', @@ -71,7 +74,8 @@ data_field_options = ( 'Email', 'Name', 'Phone', - 'URL' + 'URL', + 'Barcode' ) default_fields = ( @@ -152,32 +156,22 @@ def delete_fields(args_dict, delete=0): if not fields: continue - frappe.db.sql(""" - DELETE FROM `tabDocField` - WHERE parent='%s' AND fieldname IN (%s) - """ % (dt, ", ".join(["'{}'".format(f) for f in fields]))) + frappe.db.delete("DocField", { + "parent": dt, + "fieldname": ("in", fields), + }) # Delete the data/column only if delete is specified if not delete: continue if frappe.db.get_value("DocType", dt, "issingle"): - frappe.db.sql(""" - DELETE FROM `tabSingles` - WHERE doctype='%s' AND field IN (%s) - """ % (dt, ", ".join("'{}'".format(f) for f in fields))) + frappe.db.delete("Singles", { + "doctype": dt, + "field": ("in", fields), + }) else: - existing_fields = frappe.db.multisql({ - "mariadb": "DESC `tab%s`" % dt, - "postgres": """ - SELECT - COLUMN_NAME - FROM - information_schema.COLUMNS - WHERE - TABLE_NAME = 'tab%s'; - """ % dt, - }) + existing_fields = frappe.db.describe(dt) existing_fields = existing_fields and [e[0] for e in existing_fields] or [] fields_need_to_delete = set(fields) & set(existing_fields) if not fields_need_to_delete: diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index af696e116d..1826cca9a3 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe import datetime from frappe import _ @@ -83,11 +83,15 @@ class BaseDocument(object): @property def meta(self): - if not hasattr(self, "_meta"): + if not getattr(self, "_meta", None): self._meta = frappe.get_meta(self.doctype) return self._meta + def __getstate__(self): + self._meta = None + return self.__dict__ + def update(self, d): """ Update multiple fields of a doctype using a dictionary of key-value pairs. @@ -263,7 +267,12 @@ class BaseDocument(object): if isinstance(d[fieldname], list) and df.fieldtype not in table_fields: frappe.throw(_('Value for {0} cannot be a list').format(_(df.label))) - if convert_dates_to_str and isinstance(d[fieldname], (datetime.datetime, datetime.time, datetime.timedelta)): + if convert_dates_to_str and isinstance(d[fieldname], ( + datetime.datetime, + datetime.date, + datetime.time, + datetime.timedelta + )): d[fieldname] = str(d[fieldname]) if d[fieldname] == None and ignore_nulls: @@ -303,7 +312,7 @@ class BaseDocument(object): doc["doctype"] = self.doctype for df in self.meta.get_table_fields(): children = self.get(df.fieldname) or [] - doc[df.fieldname] = [d.as_dict(convert_dates_to_str=convert_dates_to_str, no_nulls=no_nulls) for d in children] + doc[df.fieldname] = [d.as_dict(convert_dates_to_str=convert_dates_to_str, no_nulls=no_nulls, no_default_fields=no_default_fields) for d in children] if no_nulls: for k in list(doc): @@ -723,6 +732,18 @@ class BaseDocument(object): if abs(cint(value)) > max_length: self.throw_length_exceeded_error(df, max_length, value) + def _validate_code_fields(self): + for field in self.meta.get_code_fields(): + code_string = self.get(field.fieldname) + language = field.get("options") + + if language == "Python": + frappe.utils.validate_python_code(code_string, fieldname=field.label, is_expression=False) + + elif language == "PythonExpression": + frappe.utils.validate_python_code(code_string, fieldname=field.label) + + def throw_length_exceeded_error(self, df, max_length, value): if self.parentfield and self.idx: reference = _("{0}, Row {1}").format(_(self.doctype), self.idx) @@ -858,7 +879,7 @@ class BaseDocument(object): return self._precision[cache_key][fieldname] - def get_formatted(self, fieldname, doc=None, currency=None, absolute_value=False, translated=False): + def get_formatted(self, fieldname, doc=None, currency=None, absolute_value=False, translated=False, format=None): from frappe.utils.formatters import format_value df = self.meta.get_field(fieldname) @@ -882,7 +903,7 @@ class BaseDocument(object): 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) + return format_value(val, df=df, doc=doc, currency=currency, format=format) def is_print_hide(self, fieldname, df=None, for_print=True): """Returns true if fieldname is to be hidden for print. @@ -953,7 +974,7 @@ class BaseDocument(object): return self.cast(val, df) def cast(self, value, df): - return cast_fieldtype(df.fieldtype, value) + return cast_fieldtype(df.fieldtype, value, show_warning=False) def _extract_images_from_text_editor(self): from frappe.core.doctype.file.file import extract_images_from_doc diff --git a/frappe/model/create_new.py b/frappe/model/create_new.py index fba6765479..fff2156a10 100644 --- a/frappe/model/create_new.py +++ b/frappe/model/create_new.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE """ Create a new document with defaults set diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index 7ed681644f..6181832363 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -1,8 +1,10 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE """build query for doclistview and return results""" +from typing import List import frappe.defaults +from frappe.query_builder.utils import Column import frappe.share from frappe import _ import frappe.permissions @@ -33,10 +35,10 @@ class DatabaseQuery(object): join='left join', distinct=False, start=None, page_length=None, limit=None, ignore_ifnull=False, save_user_settings=False, save_user_settings_fields=False, update=None, add_total_row=None, user_settings=None, reference_doctype=None, - return_query=False, strict=True, pluck=None, ignore_ddl=False): + run=True, strict=True, pluck=None, ignore_ddl=False, parent_doctype=None) -> List: if not ignore_permissions and \ - not frappe.has_permission(self.doctype, "select", user=user) and \ - not frappe.has_permission(self.doctype, "read", user=user): + not frappe.has_permission(self.doctype, "select", user=user, parent_doctype=parent_doctype) and \ + not frappe.has_permission(self.doctype, "read", user=user, parent_doctype=parent_doctype): frappe.flags.error_message = _('Insufficient Permission for {0}').format(frappe.bold(self.doctype)) raise frappe.PermissionError(self.doctype) @@ -85,7 +87,7 @@ class DatabaseQuery(object): self.user = user or frappe.session.user self.update = update self.user_settings_fields = copy.deepcopy(self.fields) - self.return_query = return_query + self.run = run self.strict = strict self.ignore_ddl = ignore_ddl @@ -102,8 +104,6 @@ class DatabaseQuery(object): if not self.columns: return [] result = self.build_and_run() - if return_query: - return result if with_comment_count and not as_list and self.doctype: self.add_comment_count(result) @@ -135,11 +135,8 @@ class DatabaseQuery(object): %(order_by)s %(limit)s""" % args - if self.return_query: - return query - else: - return frappe.db.sql(query, as_dict=not self.as_list, debug=self.debug, - update=self.update, ignore_ddl=self.ignore_ddl) + return frappe.db.sql(query, as_dict=not self.as_list, debug=self.debug, + update=self.update, ignore_ddl=self.ignore_ddl, run=self.run) def prepare_args(self): self.parse_args() @@ -321,7 +318,8 @@ class DatabaseQuery(object): doctype = table_name[4:-1] 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): + if not self.flags.ignore_permissions and \ + not frappe.has_permission(doctype, ptype=ptype, parent_doctype=self.doctype): frappe.flags.error_message = _('Insufficient Permission for {0}').format(frappe.bold(doctype)) raise frappe.PermissionError(doctype) @@ -492,7 +490,7 @@ class DatabaseQuery(object): if f.operator in ('>', '<') and (f.fieldname in ('creation', 'modified')): value = cstr(f.value) - fallback = "NULL" + fallback = "'0001-01-01 00:00:00'" elif f.operator.lower() in ('between') and \ (f.fieldname in ('creation', 'modified') or (df and (df.fieldtype=="Date" or df.fieldtype=="Datetime"))): @@ -546,8 +544,13 @@ class DatabaseQuery(object): value = flt(f.value) fallback = 0 + if isinstance(f.value, Column): + can_be_null = False # added to avoid the ifnull/coalesce addition + quote = '"' if frappe.conf.db_type == 'postgres' else "`" + value = f"{tname}.{quote}{f.value.name}{quote}" + # escape value - if isinstance(value, str) and not f.operator.lower() == 'between': + elif isinstance(value, str) and not f.operator.lower() == 'between': value = f"{frappe.db.escape(value, percent=False)}" if ( @@ -591,8 +594,8 @@ class DatabaseQuery(object): self.conditions.append(self.get_share_condition()) else: - #if has if_owner permission skip user perm check - if role_permissions.get("has_if_owner_enabled") and role_permissions.get("if_owner", {}): + # skip user perm check if owner constraint is required + if requires_owner_constraint(role_permissions): self.match_conditions.append( f"`tab{self.doctype}`.`owner` = {frappe.db.escape(self.user, percent=False)}" ) @@ -889,3 +892,22 @@ def get_date_range(operator, value): timespan = period_map[operator] + ' ' + timespan_map[value] if operator != 'timespan' else value return get_timespan_date_range(timespan) + +def requires_owner_constraint(role_permissions): + """Returns True if "select" or "read" isn't available without being creator.""" + + if not role_permissions.get("has_if_owner_enabled"): + return + + if_owner_perms = role_permissions.get("if_owner") + if not if_owner_perms: + return + + # has select or read without if owner, no need for constraint + for perm_type in ("select", "read"): + if role_permissions.get(perm_type) and perm_type not in if_owner_perms: + return + + # not checking if either select or read if present in if_owner_perms + # because either of those is required to perform a query + return True diff --git a/frappe/model/delete_doc.py b/frappe/model/delete_doc.py index cc88cfa106..ac976e976c 100644 --- a/frappe/model/delete_doc.py +++ b/frappe/model/delete_doc.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import os import shutil @@ -10,7 +10,7 @@ import frappe.model.meta from frappe import _ from frappe import get_module_path from frappe.model.dynamic_links import get_dynamic_link_map -from frappe.core.doctype.file.file import remove_all +from frappe.utils.file_manager import remove_all from frappe.utils.password import delete_all_passwords_for from frappe.model.naming import revert_series_if_last from frappe.utils.global_search import delete_for_document @@ -65,12 +65,12 @@ def delete_doc(doctype=None, name=None, force=0, ignore_doctypes=None, for_reloa update_flags(doc, flags, ignore_permissions) check_permission_and_not_submitted(doc) - frappe.db.sql("delete from `tabCustom Field` 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) - frappe.db.sql("delete from `__global_search` where doctype=%s", name) + frappe.db.delete("Custom Field", {"dt": name}) + frappe.db.delete("Client Script", {"dt": name}) + frappe.db.delete("Property Setter", {"doc_type": name}) + frappe.db.delete("Report", {"ref_doctype": name}) + frappe.db.delete("Custom DocPerm", {"parent": name}) + frappe.db.delete("__global_search", {"doctype": name}) delete_from_table(doctype, name, ignore_doctypes, None) @@ -162,10 +162,9 @@ def update_naming_series(doc): def delete_from_table(doctype, name, ignore_doctypes, doc): if doctype!="DocType" and doctype==name: - frappe.db.sql("delete from `tabSingles` where `doctype`=%s", name) + frappe.db.delete("Singles", {"doctype": name}) else: - frappe.db.sql("delete from `tab{0}` where `name`=%s".format(doctype), name) - + frappe.db.delete(doctype, {"name": name}) # get child tables if doc: tables = [d.options for d in doc.meta.get_table_fields()] @@ -191,7 +190,7 @@ def delete_from_table(doctype, name, ignore_doctypes, doc): # delete from child tables for t in list(set(tables)): if t not in ignore_doctypes: - frappe.db.sql("delete from `tab%s` where parenttype=%s and parent = %s" % (t, '%s', '%s'), (doctype, name)) + frappe.db.delete(t, {"parenttype": doctype, "parent": name}) def update_flags(doc, flags=None, ignore_permissions=False): if ignore_permissions: @@ -324,9 +323,10 @@ def delete_dynamic_links(doctype, name): def delete_references(doctype, reference_doctype, reference_name, reference_doctype_field = 'reference_doctype', reference_name_field = 'reference_name'): - frappe.db.sql('''delete from `tab{0}` - where {1}=%s and {2}=%s'''.format(doctype, reference_doctype_field, reference_name_field), # nosec - (reference_doctype, reference_name)) + frappe.db.delete(doctype, { + reference_doctype_field: reference_doctype, + reference_name_field: reference_name + }) def clear_references(doctype, reference_doctype, reference_name, reference_doctype_field = 'reference_doctype', reference_name_field = 'reference_name'): @@ -339,8 +339,10 @@ def clear_references(doctype, reference_doctype, reference_name, (reference_doctype, reference_name)) def clear_timeline_references(link_doctype, link_name): - frappe.db.sql("""DELETE FROM `tabCommunication Link` - WHERE `tabCommunication Link`.link_doctype=%s AND `tabCommunication Link`.link_name=%s""", (link_doctype, link_name)) + frappe.db.delete("Communication Link", { + "link_doctype": link_doctype, + "link_name": link_name + }) def insert_feed(doc): if ( diff --git a/frappe/model/docfield.py b/frappe/model/docfield.py index 6360c3866d..c173561b1e 100644 --- a/frappe/model/docfield.py +++ b/frappe/model/docfield.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE """docfield utililtes""" diff --git a/frappe/model/document.py b/frappe/model/document.py index 61160e1f01..411d447d0f 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -1,11 +1,11 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe import time from frappe import _, msgprint, is_whitelisted from frappe.utils import flt, cstr, now, get_datetime_str, file_lock, date_diff from frappe.model.base_document import BaseDocument, get_controller -from frappe.model.naming import set_new_name +from frappe.model.naming import set_new_name, gen_new_name_for_cancelled_doc from werkzeug.exceptions import NotFound, Forbidden import hashlib, json from frappe.model import optional_fields, table_fields @@ -385,15 +385,15 @@ class Document(BaseDocument): [self.name, self.doctype, fieldname] + rows) if len(deleted_rows) > 0: # delete rows that do not match the ones in the document - frappe.db.sql("""delete from `tab{0}` where name in ({1})""".format(df.options, - ','.join(['%s'] * len(deleted_rows))), tuple(row[0] for row in deleted_rows)) + frappe.db.delete(df.options, {"name": ("in", tuple(row[0] for row in deleted_rows))}) else: # no rows found, delete all rows - frappe.db.sql("""delete from `tab{0}` where parent=%s - and parenttype=%s and parentfield=%s""".format(df.options), - (self.name, self.doctype, fieldname)) - + frappe.db.delete(df.options, { + "parent": self.name, + "parenttype": self.doctype, + "parentfield": fieldname + }) def get_doc_before_save(self): return getattr(self, '_doc_before_save', None) @@ -451,7 +451,9 @@ class Document(BaseDocument): def update_single(self, d): """Updates values for Single type Document in `tabSingles`.""" - frappe.db.sql("""delete from `tabSingles` where doctype=%s""", self.doctype) + frappe.db.delete("Singles", { + "doctype": self.doctype + }) for field, value in d.items(): if field != "doctype": frappe.db.sql("""insert into `tabSingles` (doctype, field, value) @@ -492,6 +494,7 @@ class Document(BaseDocument): self._validate_selects() self._validate_non_negative() self._validate_length() + self._validate_code_fields() self._extract_images_from_text_editor() self._sanitize_content() self._save_passwords() @@ -503,6 +506,7 @@ class Document(BaseDocument): d._validate_selects() d._validate_non_negative() d._validate_length() + d._validate_code_fields() d._extract_images_from_text_editor() d._sanitize_content() d._save_passwords() @@ -705,7 +709,6 @@ class Document(BaseDocument): else: tmp = frappe.db.sql("""select modified, docstatus from `tab{0}` where name = %s for update""".format(self.doctype), self.name, as_dict=True) - if not tmp: frappe.throw(_("Record does not exist")) else: @@ -916,8 +919,12 @@ class Document(BaseDocument): @whitelist.__func__ def _cancel(self): - """Cancel the document. Sets `docstatus` = 2, then saves.""" + """Cancel the document. Sets `docstatus` = 2, then saves. + """ self.docstatus = 2 + new_name = gen_new_name_for_cancelled_doc(self) + frappe.rename_doc(self.doctype, self.name, new_name, force=True, show_alert=False) + self.name = new_name self.save() @whitelist.__func__ @@ -1060,7 +1067,10 @@ class Document(BaseDocument): self.set("modified", now()) self.set("modified_by", frappe.session.user) - self.load_doc_before_save() + # load but do not reload doc_before_save because before_change or on_change might expect it + if not self.get_doc_before_save(): + self.load_doc_before_save() + # to trigger notification on value change self.run_method('before_change') diff --git a/frappe/model/dynamic_links.py b/frappe/model/dynamic_links.py index 676c86d7da..7311b39b30 100644 --- a/frappe/model/dynamic_links.py +++ b/frappe/model/dynamic_links.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe diff --git a/frappe/model/mapper.py b/frappe/model/mapper.py index fa8858d950..bde4fb6d73 100644 --- a/frappe/model/mapper.py +++ b/frappe/model/mapper.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import json import frappe diff --git a/frappe/model/meta.py b/frappe/model/meta.py index b212324208..cd0d8e0f3a 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE # metadata @@ -15,8 +15,9 @@ Example: ''' from datetime import datetime +import click import frappe, json, os -from frappe.utils import cstr, cint, cast_fieldtype +from frappe.utils import cstr, cint, cast 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 @@ -141,6 +142,9 @@ class Meta(Document): def get_image_fields(self): return self.get("fields", {"fieldtype": "Attach Image"}) + def get_code_fields(self): + return self.get("fields", {"fieldtype": "Code"}) + def get_set_only_once_fields(self): '''Return fields with `set_only_once` set''' if not hasattr(self, "_set_only_once_fields"): @@ -319,24 +323,24 @@ class Meta(Document): for ps in property_setters: if ps.doctype_or_field=='DocType': - self.set(ps.property, cast_fieldtype(ps.property_type, ps.value)) + self.set(ps.property, cast(ps.property_type, ps.value)) 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)) + d.set(ps.property, cast(ps.property_type, ps.value)) break 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)) + d.set(ps.property, cast(ps.property_type, ps.value)) break 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)) + d.set(ps.property, cast(ps.property_type, ps.value)) break def add_custom_links_and_actions(self): @@ -504,6 +508,9 @@ class Meta(Document): if not data.non_standard_fieldnames: data.non_standard_fieldnames = {} + if not data.internal_links: + data.internal_links = {} + for link in dashboard_links: link.added = False if link.hidden: @@ -511,24 +518,32 @@ class Meta(Document): for group in data.transactions: group = frappe._dict(group) + + # For internal links parent doctype will be the key + doctype = link.parent_doctype or link.link_doctype # group found if link.group and group.label == link.group: - if link.link_doctype not in group.get('items'): - group.get('items').append(link.link_doctype) + if doctype not in group.get('items'): + group.get('items').append(doctype) link.added = True if not link.added: # group not found, make a new group data.transactions.append(dict( label = link.group, - items = [link.link_doctype] + items = [link.parent_doctype or link.link_doctype] )) - if link.link_fieldname != data.fieldname: - if data.fieldname: - data.non_standard_fieldnames[link.link_doctype] = link.link_fieldname - else: + if not link.is_child_table: + if link.link_fieldname != data.fieldname: + if data.fieldname: + data.non_standard_fieldnames[link.link_doctype] = link.link_fieldname + else: + data.fieldname = link.link_fieldname + elif link.is_child_table: + if not data.fieldname: data.fieldname = link.link_fieldname + data.internal_links[link.parent_doctype] = [link.table_fieldname, link.link_fieldname] def get_row_template(self): @@ -644,27 +659,48 @@ def get_default_df(fieldname): fieldtype = "Data" ) -def trim_tables(doctype=None): +def trim_tables(doctype=None, dry_run=False, quiet=False): """ Removes database fields that don't exist in the doctype (json or custom field). This may be needed as maintenance since removing a field in a DocType doesn't automatically delete the db field. """ - ignore_fields = default_fields + optional_fields - - filters={ "issingle": 0 } + UPDATED_TABLES = {} + filters = {"issingle": 0} if doctype: filters["name"] = doctype - for doctype in frappe.db.get_all("DocType", filters=filters): - doctype = doctype.name - columns = frappe.db.get_table_columns(doctype) - fields = frappe.get_meta(doctype).get_fieldnames_with_value() - columns_to_remove = [f for f in list(set(columns) - set(fields)) if f not in ignore_fields - and not f.startswith("_")] - if columns_to_remove: - print(doctype, "columns removed:", columns_to_remove) - columns_to_remove = ", ".join("drop `{0}`".format(c) for c in columns_to_remove) - query = """alter table `tab{doctype}` {columns}""".format( - doctype=doctype, columns=columns_to_remove) - frappe.db.sql_ddl(query) + for doctype in frappe.db.get_all("DocType", filters=filters, pluck="name"): + try: + dropped_columns = trim_table(doctype, dry_run=dry_run) + if dropped_columns: + UPDATED_TABLES[doctype] = dropped_columns + except frappe.db.TableMissingError: + if quiet: + continue + click.secho(f"Ignoring missing table for DocType: {doctype}", fg="yellow", err=True) + click.secho(f"Consider removing record in the DocType table for {doctype}", fg="yellow", err=True) + except Exception as e: + if quiet: + continue + click.echo(e, err=True) + + return UPDATED_TABLES + + +def trim_table(doctype, dry_run=True): + frappe.cache().hdel('table_columns', f"tab{doctype}") + ignore_fields = default_fields + optional_fields + columns = frappe.db.get_table_columns(doctype) + fields = frappe.get_meta(doctype, cached=False).get_fieldnames_with_value() + is_internal = lambda f: f not in ignore_fields and not f.startswith("_") + columns_to_remove = [ + f for f in list(set(columns) - set(fields)) if is_internal(f) + ] + DROPPED_COLUMNS = columns_to_remove[:] + + if columns_to_remove and not dry_run: + columns_to_remove = ", ".join(f"DROP `{c}`" for c in columns_to_remove) + frappe.db.sql_ddl(f"ALTER TABLE `tab{doctype}` {columns_to_remove}") + + return DROPPED_COLUMNS diff --git a/frappe/model/naming.py b/frappe/model/naming.py index fe136adce8..deea6698b3 100644 --- a/frappe/model/naming.py +++ b/frappe/model/naming.py @@ -1,11 +1,23 @@ +"""utilities to generate a document name based on various rules defined. + +NOTE: +Till version 13, whenever a submittable document is amended it's name is set to orig_name-X, +where X is a counter and it increments when amended again and so on. + +From Version 14, The naming pattern is changed in a way that amended documents will +have the original name `orig_name` instead of `orig_name-X`. To make this happen +the cancelled document naming pattern is changed to 'orig_name-CANC-X'. +""" + # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe from frappe import _ from frappe.utils import now_datetime, cint, cstr import re from frappe.model import log_types +from frappe.query_builder import DocType def set_new_name(doc): @@ -28,7 +40,7 @@ def set_new_name(doc): doc.name = None if getattr(doc, "amended_from", None): - _set_amended_name(doc) + doc.name = _get_amended_name(doc) return elif getattr(doc.meta, "issingle", False): @@ -183,7 +195,15 @@ def parse_naming_series(parts, doctype='', doc=''): def getseries(key, digits): # series created ? - current = frappe.db.sql("SELECT `current` FROM `tabSeries` WHERE `name`=%s FOR UPDATE", (key,)) + # Using frappe.qb as frappe.get_values does not allow order_by=None + series = DocType("Series") + current = ( + frappe.qb.from_(series) + .where(series.name == key) + .for_update() + .select("current") + ).run() + if current and current[0][0] is not None: current = current[0][0] # yes, update it @@ -221,6 +241,18 @@ def revert_series_if_last(key, name, doc=None): * prefix = #### and hashes = 2021 (hash doesn't exist) * will search hash in key then accordingly get prefix = "" """ + if hasattr(doc, 'amended_from'): + # Do not revert the series if the document is amended. + if doc.amended_from: + return + + # Get document name by parsing incase of fist cancelled document + if doc.docstatus == 2 and not doc.amended_from: + if doc.name.endswith('-CANC'): + name, _ = NameParser.parse_docname(doc.name, sep='-CANC') + else: + name, _ = NameParser.parse_docname(doc.name, sep='-CANC-') + if ".#" in key: prefix, hashes = key.rsplit(".", 1) if "#" not in hashes: @@ -237,7 +269,13 @@ def revert_series_if_last(key, name, doc=None): prefix = parse_naming_series(prefix.split('.'), doc=doc) count = cint(name.replace(prefix, "")) - current = frappe.db.sql("SELECT `current` FROM `tabSeries` WHERE `name`=%s FOR UPDATE", (prefix,)) + series = DocType("Series") + current = ( + frappe.qb.from_(series) + .where(series.name == prefix) + .for_update() + .select("current") + ).run() if current and current[0][0]==count: frappe.db.sql("UPDATE `tabSeries` SET `current` = `current` - 1 WHERE `name`=%s", prefix) @@ -303,16 +341,9 @@ def append_number_if_name_exists(doctype, value, fieldname="name", separator="-" return value -def _set_amended_name(doc): - am_id = 1 - am_prefix = doc.amended_from - if frappe.db.get_value(doc.doctype, doc.amended_from, "amended_from"): - am_id = cint(doc.amended_from.split("-")[-1]) + 1 - am_prefix = "-".join(doc.amended_from.split("-")[:-1]) # except the last hyphen - - doc.name = am_prefix + "-" + str(am_id) - return doc.name - +def _get_amended_name(doc): + name, _ = NameParser(doc).parse_amended_from() + return name def _field_autoname(autoname, doc, skip_slicing=None): """ @@ -323,7 +354,6 @@ def _field_autoname(autoname, doc, skip_slicing=None): name = (cstr(doc.get(fieldname)) or "").strip() return name - def _prompt_autoname(autoname, doc): """ Generate a name using Prompt option. This simply means the user will have to set the name manually. @@ -331,7 +361,7 @@ def _prompt_autoname(autoname, doc): """ # set from __newname in save.py if not doc.name: - frappe.throw(_("Name not set via prompt")) + frappe.throw(_("Please set the document name")) def _format_autoname(autoname, doc): """ @@ -354,3 +384,83 @@ def _format_autoname(autoname, doc): name = re.sub(r"(\{[\w | #]+\})", get_param_value_for_match, autoname_value) return name + +class NameParser: + """Parse document name and return parts of it. + + NOTE: It handles cancellend and amended doc parsing for now. It can be expanded. + """ + def __init__(self, doc): + self.doc = doc + + def parse_amended_from(self): + """ + Cancelled document naming will be in one of these formats + + * original_name-X-CANC - This is introduced to migrate old style naming to new style + * original_name-CANC - This is introduced to migrate old style naming to new style + * original_name-CANC-X - This is the new style naming + + New style naming: In new style naming amended documents will have original name. That says, + when a document gets cancelled we need rename the document by adding `-CANC-X` to the end + so that amended documents can use the original name. + + Old style naming: cancelled documents stay with original name and when amended, amended one + gets a new name as `original_name-X`. To bring new style naming we had to change the existing + cancelled document names and that is done by adding `-CANC` to cancelled documents through patch. + """ + if not getattr(self.doc, 'amended_from', None): + return (None, None) + + # Handle old style cancelled documents (original_name-X-CANC, original_name-CANC) + if self.doc.amended_from.endswith('-CANC'): + name, _ = self.parse_docname(self.doc.amended_from, '-CANC') + amended_from_doc = frappe.get_all( + self.doc.doctype, + filters = {'name': self.doc.amended_from}, + fields = ['amended_from'], + limit=1) + + # Handle format original_name-X-CANC. + if amended_from_doc and amended_from_doc[0].amended_from: + return self.parse_docname(name, '-') + return name, None + + # Handle new style cancelled documents + return self.parse_docname(self.doc.amended_from, '-CANC-') + + @classmethod + def parse_docname(cls, name, sep='-'): + split_list = name.rsplit(sep, 1) + + if len(split_list) == 1: + return (name, None) + return (split_list[0], split_list[1]) + +def get_cancelled_doc_latest_counter(tname, docname): + """Get the latest counter used for cancelled docs of given docname. + """ + name_prefix = f'{docname}-CANC-' + + rows = frappe.db.sql(""" + select + name + from `tab{tname}` + where + name like %(name_prefix)s and docstatus=2 + """.format(tname=tname), {'name_prefix': name_prefix+'%'}, as_dict=1) + + if not rows: + return -1 + return max([int(row.name.replace(name_prefix, '') or -1) for row in rows]) + +def gen_new_name_for_cancelled_doc(doc): + """Generate a new name for cancelled document. + """ + if getattr(doc, "amended_from", None): + name, _ = NameParser(doc).parse_amended_from() + else: + name = doc.name + + counter = get_cancelled_doc_latest_counter(doc.doctype, name) + return f'{name}-CANC-{counter+1}' diff --git a/frappe/model/rename_doc.py b/frappe/model/rename_doc.py index 9b8ac2574d..ee9044b73e 100644 --- a/frappe/model/rename_doc.py +++ b/frappe/model/rename_doc.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe from frappe import _, bold from frappe.model.dynamic_links import get_dynamic_link_map @@ -7,6 +7,7 @@ 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 +from frappe.query_builder import Field @frappe.whitelist() @@ -191,8 +192,14 @@ def update_autoname_field(doctype, new, meta): def validate_rename(doctype, new, meta, merge, force, ignore_permissions): # using for update so that it gets locked and someone else cannot edit it while this rename is going on! - exists = frappe.db.sql("select name from `tab{doctype}` where name=%s for update".format(doctype=doctype), new) - exists = exists[0][0] if exists else None + exists = ( + frappe.qb.from_(doctype) + .where(Field("name") == new) + .for_update() + .select("name") + .run(pluck=True) + ) + exists = exists[0] if exists else None if merge and not exists: frappe.msgprint(_("{0} {1} does not exist, select a new target to merge").format(doctype, new), raise_exception=1) @@ -458,7 +465,7 @@ def bulk_rename(doctype, rows=None, via_console = False): """Bulk rename documents :param doctype: DocType to be renamed - :param rows: list of documents as `((oldname, newname), ..)`""" + :param rows: list of documents as `((oldname, newname, merge(optional)), ..)`""" if not rows: frappe.throw(_("Please select a valid csv file with data")) @@ -471,8 +478,9 @@ def bulk_rename(doctype, rows=None, via_console = False): for row in rows: # if row has some content if len(row) > 1 and row[0] and row[1]: + merge = len(row) > 2 and (row[2] == "1" or row[2].lower() == "true") try: - if rename_doc(doctype, row[0], row[1]): + if rename_doc(doctype, row[0], row[1], merge=merge): msg = _("Successful: {0} to {1}").format(row[0], row[1]) frappe.db.commit() else: diff --git a/frappe/model/sync.py b/frappe/model/sync.py index 28f9deb25d..42bb16cbc2 100644 --- a/frappe/model/sync.py +++ b/frappe/model/sync.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE """ Sync's doctype and docfields from txt files to database perms will get synced only if none exist @@ -10,62 +10,67 @@ from frappe.modules.import_file import import_file_by_path from frappe.modules.patch_handler import block_user from frappe.utils import update_progress_bar -def sync_all(force=0, verbose=False, reset_permissions=False): + +def sync_all(force=0, reset_permissions=False): block_user(True) for app in frappe.get_installed_apps(): - sync_for(app, force, verbose=verbose, reset_permissions=reset_permissions) + sync_for(app, force, reset_permissions=reset_permissions) block_user(False) frappe.clear_cache() -def sync_for(app_name, force=0, sync_everything = False, verbose=False, reset_permissions=False): + +def sync_for(app_name, force=0, reset_permissions=False): files = [] if app_name == "frappe": # these need to go first at time of install - for d in (("core", "docfield"), - ("core", "docperm"), - ("core", "doctype_action"), - ("core", "doctype_link"), - ("core", "role"), - ("core", "has_role"), - ("core", "doctype"), - ("core", "user"), - ("custom", "custom_field"), - ("custom", "property_setter"), - ("website", "web_form"), - ("website", "web_template"), - ("website", "web_form_field"), - ("website", "portal_menu_item"), - ("data_migration", "data_migration_mapping_detail"), - ("data_migration", "data_migration_mapping"), - ("data_migration", "data_migration_plan_mapping"), - ("data_migration", "data_migration_plan"), - ("desk", "number_card"), - ("desk", "dashboard_chart"), - ("desk", "dashboard"), - ("desk", "onboarding_permission"), - ("desk", "onboarding_step"), - ("desk", "onboarding_step_map"), - ("desk", "module_onboarding"), - ("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")) + + FRAPPE_PATH = frappe.get_app_path("frappe") + + for core_module in ["docfield", "docperm", "doctype_action", "doctype_link", "role", "has_role", "doctype"]: + files.append(os.path.join(FRAPPE_PATH, "core", "doctype", core_module, f"{core_module}.json")) + + for custom_module in ["custom_field", "property_setter"]: + files.append(os.path.join(FRAPPE_PATH, "custom", "doctype", custom_module, f"{custom_module}.json")) + + for website_module in ["web_form", "web_template", "web_form_field", "portal_menu_item"]: + files.append(os.path.join(FRAPPE_PATH, "website", "doctype", website_module, f"{website_module}.json")) + + for data_migration_module in [ + "data_migration_mapping_detail", + "data_migration_mapping", + "data_migration_plan_mapping", + "data_migration_plan", + ]: + files.append(os.path.join(FRAPPE_PATH, "data_migration", "doctype", data_migration_module, f"{data_migration_module}.json")) + + for desk_module in [ + "number_card", + "dashboard_chart", + "dashboard", + "onboarding_permission", + "onboarding_step", + "onboarding_step_map", + "module_onboarding", + "workspace_link", + "workspace_chart", + "workspace_shortcut", + "workspace", + ]: + files.append(os.path.join(FRAPPE_PATH, "desk", "doctype", desk_module, f"{desk_module}.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) + files = get_doc_files(files=files, start_path=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) + import_file_by_path(doc_path, force=force, ignore_version=True, reset_permissions=reset_permissions) frappe.db.commit() @@ -75,15 +80,36 @@ 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): """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', 'workspace', - 'onboarding_step', 'module_onboarding'] + files = files or [] + # load in sequence - warning for devs + document_types = [ + "doctype", + "page", + "report", + "dashboard_chart_source", + "print_format", + "web_page", + "website_theme", + "web_form", + "web_template", + "notification", + "print_style", + "data_migration_mapping", + "data_migration_plan", + "workspace", + "onboarding_step", + "module_onboarding", + "form_tour", + "client_script", + "server_script", + "custom_field", + "property_setter", + ] for doctype in document_types: doctype_path = os.path.join(start_path, doctype) if os.path.exists(doctype_path): @@ -93,3 +119,5 @@ def get_doc_files(files, start_path): if os.path.exists(doc_path): if not doc_path in files: files.append(doc_path) + + return files diff --git a/frappe/model/utils/__init__.py b/frappe/model/utils/__init__.py index 47615182e4..4cdca5e394 100644 --- a/frappe/model/utils/__init__.py +++ b/frappe/model/utils/__init__.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe from frappe import _ from frappe.utils import cstr diff --git a/frappe/model/utils/link_count.py b/frappe/model/utils/link_count.py index 7562aaae45..404b6ec855 100644 --- a/frappe/model/utils/link_count.py +++ b/frappe/model/utils/link_count.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe diff --git a/frappe/model/utils/rename_field.py b/frappe/model/utils/rename_field.py index 9fe9d64041..c9c454b7e8 100644 --- a/frappe/model/utils/rename_field.py +++ b/frappe/model/utils/rename_field.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe import json from frappe.model import no_value_fields, table_fields diff --git a/frappe/model/workflow.py b/frappe/model/workflow.py index fa2f557370..e74d88c0f2 100644 --- a/frappe/model/workflow.py +++ b/frappe/model/workflow.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe from frappe.utils import cint diff --git a/frappe/modules.txt b/frappe/modules.txt index ae10c3ad55..a707ca853e 100644 --- a/frappe/modules.txt +++ b/frappe/modules.txt @@ -9,7 +9,6 @@ Integrations Printing Contacts Data Migration -Chat Social Automation Event Streaming \ No newline at end of file diff --git a/frappe/modules/export_file.py b/frappe/modules/export_file.py index ae9f11d53b..ab6ffd4985 100644 --- a/frappe/modules/export_file.py +++ b/frappe/modules/export_file.py @@ -1,12 +1,12 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe, os import frappe.model from frappe.modules import scrub, get_module_path, scrub_dt_dn def export_doc(doc): - export_to_files([[doc.doctype, doc.name]]) + write_document_file(doc) def export_to_files(record_list=None, record_module=None, verbose=0, create_init=None): """ @@ -21,16 +21,10 @@ def export_to_files(record_list=None, record_module=None, verbose=0, create_init write_document_file(frappe.get_doc(record[0], record[1]), record_module, create_init=create_init, folder_name=folder_name) def write_document_file(doc, record_module=None, create_init=True, folder_name=None): - newdoc = doc.as_dict(no_nulls=True) - doc.run_method("before_export", newdoc) - - # strip out default fields from children - for df in doc.meta.get_table_fields(): - for d in newdoc.get(df.fieldname): - for fieldname in frappe.model.default_fields: - if fieldname in d: - del d[fieldname] + doc_export = doc.as_dict(no_nulls=True) + doc.run_method("before_export", doc_export) + doc_export = strip_default_fields(doc, doc_export) module = record_module or get_module_name(doc) # create folder @@ -39,10 +33,36 @@ def write_document_file(doc, record_module=None, create_init=True, folder_name=N else: folder = create_folder(module, doc.doctype, doc.name, create_init) - # write the data file fname = scrub(doc.name) + write_code_files(folder, fname, doc, doc_export) + + # write the data file with open(os.path.join(folder, fname + ".json"), 'w+') as txtfile: - txtfile.write(frappe.as_json(newdoc)) + txtfile.write(frappe.as_json(doc_export)) + +def strip_default_fields(doc, doc_export): + # strip out default fields from children + if doc.doctype == "DocType" and doc.migration_hash: + del doc_export["migration_hash"] + + for df in doc.meta.get_table_fields(): + for d in doc_export.get(df.fieldname): + for fieldname in frappe.model.default_fields: + if fieldname in d: + del d[fieldname] + + return doc_export + +def write_code_files(folder, fname, doc, doc_export): + '''Export code files and strip from values''' + if hasattr(doc, 'get_code_fields'): + for key, extn in doc.get_code_fields().items(): + if doc.get(key): + with open(os.path.join(folder, fname + "." + extn), 'w+') as txtfile: + txtfile.write(doc.get(key)) + + # remove from exporting + del doc_export[key] def get_module_name(doc): if doc.doctype == 'Module Def': @@ -57,7 +77,10 @@ def get_module_name(doc): return module def create_folder(module, dt, dn, create_init): - module_path = get_module_path(module) + if frappe.db.get_value('Module Def', module, 'custom'): + module_path = get_custom_module_path(module) + else: + module_path = get_module_path(module) dt, dn = scrub_dt_dn(dt, dn) @@ -72,6 +95,23 @@ def create_folder(module, dt, dn, create_init): return folder +def get_custom_module_path(module): + package = frappe.db.get_value('Module Def', module, 'package') + if not package: + frappe.throw('Package must be set for custom Module {module}'.format(module=module)) + + path = os.path.join(get_package_path(package), scrub(module)) + if not os.path.exists(path): + os.makedirs(path) + + return path + +def get_package_path(package): + path = os.path.join(frappe.get_site_path('packages'), frappe.db.get_value('Package', package, 'package_name')) + if not os.path.exists(path): + os.makedirs(path) + return path + def create_init_py(module_path, dt, dn): def create_if_not_exists(path): initpy = os.path.join(path, '__init__.py') diff --git a/frappe/modules/import_file.py b/frappe/modules/import_file.py index e743f0c3da..cf8ec46d76 100644 --- a/frappe/modules/import_file.py +++ b/frappe/modules/import_file.py @@ -1,31 +1,53 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt -import frappe, os, json -from frappe.modules import get_module_path, scrub_dt_dn -from frappe.utils import get_datetime_str +# License: MIT. See LICENSE +import hashlib +import json +import os + +import frappe from frappe.model.base_document import get_controller +from frappe.modules import get_module_path, scrub_dt_dn +from frappe.query_builder import DocType +from frappe.utils import get_datetime_str, now + + +def caclulate_hash(path: str) -> str: + """Calculate md5 hash of the file in binary mode + + Args: + path (str): Path to the file to be hashed + + Returns: + str: The calculated hash + """ + hash_md5 = hashlib.md5() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(4096), b""): + hash_md5.update(chunk) + return hash_md5.hexdigest() + ignore_values = { "Report": ["disabled", "prepared_report", "add_total_row"], "Print Format": ["disabled"], "Notification": ["enabled"], "Print Style": ["disabled"], - "Module Onboarding": ['is_complete'], - "Onboarding Step": ['is_complete', 'is_skipped'] + "Module Onboarding": ["is_complete"], + "Onboarding Step": ["is_complete", "is_skipped"], } ignore_doctypes = [""] + def import_files(module, dt=None, dn=None, force=False, pre_process=None, reset_permissions=False): if type(module) is list: out = [] for m in module: - out.append(import_file(m[0], m[1], m[2], force=force, pre_process=pre_process, - reset_permissions=reset_permissions)) + out.append(import_file(m[0], m[1], m[2], force=force, pre_process=pre_process, reset_permissions=reset_permissions)) return out else: - return import_file(module, dt, dn, force=force, pre_process=pre_process, - reset_permissions=reset_permissions) + return import_file(module, dt, dn, force=force, pre_process=pre_process, reset_permissions=reset_permissions) + def import_file(module, dt, dn, force=False, pre_process=None, reset_permissions=False): """Sync a file from txt if modifed, return false if not updated""" @@ -33,85 +55,166 @@ def import_file(module, dt, dn, force=False, pre_process=None, reset_permissions ret = import_file_by_path(path, force, pre_process=pre_process, reset_permissions=reset_permissions) return ret + def get_file_path(module, dt, dn): dt, dn = scrub_dt_dn(dt, dn) - path = os.path.join(get_module_path(module), - os.path.join(dt, dn, dn + ".json")) + path = os.path.join(get_module_path(module), os.path.join(dt, dn, f"{dn}.json")) return path -def import_file_by_path(path, force=False, data_import=False, pre_process=None, ignore_version=None, - reset_permissions=False, for_sync=False): + +def import_file_by_path(path: str,force: bool = False,data_import: bool = False,pre_process = None,ignore_version: bool = None,reset_permissions: bool = False): + """Import file from the given path + + Some conditions decide if a file should be imported or not. + Evaluation takes place in the order they are mentioned below. + + - Check if `force` is true. Import the file. If not, move ahead. + - Get `db_modified_timestamp`(value of the modified field in the database for the file). + If the return is `none,` this file doesn't exist in the DB, so Import the file. If not, move ahead. + - Check if there is a hash in DB for that file. If there is, Calculate the Hash of the file to import and compare it with the one in DB if they are not equal. + Import the file. If Hash doesn't exist, move ahead. + - Check if `db_modified_timestamp` is older than the timestamp in the file; if it is, we import the file. + + If timestamp comparison happens for doctypes, that means the Hash for it doesn't exist. + So, even if the timestamp is newer on DB (When comparing timestamps), we import the file and add the calculated Hash to the DB. + So in the subsequent imports, we can use hashes to compare. As a precautionary measure, the timestamp is updated to the current time as well. + + Args: + path (str): Path to the file. + force (bool, optional): Load the file without checking any conditions. Defaults to False. + data_import (bool, optional): [description]. Defaults to False. + pre_process ([type], optional): Any preprocesing that may need to take place on the doc. Defaults to None. + ignore_version (bool, optional): ignore current version. Defaults to None. + reset_permissions (bool, optional): reset permissions for the file. Defaults to False. + + Returns: + [bool]: True if import takes place. False if it wasn't imported. + """ + frappe.flags.dt = frappe.flags.dt or [] try: docs = read_doc_from_file(path) except IOError: - print (path + " missing") + print(f"{path} missing") return + calculated_hash = caclulate_hash(path) + if docs: if not isinstance(docs, list): docs = [docs] for doc in docs: - if not force: - # check if timestamps match - db_modified = frappe.db.get_value(doc['doctype'], doc['name'], 'modified') - if db_modified and doc.get('modified')==get_datetime_str(db_modified): + + # modified timestamp in db, none if doctype's first import + db_modified_timestamp = frappe.db.get_value(doc["doctype"], doc["name"], "modified") + is_db_timestamp_latest = db_modified_timestamp and doc.get("modified") <= get_datetime_str(db_modified_timestamp) + + if not force or db_modified_timestamp: + try: + stored_hash = frappe.db.get_value(doc["doctype"], doc["name"], "migration_hash") + except Exception: + frappe.flags.dt += [doc["doctype"]] + stored_hash = None + + # if hash exists and is equal no need to update + if stored_hash and stored_hash == calculated_hash: return False - original_modified = doc.get("modified") + # if hash doesn't exist, check if db timestamp is same as json timestamp, add hash if from doctype + if is_db_timestamp_latest and doc["doctype"] != "DocType": + return False - frappe.flags.in_import = True - import_doc(doc, force=force, data_import=data_import, pre_process=pre_process, - ignore_version=ignore_version, reset_permissions=reset_permissions) - frappe.flags.in_import = False + import_doc( + docdict=doc, + force=force, + data_import=data_import, + pre_process=pre_process, + ignore_version=ignore_version, + reset_permissions=reset_permissions, + path=path, + ) - if original_modified: - # since there is a new timestamp on the file, update timestamp in - if doc["doctype"] == doc["name"] and doc["name"]!="DocType": - frappe.db.sql("""update tabSingles set value=%s where field="modified" and doctype=%s""", - (original_modified, doc["name"])) - else: - frappe.db.sql("update `tab%s` set modified=%s where name=%s" % \ - (doc['doctype'], '%s', '%s'), - (original_modified, doc['name'])) + if doc["doctype"] == "DocType": + doctype_table = DocType("DocType") + frappe.qb.update( + doctype_table + ).set( + doctype_table.migration_hash, calculated_hash + ).where( + doctype_table.name == doc["name"] + ).run() + + new_modified_timestamp = doc.get("modified") + + # if db timestamp is newer, hash must have changed, must update db timestamp + if is_db_timestamp_latest and doc["doctype"] == "DocType": + new_modified_timestamp = now() + + if new_modified_timestamp: + update_modified(new_modified_timestamp, doc) return True + +def is_timestamp_changed(doc): + # check if timestamps match + db_modified = frappe.db.get_value(doc["doctype"], doc["name"], "modified") + return not (db_modified and doc.get("modified") == get_datetime_str(db_modified)) + + def read_doc_from_file(path): doc = None if os.path.exists(path): - with open(path, 'r') as f: + with open(path, "r") as f: try: doc = json.loads(f.read()) except ValueError: print("bad json: {0}".format(path)) raise else: - raise IOError('%s missing' % path) + raise IOError("%s missing" % path) return doc -def import_doc(docdict, force=False, data_import=False, pre_process=None, - ignore_version=None, reset_permissions=False): + +def update_modified(original_modified, doc): + # since there is a new timestamp on the file, update timestamp in + if doc["doctype"] == doc["name"] and doc["name"] != "DocType": + singles_table = DocType("Singles") + + frappe.qb.update( + singles_table + ).set( + singles_table.value,original_modified + ).where( + singles_table.field == "modified" + ).where( + singles_table.doctype == doc["name"] + ).run() + else: + doctype_table = DocType(doc['doctype']) + + frappe.qb.update(doctype_table + ).set( + doctype_table.modified, original_modified + ).where( + doctype_table.name == doc["name"] + ).run() + +def import_doc(docdict, force=False, data_import=False, pre_process=None, ignore_version=None, reset_permissions=False, path=None): frappe.flags.in_import = True docdict["__islocal"] = 1 - controller = get_controller(docdict['doctype']) - if controller and hasattr(controller, 'prepare_for_import') and callable(getattr(controller, 'prepare_for_import')): + controller = get_controller(docdict["doctype"]) + if controller and hasattr(controller, "prepare_for_import") and callable(getattr(controller, "prepare_for_import")): controller.prepare_for_import(docdict) doc = frappe.get_doc(docdict) - # Note on Tree DocTypes: - # The tree structure is maintained in the database via the fields "lft" and - # "rgt". They are automatically set and kept up-to-date. Importing them - # would destroy any existing tree structure. - if getattr(doc.meta, 'is_tree', None) and any([doc.lft, doc.rgt]): - print('Ignoring values of `lft` and `rgt` for {} "{}"'.format(doc.doctype, doc.name)) - doc.lft = None - doc.rgt = None + reset_tree_properties(doc) + load_code_properties(doc, path) doc.run_method("before_import") @@ -119,27 +222,9 @@ def import_doc(docdict, force=False, data_import=False, pre_process=None, if pre_process: pre_process(doc) - ignore = [] - if frappe.db.exists(doc.doctype, doc.name): + delete_old_doc(doc, reset_permissions) - old_doc = frappe.get_doc(doc.doctype, doc.name) - - if doc.doctype in ignore_values: - # update ignore values - for key in ignore_values.get(doc.doctype) or []: - doc.set(key, old_doc.get(key)) - - # update ignored docs into new doc - for df in doc.meta.get_table_fields(): - if df.options in ignore_doctypes and not reset_permissions: - doc.set(df.fieldname, []) - ignore.append(df.options) - - # delete old - frappe.delete_doc(doc.doctype, doc.name, force=1, ignore_doctypes=ignore, for_reload=True) - - doc.flags.ignore_children_type = ignore doc.flags.ignore_links = True if not data_import: doc.flags.ignore_validate = True @@ -149,3 +234,49 @@ def import_doc(docdict, force=False, data_import=False, pre_process=None, doc.insert() frappe.flags.in_import = False + + return doc + + +def load_code_properties(doc, path): + """Load code files stored in separate files with extensions""" + if path: + if hasattr(doc, "get_code_fields"): + dirname, filename = os.path.split(path) + for key, extn in doc.get_code_fields().items(): + codefile = os.path.join(dirname, filename.split(".")[0] + "." + extn) + if os.path.exists(codefile): + with open(codefile, "r") as txtfile: + doc.set(key, txtfile.read()) + + +def delete_old_doc(doc, reset_permissions): + ignore = [] + old_doc = frappe.get_doc(doc.doctype, doc.name) + + if doc.doctype in ignore_values: + # update ignore values + for key in ignore_values.get(doc.doctype) or []: + doc.set(key, old_doc.get(key)) + + # update ignored docs into new doc + for df in doc.meta.get_table_fields(): + if df.options in ignore_doctypes and not reset_permissions: + doc.set(df.fieldname, []) + ignore.append(df.options) + + # delete old + frappe.delete_doc(doc.doctype, doc.name, force=1, ignore_doctypes=ignore, for_reload=True) + + doc.flags.ignore_children_type = ignore + + +def reset_tree_properties(doc): + # Note on Tree DocTypes: + # The tree structure is maintained in the database via the fields "lft" and + # "rgt". They are automatically set and kept up-to-date. Importing them + # would destroy any existing tree structure. + if getattr(doc.meta, "is_tree", None) and any([doc.lft, doc.rgt]): + print('Ignoring values of `lft` and `rgt` for {} "{}"'.format(doc.doctype, doc.name)) + doc.lft = None + doc.rgt = None diff --git a/frappe/modules/patch_handler.py b/frappe/modules/patch_handler.py index 029234d5d9..8dfb27c0b8 100644 --- a/frappe/modules/patch_handler.py +++ b/frappe/modules/patch_handler.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE """ Execute Patch Files diff --git a/frappe/modules/utils.py b/frappe/modules/utils.py index 0f3e57a5a0..bbfd63a277 100644 --- a/frappe/modules/utils.py +++ b/frappe/modules/utils.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE """ Utilities for using modules """ @@ -114,8 +114,7 @@ def sync_customizations_for_doctype(data, folder): doc.db_insert() if custom_doctype != 'Custom Field': - frappe.db.sql('delete from `tab{0}` where `{1}` =%s'.format( - custom_doctype, doctype_fieldname), doc_type) + frappe.db.delete(custom_doctype, {doctype_fieldname: doc_type}) for d in data[key]: _insert(d) diff --git a/frappe/monitor.py b/frappe/monitor.py index 34ca7d67f7..6bad03dfe9 100644 --- a/frappe/monitor.py +++ b/frappe/monitor.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE from datetime import datetime import json diff --git a/frappe/parallel_test_runner.py b/frappe/parallel_test_runner.py index 2f83b88572..1a6892d30d 100644 --- a/frappe/parallel_test_runner.py +++ b/frappe/parallel_test_runner.py @@ -15,10 +15,9 @@ if click_ctx: click_ctx.color = True class ParallelTestRunner(): - def __init__(self, app, site, build_number=1, total_builds=1, with_coverage=False): + def __init__(self, app, site, build_number=1, total_builds=1): self.app = app self.site = site - self.with_coverage = with_coverage self.build_number = frappe.utils.cint(build_number) or 1 self.total_builds = frappe.utils.cint(total_builds) self.setup_test_site() @@ -53,12 +52,9 @@ class ParallelTestRunner(): def run_tests(self): self.test_result = ParallelTestResult(stream=sys.stderr, descriptions=True, verbosity=2) - self.start_coverage() - for test_file_info in self.get_test_file_list(): self.run_tests_for_file(test_file_info) - self.save_coverage() self.print_result() def run_tests_for_file(self, file_info): @@ -107,45 +103,6 @@ class ParallelTestRunner(): if os.environ.get('CI'): sys.exit(1) - def start_coverage(self): - if self.with_coverage: - from coverage import Coverage - from frappe.utils import get_bench_path - - # Generate coverage report only for app that is being tested - source_path = os.path.join(get_bench_path(), 'apps', self.app) - incl = [ - '*.py', - ] - omit = [ - '*.js', - '*.xml', - '*.pyc', - '*.css', - '*.less', - '*.scss', - '*.vue', - '*.pyc', - '*.html', - '*/test_*', - '*/node_modules/*', - '*/doctype/*/*_dashboard.py', - '*/patches/*', - ] - - if self.app == 'frappe': - omit.append('*/tests/*') - omit.append('*/commands/*') - - self.coverage = Coverage(source=[source_path], omit=omit, include=incl) - self.coverage.start() - - def save_coverage(self): - if not self.with_coverage: - return - self.coverage.stop() - self.coverage.save() - def get_test_file_list(self): test_list = get_all_tests(self.app) split_size = frappe.utils.ceil(len(test_list) / self.total_builds) @@ -241,7 +198,7 @@ class ParallelTestWithOrchestrator(ParallelTestRunner): - get-next-test-spec (, ) - test-completed (, ) ''' - def __init__(self, app, site, with_coverage=False): + def __init__(self, app, site): self.orchestrator_url = os.environ.get('ORCHESTRATOR_URL') if not self.orchestrator_url: click.echo('ORCHESTRATOR_URL environment variable not found!') @@ -254,7 +211,7 @@ class ParallelTestWithOrchestrator(ParallelTestRunner): click.echo('CI_BUILD_ID environment variable not found!') sys.exit(1) - ParallelTestRunner.__init__(self, app, site, with_coverage=with_coverage) + ParallelTestRunner.__init__(self, app, site) def run_tests(self): self.test_status = 'ongoing' @@ -281,7 +238,9 @@ class ParallelTestWithOrchestrator(ParallelTestRunner): self.call_orchestrator('test-completed') return super().print_result() - def call_orchestrator(self, endpoint, data={}): + def call_orchestrator(self, endpoint, data=None): + if data is None: + data = {} # add repo token header # build id in header headers = { diff --git a/frappe/patches.txt b/frappe/patches.txt index 7605d8ea2b..8309b2df57 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -178,5 +178,10 @@ frappe.patches.v13_0.rename_list_view_setting_to_list_view_settings frappe.patches.v13_0.remove_twilio_settings frappe.patches.v12_0.rename_uploaded_files_with_proper_name frappe.patches.v13_0.queryreport_columns +execute:frappe.reload_doc('core', 'doctype', 'doctype') frappe.patches.v13_0.jinja_hook frappe.patches.v13_0.update_notification_channel_if_empty +frappe.patches.v14_0.drop_data_import_legacy +frappe.patches.v14_0.rename_cancelled_documents +frappe.patches.v14_0.update_workspace2 # 20.09.2021 +frappe.patches.v14_0.update_github_endpoints #08-11-2021 diff --git a/frappe/patches/v10_0/modify_smallest_currency_fraction.py b/frappe/patches/v10_0/modify_smallest_currency_fraction.py index c9ae477359..9469d546ce 100644 --- a/frappe/patches/v10_0/modify_smallest_currency_fraction.py +++ b/frappe/patches/v10_0/modify_smallest_currency_fraction.py @@ -1,5 +1,5 @@ # Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe diff --git a/frappe/patches/v10_0/set_default_locking_time.py b/frappe/patches/v10_0/set_default_locking_time.py index 045fa0e3fa..11993e1163 100644 --- a/frappe/patches/v10_0/set_default_locking_time.py +++ b/frappe/patches/v10_0/set_default_locking_time.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe diff --git a/frappe/patches/v11_0/apply_customization_to_custom_doctype.py b/frappe/patches/v11_0/apply_customization_to_custom_doctype.py index 49b68ed240..7e84c5ae24 100644 --- a/frappe/patches/v11_0/apply_customization_to_custom_doctype.py +++ b/frappe/patches/v11_0/apply_customization_to_custom_doctype.py @@ -28,7 +28,7 @@ def execute(): for prop in property_setters: property_setter_map[prop.field_name] = prop - frappe.db.sql('DELETE FROM `tabProperty Setter` WHERE `name`=%s', prop.name) + frappe.db.delete("Property Setter", {"name": prop.name}) meta = frappe.get_meta(doctype.name) @@ -50,6 +50,6 @@ def execute(): df = frappe.new_doc('DocField', meta, 'fields') df.update(cf) meta.fields.append(df) - frappe.db.sql('DELETE FROM `tabCustom Field` WHERE name=%s', cf.name) + frappe.db.delete("Custom Field", {"name": cf.name}) meta.save() diff --git a/frappe/patches/v11_0/change_email_signature_fieldtype.py b/frappe/patches/v11_0/change_email_signature_fieldtype.py index ccfa8541c3..7c57aa044e 100644 --- a/frappe/patches/v11_0/change_email_signature_fieldtype.py +++ b/frappe/patches/v11_0/change_email_signature_fieldtype.py @@ -1,5 +1,5 @@ # Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe diff --git a/frappe/patches/v11_0/remove_doctype_user_permissions_for_page_and_report.py b/frappe/patches/v11_0/remove_doctype_user_permissions_for_page_and_report.py index 5c54b1e5c1..ff5cf3fc5e 100644 --- a/frappe/patches/v11_0/remove_doctype_user_permissions_for_page_and_report.py +++ b/frappe/patches/v11_0/remove_doctype_user_permissions_for_page_and_report.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe diff --git a/frappe/patches/v11_0/remove_skip_for_doctype.py b/frappe/patches/v11_0/remove_skip_for_doctype.py index 638a5a0fd7..1bbe74bb6d 100644 --- a/frappe/patches/v11_0/remove_skip_for_doctype.py +++ b/frappe/patches/v11_0/remove_skip_for_doctype.py @@ -2,6 +2,7 @@ import frappe from frappe.desk.form.linked_with import get_linked_doctypes from frappe.patches.v11_0.replicate_old_user_permissions import get_doctypes_to_skip +from frappe.query_builder import Field # `skip_for_doctype` was a un-normalized way of storing for which # doctypes the user permission was applicable. @@ -72,16 +73,12 @@ def execute(): frappe.db.set_value('User Permission', user_permission.name, 'apply_to_all_doctypes', 1) if new_user_permissions_list: - frappe.db.sql(''' - INSERT INTO `tabUser Permission` - (`name`, `user`, `allow`, `for_value`, `applicable_for`, `apply_to_all_doctypes`, `creation`, `modified`) - VALUES {} - '''.format( # nosec - ', '.join(['%s'] * len(new_user_permissions_list)) - ), tuple(new_user_permissions_list)) + frappe.qb.into("User Permission").columns( + "name", "user", "allow", "for_value", "applicable_for", "apply_to_all_doctypes", "creation", "modified" + ).insert(*new_user_permissions_list).run() if user_permissions_to_delete: - frappe.db.sql('DELETE FROM `tabUser Permission` WHERE `name` in ({})' # nosec - .format(','.join(['%s'] * len(user_permissions_to_delete))), - tuple(user_permissions_to_delete) + frappe.db.delete( + "User Permission", + filters=(Field("name").isin(tuple(user_permissions_to_delete))) ) diff --git a/frappe/patches/v11_0/sync_stripe_settings_before_migrate.py b/frappe/patches/v11_0/sync_stripe_settings_before_migrate.py index a8e9bd4de1..901ab66bfd 100644 --- a/frappe/patches/v11_0/sync_stripe_settings_before_migrate.py +++ b/frappe/patches/v11_0/sync_stripe_settings_before_migrate.py @@ -17,4 +17,4 @@ def execute(): settings.secret_key = secret_key settings.save(ignore_permissions=True) - frappe.db.sql("""DELETE FROM tabSingles WHERE doctype='Stripe Settings'""") \ No newline at end of file + frappe.db.delete("Singles", {"doctype": "Stripe Settings"}) diff --git a/frappe/patches/v11_0/sync_user_permission_doctype_before_migrate.py b/frappe/patches/v11_0/sync_user_permission_doctype_before_migrate.py index 55a7b74f7e..6b7a7695f6 100644 --- a/frappe/patches/v11_0/sync_user_permission_doctype_before_migrate.py +++ b/frappe/patches/v11_0/sync_user_permission_doctype_before_migrate.py @@ -1,7 +1,7 @@ - import frappe + def execute(): frappe.flags.in_patch = True - frappe.reload_doc('core', 'doctype', 'user_permission') + frappe.reload_doc("core", "doctype", "user_permission") frappe.db.commit() diff --git a/frappe/patches/v12_0/delete_feedback_request_if_exists.py b/frappe/patches/v12_0/delete_feedback_request_if_exists.py index fdbcecfc5a..c1bf46b14a 100644 --- a/frappe/patches/v12_0/delete_feedback_request_if_exists.py +++ b/frappe/patches/v12_0/delete_feedback_request_if_exists.py @@ -2,7 +2,4 @@ import frappe def execute(): - frappe.db.sql(''' - DELETE from `tabDocType` - WHERE name = 'Feedback Request' - ''') \ No newline at end of file + frappe.db.delete("DocType", {"name": "Feedback Request"}) diff --git a/frappe/patches/v12_0/remove_deprecated_fields_from_doctype.py b/frappe/patches/v12_0/remove_deprecated_fields_from_doctype.py index 60599066e6..9c9a79ccbf 100644 --- a/frappe/patches/v12_0/remove_deprecated_fields_from_doctype.py +++ b/frappe/patches/v12_0/remove_deprecated_fields_from_doctype.py @@ -8,7 +8,6 @@ def execute(): 'DocType': ['hide_heading', 'image_view', 'read_only_onload'] }, delete=1) - frappe.db.sql(''' - DELETE from `tabProperty Setter` - WHERE property = 'read_only_onload' - ''') + frappe.db.delete("Property Setter", { + "property": "read_only_onload" + }) \ No newline at end of file diff --git a/frappe/patches/v12_0/set_correct_assign_value_in_docs.py b/frappe/patches/v12_0/set_correct_assign_value_in_docs.py index 65a635c170..5aaadd00e8 100644 --- a/frappe/patches/v12_0/set_correct_assign_value_in_docs.py +++ b/frappe/patches/v12_0/set_correct_assign_value_in_docs.py @@ -1,32 +1,27 @@ import frappe +from frappe.query_builder.functions import GroupConcat, Coalesce def execute(): - frappe.reload_doc('desk', 'doctype', 'todo') + frappe.reload_doc("desk", "doctype", "todo") - query = ''' - SELECT - name, reference_type, reference_name, {} as assignees - FROM - `tabToDo` - WHERE - COALESCE(reference_type, '') != '' AND - COALESCE(reference_name, '') != '' AND - status != 'Cancelled' - GROUP BY - reference_type, reference_name - ''' + ToDo = frappe.qb.DocType("ToDo") + assignees = GroupConcat("owner").distinct().as_("assignees") - assignments = frappe.db.multisql({ - 'mariadb': query.format('GROUP_CONCAT(DISTINCT `owner`)'), - 'postgres': query.format('STRING_AGG(DISTINCT "owner", ",")') - }, as_dict=True) + assignments = ( + frappe.qb.from_(ToDo) + .select(ToDo.name, ToDo.reference_type, assignees) + .where(Coalesce(ToDo.reference_type, "") != "") + .where(Coalesce(ToDo.reference_name, "") != "") + .where(ToDo.status != "Cancelled") + .groupby(ToDo.reference_type, ToDo.reference_name) + ).run(as_dict=True) for doc in assignments: - assignments = doc.assignees.split(',') + assignments = doc.assignees.split(",") frappe.db.set_value( doc.reference_type, doc.reference_name, - '_assign', + "_assign", frappe.as_json(assignments), update_modified=False - ) + ) \ No newline at end of file diff --git a/frappe/patches/v12_0/set_default_password_reset_limit.py b/frappe/patches/v12_0/set_default_password_reset_limit.py index 188f2383e7..e403b5251e 100644 --- a/frappe/patches/v12_0/set_default_password_reset_limit.py +++ b/frappe/patches/v12_0/set_default_password_reset_limit.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe diff --git a/frappe/patches/v12_0/set_primary_key_in_series.py b/frappe/patches/v12_0/set_primary_key_in_series.py index e5ed2204ba..83a903fc2d 100644 --- a/frappe/patches/v12_0/set_primary_key_in_series.py +++ b/frappe/patches/v12_0/set_primary_key_in_series.py @@ -1,21 +1,24 @@ import frappe def execute(): - #if current = 0, simply delete the key as it'll be recreated on first entry - frappe.db.sql('delete from `tabSeries` where current = 0') - duplicate_keys = frappe.db.sql(''' - SELECT name, max(current) as current - from - `tabSeries` - group by - name - having count(name) > 1 - ''', as_dict=True) - for row in duplicate_keys: - frappe.db.sql('delete from `tabSeries` where name = %(key)s', { - 'key': row.name - }) - if row.current: - frappe.db.sql('insert into `tabSeries`(`name`, `current`) values (%(name)s, %(current)s)', row) - frappe.db.commit() - frappe.db.sql('ALTER table `tabSeries` ADD PRIMARY KEY IF NOT EXISTS (name)') + #if current = 0, simply delete the key as it'll be recreated on first entry + frappe.db.delete("Series", {"current": 0}) + + duplicate_keys = frappe.db.sql(''' + SELECT name, max(current) as current + from + `tabSeries` + group by + name + having count(name) > 1 + ''', as_dict=True) + + for row in duplicate_keys: + frappe.db.delete("Series", { + "name": row.name + }) + if row.current: + frappe.db.sql('insert into `tabSeries`(`name`, `current`) values (%(name)s, %(current)s)', row) + frappe.db.commit() + + frappe.db.sql('ALTER table `tabSeries` ADD PRIMARY KEY IF NOT EXISTS (name)') diff --git a/frappe/patches/v12_0/setup_comments_from_communications.py b/frappe/patches/v12_0/setup_comments_from_communications.py index 039ceeff35..11e02965f1 100644 --- a/frappe/patches/v12_0/setup_comments_from_communications.py +++ b/frappe/patches/v12_0/setup_comments_from_communications.py @@ -29,4 +29,6 @@ def execute(): frappe.db.auto_commit_on_many_writes = False # clean up - frappe.db.sql("delete from `tabCommunication` where communication_type = 'Comment'") + frappe.db.delete("Communication", { + "communication_type": "Comment" + }) diff --git a/frappe/patches/v13_0/delete_event_producer_and_consumer_keys.py b/frappe/patches/v13_0/delete_event_producer_and_consumer_keys.py index 776e9c796e..2d9e232da5 100644 --- a/frappe/patches/v13_0/delete_event_producer_and_consumer_keys.py +++ b/frappe/patches/v13_0/delete_event_producer_and_consumer_keys.py @@ -1,5 +1,5 @@ # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe diff --git a/frappe/patches/v13_0/delete_package_publish_tool.py b/frappe/patches/v13_0/delete_package_publish_tool.py index bf9aaf5a76..5c1678bdbe 100644 --- a/frappe/patches/v13_0/delete_package_publish_tool.py +++ b/frappe/patches/v13_0/delete_package_publish_tool.py @@ -1,5 +1,5 @@ # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe diff --git a/frappe/patches/v13_0/enable_custom_script.py b/frappe/patches/v13_0/enable_custom_script.py index 0684074fe7..de027ab97a 100644 --- a/frappe/patches/v13_0/enable_custom_script.py +++ b/frappe/patches/v13_0/enable_custom_script.py @@ -1,5 +1,5 @@ # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe 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 dd9fb1961a..6e8e0d7fc5 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 @@ -1,5 +1,5 @@ # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe diff --git a/frappe/patches/v13_0/increase_password_length.py b/frappe/patches/v13_0/increase_password_length.py index 1bb1979051..deb7d7e98a 100644 --- a/frappe/patches/v13_0/increase_password_length.py +++ b/frappe/patches/v13_0/increase_password_length.py @@ -1,7 +1,4 @@ import frappe def execute(): - frappe.db.multisql({ - "mariadb": "ALTER TABLE `__Auth` MODIFY `password` TEXT NOT NULL", - "postgres": 'ALTER TABLE "__Auth" ALTER COLUMN "password" TYPE TEXT' - }) + frappe.db.change_column_type("__Auth", column="password", type="TEXT") diff --git a/frappe/patches/v13_0/jinja_hook.py b/frappe/patches/v13_0/jinja_hook.py index 990ae50f35..e1c9175576 100644 --- a/frappe/patches/v13_0/jinja_hook.py +++ b/frappe/patches/v13_0/jinja_hook.py @@ -1,5 +1,5 @@ # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe from click import secho diff --git a/frappe/patches/v13_0/queryreport_columns.py b/frappe/patches/v13_0/queryreport_columns.py index 5c381f4f3e..ed22ce4441 100644 --- a/frappe/patches/v13_0/queryreport_columns.py +++ b/frappe/patches/v13_0/queryreport_columns.py @@ -1,5 +1,5 @@ # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe import json diff --git a/frappe/patches/v13_0/remove_tailwind_from_page_builder.py b/frappe/patches/v13_0/remove_tailwind_from_page_builder.py index 2bf2c7bf87..b26d2bef4a 100644 --- a/frappe/patches/v13_0/remove_tailwind_from_page_builder.py +++ b/frappe/patches/v13_0/remove_tailwind_from_page_builder.py @@ -1,5 +1,5 @@ # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe diff --git a/frappe/patches/v13_0/remove_twilio_settings.py b/frappe/patches/v13_0/remove_twilio_settings.py index 363cbdd4b6..826edfb951 100644 --- a/frappe/patches/v13_0/remove_twilio_settings.py +++ b/frappe/patches/v13_0/remove_twilio_settings.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe @@ -12,7 +12,9 @@ def execute(): frappe.delete_doc_if_exists('DocType', 'Twilio Number Group') if twilio_settings_doctype_in_integrations(): frappe.delete_doc_if_exists('DocType', 'Twilio Settings') - frappe.db.sql("delete from `tabSingles` where `doctype`=%s", 'Twilio Settings') + frappe.db.delete("Singles", { + "doctype": "Twilio Settings" + }) def twilio_settings_doctype_in_integrations() -> bool: """Check Twilio Settings doctype exists in integrations module or not. 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 index 3122de8bea..db3ab1b32a 100644 --- 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 @@ -1,5 +1,5 @@ # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe diff --git a/frappe/patches/v13_0/rename_notification_fields.py b/frappe/patches/v13_0/rename_notification_fields.py index 1413d80358..2f314df9c1 100644 --- a/frappe/patches/v13_0/rename_notification_fields.py +++ b/frappe/patches/v13_0/rename_notification_fields.py @@ -1,5 +1,5 @@ # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe from frappe.model.utils.rename_field import rename_field diff --git a/frappe/patches/v13_0/rename_onboarding.py b/frappe/patches/v13_0/rename_onboarding.py index 852065dfd2..cd910195ad 100644 --- a/frappe/patches/v13_0/rename_onboarding.py +++ b/frappe/patches/v13_0/rename_onboarding.py @@ -1,5 +1,5 @@ # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe diff --git a/frappe/patches/v13_0/replace_old_data_import.py b/frappe/patches/v13_0/replace_old_data_import.py index 838881b48e..7d2692a433 100644 --- a/frappe/patches/v13_0/replace_old_data_import.py +++ b/frappe/patches/v13_0/replace_old_data_import.py @@ -1,5 +1,5 @@ # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe diff --git a/frappe/patches/v13_0/update_duration_options.py b/frappe/patches/v13_0/update_duration_options.py index e0d8dea4ea..48f0dc0969 100644 --- a/frappe/patches/v13_0/update_duration_options.py +++ b/frappe/patches/v13_0/update_duration_options.py @@ -1,5 +1,5 @@ # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe diff --git a/frappe/patches/v13_0/update_newsletter_content_type.py b/frappe/patches/v13_0/update_newsletter_content_type.py index 5f047680ee..39758c8257 100644 --- a/frappe/patches/v13_0/update_newsletter_content_type.py +++ b/frappe/patches/v13_0/update_newsletter_content_type.py @@ -1,5 +1,5 @@ # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe diff --git a/frappe/patches/v13_0/update_notification_channel_if_empty.py b/frappe/patches/v13_0/update_notification_channel_if_empty.py index bcf9a7b28c..43cf813c74 100644 --- a/frappe/patches/v13_0/update_notification_channel_if_empty.py +++ b/frappe/patches/v13_0/update_notification_channel_if_empty.py @@ -1,5 +1,5 @@ # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe diff --git a/frappe/patches/v13_0/web_template_set_module.py b/frappe/patches/v13_0/web_template_set_module.py index 2ee9e3ba2d..d200f4e0da 100644 --- a/frappe/patches/v13_0/web_template_set_module.py +++ b/frappe/patches/v13_0/web_template_set_module.py @@ -1,5 +1,5 @@ # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe diff --git a/frappe/patches/v14_0/__init__.py b/frappe/patches/v14_0/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/patches/v14_0/drop_data_import_legacy.py b/frappe/patches/v14_0/drop_data_import_legacy.py new file mode 100644 index 0000000000..2037930c9f --- /dev/null +++ b/frappe/patches/v14_0/drop_data_import_legacy.py @@ -0,0 +1,22 @@ +import frappe +import click + + +def execute(): + doctype = "Data Import Legacy" + table = frappe.utils.get_table_name(doctype) + + # delete the doctype record to avoid broken links + frappe.db.delete("DocType", {"name": doctype}) + + # leaving table in database for manual cleanup + click.secho( + f"`{doctype}` has been deprecated. The DocType is deleted, but the data still" + " exists on the database. If this data is worth recovering, you may export it" + f" using\n\n\tbench --site {frappe.local.site} backup -i '{doctype}'\n\nAfter" + " this, the table will continue to persist in the database, until you choose" + " to remove it yourself. If you want to drop the table, you may run\n\n\tbench" + f" --site {frappe.local.site} execute frappe.db.sql --args \"('DROP TABLE IF" + f" EXISTS `{table}`', )\"\n", + fg="yellow", + ) diff --git a/frappe/patches/v14_0/rename_cancelled_documents.py b/frappe/patches/v14_0/rename_cancelled_documents.py new file mode 100644 index 0000000000..4b565d4f76 --- /dev/null +++ b/frappe/patches/v14_0/rename_cancelled_documents.py @@ -0,0 +1,213 @@ +import functools +import traceback + +import frappe + +def execute(): + """Rename cancelled documents by adding a postfix. + """ + rename_cancelled_docs() + +def get_submittable_doctypes(): + """Returns list of submittable doctypes in the system. + """ + return frappe.db.get_all('DocType', filters={'is_submittable': 1}, pluck='name') + +def get_cancelled_doc_names(doctype): + """Return names of cancelled document names those are in old format. + """ + docs = frappe.db.get_all(doctype, filters={'docstatus': 2}, pluck='name') + return [each for each in docs if not (each.endswith('-CANC') or ('-CANC-' in each))] + +@functools.lru_cache() +def get_linked_doctypes(): + """Returns list of doctypes those are linked with given doctype using 'Link' fieldtype. + """ + filters=[['fieldtype','=', 'Link']] + links = frappe.get_all("DocField", + fields=["parent", "fieldname", "options as linked_to"], + filters=filters, + as_list=1) + + links+= frappe.get_all("Custom Field", + fields=["dt as parent", "fieldname", "options as linked_to"], + filters=filters, + as_list=1) + + links_by_doctype = {} + for doctype, fieldname, linked_to in links: + links_by_doctype.setdefault(linked_to, []).append((doctype, fieldname)) + return links_by_doctype + +@functools.lru_cache() +def get_single_doctypes(): + return frappe.get_all("DocType", filters={'issingle': 1}, pluck='name') + +@functools.lru_cache() +def get_dynamic_linked_doctypes(): + filters=[['fieldtype','=', 'Dynamic Link']] + + # find dynamic links of parents + links = frappe.get_all("DocField", + fields=["parent as doctype", "fieldname", "options as doctype_fieldname"], + filters=filters, + as_list=1) + links+= frappe.get_all("Custom Field", + fields=["dt as doctype", "fieldname", "options as doctype_fieldname"], + filters=filters, + as_list=1) + return links + +@functools.lru_cache() +def get_child_tables(): + """ + """ + filters =[['fieldtype', 'in', ('Table', 'Table MultiSelect')]] + links = frappe.get_all("DocField", + fields=["parent as doctype", "options as child_table"], + filters=filters, + as_list=1) + + links+= frappe.get_all("Custom Field", + fields=["dt as doctype", "options as child_table"], + filters=filters, + as_list=1) + + map = {} + for doctype, child_table in links: + map.setdefault(doctype, []).append(child_table) + return map + +def update_cancelled_document_names(doctype, cancelled_doc_names): + return frappe.db.sql(""" + update + `tab{doctype}` + set + name=CONCAT(name, '-CANC') + where + docstatus=2 + and + name in %(cancelled_doc_names)s; + """.format(doctype=doctype), {'cancelled_doc_names': cancelled_doc_names}) + +def update_amended_field(doctype, cancelled_doc_names): + return frappe.db.sql(""" + update + `tab{doctype}` + set + amended_from=CONCAT(amended_from, '-CANC') + where + amended_from in %(cancelled_doc_names)s; + """.format(doctype=doctype), {'cancelled_doc_names': cancelled_doc_names}) + +def update_attachments(doctype, cancelled_doc_names): + frappe.db.sql(""" + update + `tabFile` + set + attached_to_name=CONCAT(attached_to_name, '-CANC') + where + attached_to_doctype=%(dt)s and attached_to_name in %(cancelled_doc_names)s + """, {'cancelled_doc_names': cancelled_doc_names, 'dt': doctype}) + +def update_versions(doctype, cancelled_doc_names): + frappe.db.sql(""" + UPDATE + `tabVersion` + SET + docname=CONCAT(docname, '-CANC') + WHERE + ref_doctype=%(dt)s AND docname in %(cancelled_doc_names)s + """, {'cancelled_doc_names': cancelled_doc_names, 'dt': doctype}) + +def update_linked_doctypes(doctype, cancelled_doc_names): + single_doctypes = get_single_doctypes() + + for linked_dt, field in get_linked_doctypes().get(doctype, []): + if linked_dt not in single_doctypes: + frappe.db.sql(""" + update + `tab{linked_dt}` + set + `{column}`=CONCAT(`{column}`, '-CANC') + where + `{column}` in %(cancelled_doc_names)s; + """.format(linked_dt=linked_dt, column=field), + {'cancelled_doc_names': cancelled_doc_names}) + else: + doc = frappe.get_single(linked_dt) + if getattr(doc, field) in cancelled_doc_names: + setattr(doc, field, getattr(doc, field)+'-CANC') + doc.flags.ignore_mandatory=True + doc.flags.ignore_validate=True + doc.save(ignore_permissions=True) + +def update_dynamic_linked_doctypes(doctype, cancelled_doc_names): + single_doctypes = get_single_doctypes() + + for linked_dt, fieldname, doctype_fieldname in get_dynamic_linked_doctypes(): + if linked_dt not in single_doctypes: + frappe.db.sql(""" + update + `tab{linked_dt}` + set + `{column}`=CONCAT(`{column}`, '-CANC') + where + `{column}` in %(cancelled_doc_names)s and {doctype_fieldname}=%(dt)s; + """.format(linked_dt=linked_dt, column=fieldname, doctype_fieldname=doctype_fieldname), + {'cancelled_doc_names': cancelled_doc_names, 'dt': doctype}) + else: + doc = frappe.get_single(linked_dt) + if getattr(doc, doctype_fieldname) == doctype and getattr(doc, fieldname) in cancelled_doc_names: + setattr(doc, fieldname, getattr(doc, fieldname)+'-CANC') + doc.flags.ignore_mandatory=True + doc.flags.ignore_validate=True + doc.save(ignore_permissions=True) + +def update_child_tables(doctype, cancelled_doc_names): + child_tables = get_child_tables().get(doctype, []) + single_doctypes = get_single_doctypes() + + for table in child_tables: + if table not in single_doctypes: + frappe.db.sql(""" + update + `tab{table}` + set + parent=CONCAT(parent, '-CANC') + where + parenttype=%(dt)s and parent in %(cancelled_doc_names)s; + """.format(table=table), {'cancelled_doc_names': cancelled_doc_names, 'dt': doctype}) + else: + doc = frappe.get_single(table) + if getattr(doc, 'parenttype')==doctype and getattr(doc, 'parent') in cancelled_doc_names: + setattr(doc, 'parent', getattr(doc, 'parent')+'-CANC') + doc.flags.ignore_mandatory=True + doc.flags.ignore_validate=True + doc.save(ignore_permissions=True) + +def rename_cancelled_docs(): + submittable_doctypes = get_submittable_doctypes() + + for dt in submittable_doctypes: + for retry in range(2): + try: + cancelled_doc_names = tuple(get_cancelled_doc_names(dt)) + if not cancelled_doc_names: + break + update_cancelled_document_names(dt, cancelled_doc_names) + update_amended_field(dt, cancelled_doc_names) + update_child_tables(dt, cancelled_doc_names) + update_linked_doctypes(dt, cancelled_doc_names) + update_dynamic_linked_doctypes(dt, cancelled_doc_names) + update_attachments(dt, cancelled_doc_names) + update_versions(dt, cancelled_doc_names) + print(f"Renaming cancelled records of {dt} doctype") + frappe.db.commit() + break + except Exception: + if retry == 1: + print(f"Failed to rename the cancelled records of {dt} doctype, moving on!") + traceback.print_exc() + frappe.db.rollback() + diff --git a/frappe/patches/v14_0/update_github_endpoints.py b/frappe/patches/v14_0/update_github_endpoints.py new file mode 100644 index 0000000000..8f9a06a043 --- /dev/null +++ b/frappe/patches/v14_0/update_github_endpoints.py @@ -0,0 +1,10 @@ +import frappe +import json + +def execute(): + if frappe.db.exists("Social Login Key", "github"): + frappe.db.set_value("Social Login Key", "github", "auth_url_data", + json.dumps({ + "scope": "user:email" + }) + ) diff --git a/frappe/patches/v14_0/update_workspace2.py b/frappe/patches/v14_0/update_workspace2.py new file mode 100644 index 0000000000..82076c4328 --- /dev/null +++ b/frappe/patches/v14_0/update_workspace2.py @@ -0,0 +1,69 @@ +import frappe +import json +from frappe import _ + +def execute(): + frappe.reload_doc('desk', 'doctype', 'workspace', force=True) + + for seq, wspace in enumerate(frappe.get_all('Workspace', order_by='name asc')): + doc = frappe.get_doc('Workspace', wspace.name) + content = create_content(doc) + update_wspace(doc, seq, content) + frappe.db.commit() + +def create_content(doc): + content = [] + if doc.onboarding: + content.append({"type":"onboarding","data":{"onboarding_name":doc.onboarding,"col":12}}) + if doc.charts: + invalid_links = [] + for c in doc.charts: + if c.get_invalid_links()[0]: + invalid_links.append(c) + else: + content.append({"type":"chart","data":{"chart_name":c.label,"col":12}}) + for l in invalid_links: + del doc.charts[doc.charts.index(l)] + if doc.shortcuts: + invalid_links = [] + if doc.charts: + content.append({"type":"spacer","data":{"col":12}}) + content.append({"type":"header","data":{"text":doc.shortcuts_label or _("Your Shortcuts"),"level":4,"col":12}}) + for s in doc.shortcuts: + if s.get_invalid_links()[0]: + invalid_links.append(s) + else: + content.append({"type":"shortcut","data":{"shortcut_name":s.label,"col":4}}) + for l in invalid_links: + del doc.shortcuts[doc.shortcuts.index(l)] + if doc.links: + invalid_links = [] + content.append({"type":"spacer","data":{"col":12}}) + content.append({"type":"header","data":{"text":doc.cards_label or _("Reports & Masters"),"level":4,"col":12}}) + for l in doc.links: + if l.type == 'Card Break': + content.append({"type":"card","data":{"card_name":l.label,"col":4}}) + if l.get_invalid_links()[0]: + invalid_links.append(l) + for l in invalid_links: + del doc.links[doc.links.index(l)] + return content + +def update_wspace(doc, seq, content): + if not doc.title and not doc.content and not doc.is_standard and not doc.public: + doc.sequence_id = seq + 1 + doc.content = json.dumps(content) + doc.public = 0 if doc.for_user else 1 + doc.title = doc.extends or doc.label + doc.extends = '' + doc.category = '' + doc.onboarding = '' + doc.extends_another_page = 0 + doc.is_default = 0 + doc.is_standard = 0 + doc.developer_mode_only = 0 + doc.disable_user_customization = 0 + doc.pin_to_top = 0 + doc.pin_to_bottom = 0 + doc.hide_custom = 0 + doc.save(ignore_permissions=True) \ No newline at end of file diff --git a/frappe/permissions.py b/frappe/permissions.py index 07b4a2e68f..96e1910462 100644 --- a/frappe/permissions.py +++ b/frappe/permissions.py @@ -1,15 +1,17 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import copy import frappe import frappe.share from frappe import _, msgprint from frappe.utils import cint +from frappe.query_builder import DocType rights = ("select", "read", "write", "create", "delete", "submit", "cancel", "amend", "print", "email", "report", "import", "export", "set_user_permissions", "share") + def check_admin_or_system_manager(user=None): if not user: user = frappe.session.user @@ -32,7 +34,7 @@ def print_has_permission_check_logs(func): return inner @print_has_permission_check_logs -def has_permission(doctype, ptype="read", doc=None, verbose=False, user=None, raise_exception=True): +def has_permission(doctype, ptype="read", doc=None, verbose=False, user=None, raise_exception=True, parent_doctype=None): """Returns True if user has permission `ptype` for given `doctype`. If `doc` is passed, it also checks user, share and owner permissions. @@ -45,11 +47,12 @@ def has_permission(doctype, ptype="read", doc=None, verbose=False, user=None, ra doc = doctype doctype = doc.doctype - if frappe.is_table(doctype): + if user == "Administrator": return True - if user=="Administrator": - return True + if frappe.is_table(doctype): + return has_child_table_permission(doctype, ptype, doc, verbose, + user, raise_exception, parent_doctype) meta = frappe.get_meta(doctype) @@ -94,7 +97,7 @@ def has_permission(doctype, ptype="read", doc=None, verbose=False, user=None, ra if not perm: perm = false_if_not_shared() - return perm + return bool(perm) def get_doc_permissions(doc, user=None, ptype=None): """Returns a dict of evaluated permissions for given `doc` like `{"read":1, "write":1}`""" @@ -105,13 +108,9 @@ def get_doc_permissions(doc, user=None, ptype=None): meta = frappe.get_meta(doc.doctype) def is_user_owner(): - doc_owner = doc.get('owner') or '' - doc_owner = doc_owner.lower() - session_user = frappe.session.user.lower() - return doc_owner == session_user + return (doc.get("owner") or "").lower() == frappe.session.user.lower() - - if has_controller_permissions(doc, ptype, user=user) == False : + if has_controller_permissions(doc, ptype, user=user) is False: push_perm_check_log('Not allowed via controller permission check') return {ptype: 0} @@ -180,22 +179,23 @@ def get_role_permissions(doctype_meta, user=None, is_owner=None): applicable_permissions = list(filter(is_perm_applicable, getattr(doctype_meta, 'permissions', []))) has_if_owner_enabled = any(p.get('if_owner', 0) for p in applicable_permissions) - perms['has_if_owner_enabled'] = has_if_owner_enabled for ptype in rights: pvalue = any(p.get(ptype, 0) for p in applicable_permissions) # check if any perm object allows perm type perms[ptype] = cint(pvalue) - if (pvalue - and has_if_owner_enabled - and not has_permission_without_if_owner_enabled(ptype) - and ptype != 'create'): + if ( + pvalue + and has_if_owner_enabled + and not has_permission_without_if_owner_enabled(ptype) + and ptype != 'create' + ): perms['if_owner'][ptype] = cint(pvalue and is_owner) # has no access if not owner # 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 in ['select', 'read'] else 0 + perms[ptype] = 1 if ptype in ('select', 'read') else 0 frappe.local.role_permissions[cache_key] = perms @@ -299,7 +299,7 @@ def has_controller_permissions(doc, ptype, user=None): if not methods: return None - for method in methods: + for method in reversed(methods): controller_permission = frappe.call(frappe.get_attr(method), doc=doc, ptype=ptype, user=user) if controller_permission is not None: return controller_permission @@ -331,8 +331,7 @@ def get_all_perms(role): '''Returns valid permissions for a given role''' perms = frappe.get_all('DocPerm', fields='*', filters=dict(role=role)) custom_perms = frappe.get_all('Custom DocPerm', fields='*', filters=dict(role=role)) - doctypes_with_custom_perms = frappe.db.sql_list("""select distinct parent - from `tabCustom DocPerm`""") + doctypes_with_custom_perms = frappe.get_all("Custom DocPerm", pluck="parent", distinct=True) for p in perms: if p.parent not in doctypes_with_custom_perms: @@ -349,10 +348,13 @@ def get_roles(user=None, with_standard=True): def get(): if user == 'Administrator': - return [r[0] for r in frappe.db.sql("select name from `tabRole`")] # return all available roles + return frappe.get_all("Role", pluck="name") # return all available roles else: - return [r[0] for r in frappe.db.sql("""select role from `tabHas Role` - where parent=%s and role not in ('All', 'Guest')""", (user,))] + ['All', 'Guest'] + table = DocType("Has Role") + roles = frappe.qb.from_(table).where( + (table.parent == user) & (table.role.notin(["All", "Guest"])) + ).select(table.role).run(pluck=True) + return roles + ['All', 'Guest'] roles = frappe.cache().hget("roles", user, get) @@ -461,10 +463,9 @@ def update_permission_property(doctype, role, permlevel, ptype, value=None, vali name = frappe.get_value('Custom DocPerm', dict(parent=doctype, role=role, permlevel=permlevel)) + table = DocType("Custom DocPerm") + frappe.qb.update(table).set(ptype, value).where(table.name == name).run() - frappe.db.sql(""" - update `tabCustom DocPerm` - set `{0}`=%s where name=%s""".format(ptype), (value, name)) if validate: validate_permissions_for_doctype(doctype) @@ -516,8 +517,7 @@ def reset_perms(doctype): """Reset permissions for given doctype.""" from frappe.desk.notifications import delete_notification_count_for delete_notification_count_for(doctype) - - frappe.db.sql("""delete from `tabCustom DocPerm` where parent=%s""", doctype) + frappe.db.delete("Custom DocPerm", {"parent": doctype}) def get_linked_doctypes(dt): return list(set([dt] + [d.options for d in @@ -561,3 +561,35 @@ def filter_allowed_docs_for_doctype(user_permissions, doctype, with_default_doc= def push_perm_check_log(log): if frappe.flags.get('has_permission_check_logs') == None: return frappe.flags.get('has_permission_check_logs').append(_(log)) + +def has_child_table_permission(child_doctype, ptype="read", child_doc=None, + verbose=False, user=None, raise_exception=True, parent_doctype=None): + parent_doc = None + + if child_doc: + parent_doctype = child_doc.get("parenttype") + parent_doc = frappe.get_cached_doc({ + "doctype": parent_doctype, + "docname": child_doc.get("parent") + }) + + if parent_doctype: + if not is_parent_valid(child_doctype, parent_doctype): + frappe.throw(_("{0} is not a valid parent DocType for {1}").format( + frappe.bold(parent_doctype), + frappe.bold(child_doctype) + ), title=_("Invalid Parent DocType")) + else: + frappe.throw(_("Please specify a valid parent DocType for {0}").format( + frappe.bold(child_doctype) + ), title=_("Parent DocType Required")) + + return has_permission(parent_doctype, ptype=ptype, doc=parent_doc, + verbose=verbose, user=user, raise_exception=raise_exception) + + +def is_parent_valid(child_doctype, parent_doctype): + from frappe.core.utils import find + parent_meta = frappe.get_meta(parent_doctype) + child_table_field_exists = find(parent_meta.get_table_fields(), lambda d: d.options == child_doctype) + return not parent_meta.istable and child_table_field_exists \ No newline at end of file diff --git a/frappe/printing/doctype/letter_head/letter_head.json b/frappe/printing/doctype/letter_head/letter_head.json index f6c9def567..f723a6b489 100644 --- a/frappe/printing/doctype/letter_head/letter_head.json +++ b/frappe/printing/doctype/letter_head/letter_head.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_rename": 1, "autoname": "field:letter_head_name", "creation": "2012-11-22 17:45:46", @@ -13,6 +14,9 @@ "is_default", "letter_head_image_section", "image", + "image_height", + "image_width", + "align", "header_section", "content", "footer_section", @@ -100,15 +104,34 @@ "fieldname": "footer", "fieldtype": "HTML Editor", "label": "Footer HTML" + }, + { + "default": "Left", + "fieldname": "align", + "fieldtype": "Select", + "label": "Align", + "options": "Left\nRight\nCenter" + }, + { + "fieldname": "image_height", + "fieldtype": "Float", + "label": "Image Height" + }, + { + "fieldname": "image_width", + "fieldtype": "Float", + "label": "Image Width" } ], "icon": "fa fa-font", "idx": 1, + "links": [], "max_attachments": 3, - "modified": "2019-11-11 18:46:43.375120", + "modified": "2021-10-03 14:37:58.314696", "modified_by": "Administrator", "module": "Printing", "name": "Letter Head", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { diff --git a/frappe/printing/doctype/letter_head/letter_head.py b/frappe/printing/doctype/letter_head/letter_head.py index 948be60b88..67c0d236e0 100644 --- a/frappe/printing/doctype/letter_head/letter_head.py +++ b/frappe/printing/doctype/letter_head/letter_head.py @@ -1,8 +1,8 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe -from frappe.utils import is_image +from frappe.utils import is_image, flt from frappe.model.document import Document from frappe import _ @@ -26,7 +26,15 @@ class LetterHead(Document): def set_image(self): if self.source=='Image': if self.image and is_image(self.image): - self.content = ''.format(self.image) + self.image_width = flt(self.image_width) + self.image_height = flt(self.image_height) + dimension = 'width' if self.image_width > self.image_height else 'height' + dimension_value = self.get('image_' + dimension) + self.content = f''' +
+ {self.name} +
+ ''' frappe.msgprint(frappe._('Header HTML set from attachment {0}').format(self.image), alert = True) else: frappe.msgprint(frappe._('Please attach an image file to set HTML'), alert = True, indicator = 'orange') diff --git a/frappe/printing/doctype/letter_head/test_letter_head.py b/frappe/printing/doctype/letter_head/test_letter_head.py index 96dfc68705..67d307ee8b 100644 --- a/frappe/printing/doctype/letter_head/test_letter_head.py +++ b/frappe/printing/doctype/letter_head/test_letter_head.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/printing/doctype/network_printer_settings/__init__.py b/frappe/printing/doctype/network_printer_settings/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/printing/doctype/network_printer_settings/network_printer_settings.js b/frappe/printing/doctype/network_printer_settings/network_printer_settings.js new file mode 100644 index 0000000000..043afd388f --- /dev/null +++ b/frappe/printing/doctype/network_printer_settings/network_printer_settings.js @@ -0,0 +1,29 @@ +// Copyright (c) 2021, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Network Printer Settings', { + onload (frm) { + frm.trigger("connect_print_server"); + }, + server_ip (frm) { + frm.trigger("connect_print_server"); + }, + port (frm) { + frm.trigger("connect_print_server"); + }, + connect_print_server (frm) { + if (frm.doc.server_ip && frm.doc.port) { + frappe.call({ + "doc": frm.doc, + "method": "get_printers_list", + "args": { + ip: frm.doc.server_ip, + port: frm.doc.port + }, + callback: function(data) { + frm.set_df_property('printer_name', 'options', [""].concat(data.message)); + } + }); + } + } +}); diff --git a/frappe/printing/doctype/network_printer_settings/network_printer_settings.json b/frappe/printing/doctype/network_printer_settings/network_printer_settings.json new file mode 100644 index 0000000000..11f1382225 --- /dev/null +++ b/frappe/printing/doctype/network_printer_settings/network_printer_settings.json @@ -0,0 +1,76 @@ +{ + "actions": [], + "autoname": "Prompt", + "creation": "2021-09-17 11:26:06.943999", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "server_ip", + "port", + "column_break_4", + "printer_name" + ], + "fields": [ + { + "default": "localhost", + "fieldname": "server_ip", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Server IP", + "reqd": 1 + }, + { + "default": "631", + "fieldname": "port", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Port", + "reqd": 1 + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "fieldname": "printer_name", + "fieldtype": "Select", + "label": "Printer Name", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-10-07 11:23:13.799402", + "modified_by": "Administrator", + "module": "Printing", + "name": "Network Printer Settings", + "naming_rule": "Set by user", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "All", + "share": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/printing/doctype/network_printer_settings/network_printer_settings.py b/frappe/printing/doctype/network_printer_settings/network_printer_settings.py new file mode 100644 index 0000000000..e42ed818c7 --- /dev/null +++ b/frappe/printing/doctype/network_printer_settings/network_printer_settings.py @@ -0,0 +1,37 @@ +# Copyright (c) 2021, Frappe Technologies and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document +from frappe import _ + +class NetworkPrinterSettings(Document): + @frappe.whitelist() + def get_printers_list(self,ip="localhost",port=631): + printer_list = [] + try: + import cups + except ImportError: + frappe.throw(_('''This feature can not be used as dependencies are missing. + Please contact your system manager to enable this by installing pycups!''')) + return + try: + cups.setServer(self.server_ip) + cups.setPort(self.port) + conn = cups.Connection() + printers = conn.getPrinters() + for printer_id,printer in printers.items(): + printer_list.append({ + 'value': printer_id, + 'label': printer['printer-make-and-model'] + }) + + except RuntimeError: + frappe.throw(_("Failed to connect to server")) + except frappe.ValidationError: + frappe.throw(_("Failed to connect to server")) + return printer_list + +@frappe.whitelist() +def get_network_printer_settings(): + return frappe.db.get_list('Network Printer Settings', pluck='name') diff --git a/frappe/printing/doctype/network_printer_settings/test_network_printer_settings.py b/frappe/printing/doctype/network_printer_settings/test_network_printer_settings.py new file mode 100644 index 0000000000..86509b239f --- /dev/null +++ b/frappe/printing/doctype/network_printer_settings/test_network_printer_settings.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies and Contributors +# See license.txt + +# import frappe +import unittest + +class TestNetworkPrinterSettings(unittest.TestCase): + pass diff --git a/frappe/printing/doctype/print_format/print_format.js b/frappe/printing/doctype/print_format/print_format.js index 786f8f97ab..3fd1d9d148 100644 --- a/frappe/printing/doctype/print_format/print_format.js +++ b/frappe/printing/doctype/print_format/print_format.js @@ -30,27 +30,33 @@ frappe.ui.form.on("Print Format", { frappe.msgprint(__("Please select DocType first")); return; } - frappe.set_route("print-format-builder", frm.doc.name); + if (frm.doc.print_format_builder_beta) { + frappe.set_route("print-format-builder-beta", frm.doc.name); + } else { + frappe.set_route("print-format-builder", frm.doc.name); + } }); } else if (frm.doc.custom_format && !frm.doc.raw_printing) { frm.set_df_property("html", "reqd", 1); } - frappe.db.get_value('DocType', frm.doc.doc_type, 'default_print_format', (r) => { - if (r.default_print_format != frm.doc.name) { - frm.add_custom_button(__("Set as Default"), function () { - frappe.call({ - method: "frappe.printing.doctype.print_format.print_format.make_default", - args: { - name: frm.doc.name - }, - callback: function() { - frm.refresh(); - } + if (frappe.model.can_read(frm.doc.doc_type)) { + frappe.db.get_value('DocType', frm.doc.doc_type, 'default_print_format', (r) => { + if (r.default_print_format != frm.doc.name) { + frm.add_custom_button(__("Set as Default"), function () { + frappe.call({ + method: "frappe.printing.doctype.print_format.print_format.make_default", + args: { + name: frm.doc.name + }, + callback: function() { + frm.refresh(); + } + }); }); - }); - } - }); + } + }); + } } }, custom_format: function (frm) { diff --git a/frappe/printing/doctype/print_format/print_format.json b/frappe/printing/doctype/print_format/print_format.json index 4032cef209..75ec0fa7fd 100644 --- a/frappe/printing/doctype/print_format/print_format.json +++ b/frappe/printing/doctype/print_format/print_format.json @@ -19,19 +19,26 @@ "html", "raw_commands", "section_break_9", + "margin_top", + "margin_bottom", + "margin_left", + "margin_right", "align_labels_right", "show_section_headings", "line_breaks", "absolute_value", "column_break_11", + "font_size", "font", + "page_number", "css_section", "css", "custom_html_help", "section_break_13", "print_format_help", "format_data", - "print_format_builder" + "print_format_builder", + "print_format_builder_beta" ], "fields": [ { @@ -149,12 +156,10 @@ "options": "Language" }, { - "default": "Default", "depends_on": "eval:!doc.custom_format", "fieldname": "font", - "fieldtype": "Select", - "label": "Font", - "options": "Default\nHelvetica Neue\nArial\nHelvetica\nVerdana\nMonospace" + "fieldtype": "Data", + "label": "Google Font" }, { "depends_on": "eval:!doc.raw_printing", @@ -205,16 +210,60 @@ "fieldname": "absolute_value", "fieldtype": "Check", "label": "Show Absolute Values" + }, + { + "default": "0", + "fieldname": "print_format_builder_beta", + "fieldtype": "Check", + "label": "Print Format Builder Beta" + }, + { + "default": "15", + "fieldname": "margin_top", + "fieldtype": "Float", + "label": "Margin Top" + }, + { + "default": "15", + "fieldname": "margin_bottom", + "fieldtype": "Float", + "label": "Margin Bottom" + }, + { + "default": "15", + "fieldname": "margin_left", + "fieldtype": "Float", + "label": "Margin Left" + }, + { + "default": "15", + "fieldname": "margin_right", + "fieldtype": "Float", + "label": "Margin Right" + }, + { + "default": "14", + "fieldname": "font_size", + "fieldtype": "Int", + "label": "Font Size" + }, + { + "default": "Hide", + "fieldname": "page_number", + "fieldtype": "Select", + "label": "Page Number", + "options": "Hide\nTop Left\nTop Center\nTop Right\nBottom Left\nBottom Center\nBottom Right" } ], "icon": "fa fa-print", "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2021-03-01 15:25:46.578863", + "modified": "2021-10-12 17:52:41.167107", "modified_by": "Administrator", "module": "Printing", "name": "Print Format", + "naming_rule": "Set by user", "owner": "Administrator", "permissions": [ { diff --git a/frappe/printing/doctype/print_format/print_format.py b/frappe/printing/doctype/print_format/print_format.py index 5d4ff92fe2..f19c0af9bf 100644 --- a/frappe/printing/doctype/print_format/print_format.py +++ b/frappe/printing/doctype/print_format/print_format.py @@ -1,16 +1,30 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe import frappe.utils import json from frappe import _ from frappe.utils.jinja import validate_template - +from frappe.utils.weasyprint import get_html, download_pdf from frappe.model.document import Document class PrintFormat(Document): + def onload(self): + templates = frappe.db.get_all( + "Print Format Field Template", + fields=["template", "field", "name"], + filters={"document_type": self.doc_type}, + ) + self.set_onload("print_templates", templates) + + def get_html(self, docname, letterhead=None): + return get_html(self.doc_type, docname, self.name, letterhead) + + def download_pdf(self, docname, letterhead=None): + return download_pdf(self.doc_type, docname, self.name, letterhead) + def validate(self): if (self.standard=="Yes" and not frappe.local.conf.get("developer_mode") @@ -38,6 +52,10 @@ class PrintFormat(Document): def extract_images(self): from frappe.core.doctype.file.file import extract_images_from_html + + if self.print_format_builder_beta: + return + if self.format_data: data = json.loads(self.format_data) for df in data: diff --git a/frappe/printing/doctype/print_format/test_print_format.py b/frappe/printing/doctype/print_format/test_print_format.py index e65eb0183f..564a2c750c 100644 --- a/frappe/printing/doctype/print_format/test_print_format.py +++ b/frappe/printing/doctype/print_format/test_print_format.py @@ -1,5 +1,5 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest import re diff --git a/frappe/printing/doctype/print_format_field_template/__init__.py b/frappe/printing/doctype/print_format_field_template/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/printing/doctype/print_format_field_template/print_format_field_template.js b/frappe/printing/doctype/print_format_field_template/print_format_field_template.js new file mode 100644 index 0000000000..7fbb0d7359 --- /dev/null +++ b/frappe/printing/doctype/print_format_field_template/print_format_field_template.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Print Format Field Template', { + // refresh: function(frm) { + + // } +}); diff --git a/frappe/printing/doctype/print_format_field_template/print_format_field_template.json b/frappe/printing/doctype/print_format_field_template/print_format_field_template.json new file mode 100644 index 0000000000..3b79aae7e8 --- /dev/null +++ b/frappe/printing/doctype/print_format_field_template/print_format_field_template.json @@ -0,0 +1,101 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "Prompt", + "creation": "2021-10-05 14:23:56.508499", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "document_type", + "field", + "template_file", + "column_break_3", + "module", + "standard", + "section_break_5", + "template" + ], + "fields": [ + { + "depends_on": "eval:!doc.multiple", + "fieldname": "document_type", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Document Type", + "mandatory_depends_on": "eval:!doc.multiple", + "options": "DocType", + "reqd": 1 + }, + { + "fieldname": "field", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Default Template For Field" + }, + { + "depends_on": "eval:!doc.standard", + "fieldname": "template", + "fieldtype": "Code", + "label": "Template", + "mandatory_depends_on": "eval:!doc.standard", + "options": "HTML" + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_5", + "fieldtype": "Section Break", + "hide_border": 1 + }, + { + "depends_on": "standard", + "fieldname": "module", + "fieldtype": "Link", + "label": "Module", + "options": "Module Def" + }, + { + "default": "0", + "fieldname": "standard", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Standard" + }, + { + "depends_on": "eval:doc.standard", + "fieldname": "template_file", + "fieldtype": "Data", + "label": "Template File", + "mandatory_depends_on": "eval:doc.standard" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-10-19 17:47:59.577949", + "modified_by": "Administrator", + "module": "Printing", + "name": "Print Format Field Template", + "naming_rule": "Set by user", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/printing/doctype/print_format_field_template/print_format_field_template.py b/frappe/printing/doctype/print_format_field_template/print_format_field_template.py new file mode 100644 index 0000000000..b66afdb6b1 --- /dev/null +++ b/frappe/printing/doctype/print_format_field_template/print_format_field_template.py @@ -0,0 +1,43 @@ +# Copyright (c) 2021, Frappe Technologies and contributors +# For license information, please see license.txt + +import frappe +from frappe.model.document import Document +from frappe import _ + + +class PrintFormatFieldTemplate(Document): + def validate(self): + if self.standard and not (frappe.conf.developer_mode or frappe.flags.in_patch): + frappe.throw(_("Enable developer mode to create a standard Print Template")) + + def before_insert(self): + self.validate_duplicate() + + def on_update(self): + self.validate_duplicate() + self.export_doc() + + def validate_duplicate(self): + if not self.standard: + return + if not self.field: + return + + filters = {"document_type": self.document_type, "field": self.field} + if not self.is_new(): + filters.update({"name": ("!=", self.name)}) + result = frappe.db.get_all("Print Format Field Template", filters=filters, limit=1) + if result: + frappe.throw( + _("A template already exists for field {0} of {1}").format( + frappe.bold(self.field), frappe.bold(self.document_type) + ), + frappe.DuplicateEntryError, + title=_("Duplicate Entry"), + ) + + def export_doc(self): + from frappe.modules.utils import export_module_json + + export_module_json(self, self.standard, self.module) diff --git a/frappe/printing/doctype/print_format_field_template/test_print_format_field_template.py b/frappe/printing/doctype/print_format_field_template/test_print_format_field_template.py new file mode 100644 index 0000000000..f0b1329763 --- /dev/null +++ b/frappe/printing/doctype/print_format_field_template/test_print_format_field_template.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies and Contributors +# See license.txt + +# import frappe +import unittest + +class TestPrintFormatFieldTemplate(unittest.TestCase): + pass diff --git a/frappe/printing/doctype/print_heading/print_heading.py b/frappe/printing/doctype/print_heading/print_heading.py index f9955c019d..39c46ad152 100644 --- a/frappe/printing/doctype/print_heading/print_heading.py +++ b/frappe/printing/doctype/print_heading/print_heading.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/printing/doctype/print_heading/test_print_heading.py b/frappe/printing/doctype/print_heading/test_print_heading.py index ce99cde607..7eaa1bc6ba 100644 --- a/frappe/printing/doctype/print_heading/test_print_heading.py +++ b/frappe/printing/doctype/print_heading/test_print_heading.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/printing/doctype/print_settings/print_settings.js b/frappe/printing/doctype/print_settings/print_settings.js index 9616892a31..b1311166ee 100644 --- a/frappe/printing/doctype/print_settings/print_settings.js +++ b/frappe/printing/doctype/print_settings/print_settings.js @@ -15,27 +15,5 @@ frappe.ui.form.on('Print Settings', { }, onload: function(frm) { frm.script_manager.trigger("print_style"); - }, - server_ip: function(frm) { - frm.trigger("connect_print_server"); - }, - port:function(frm) { - frm.trigger("connect_print_server"); - }, - connect_print_server:function(frm) { - if(frm.doc.server_ip && frm.doc.port){ - frappe.call({ - "doc": frm.doc, - "method": "get_printers", - "args": { - ip: frm.doc.server_ip, - port: frm.doc.port - }, - callback: function(data) { - frm.set_df_property('printer_name', 'options', [""].concat(data.message)); - }, - error: (data) => frm.set_value("enable_print_server", 0) - }); - } } }); diff --git a/frappe/printing/doctype/print_settings/print_settings.json b/frappe/printing/doctype/print_settings/print_settings.json index d64cb4c6d3..babbae248d 100644 --- a/frappe/printing/doctype/print_settings/print_settings.json +++ b/frappe/printing/doctype/print_settings/print_settings.json @@ -19,9 +19,6 @@ "allow_print_for_cancelled", "server_printer", "enable_print_server", - "server_ip", - "printer_name", - "port", "raw_printing_section", "enable_raw_printing", "print_style_section", @@ -107,29 +104,11 @@ }, { "default": "0", + "depends_on": "enable_print_server", "fieldname": "enable_print_server", "fieldtype": "Check", - "label": "Enable Print Server" - }, - { - "default": "localhost", - "depends_on": "enable_print_server", - "fieldname": "server_ip", - "fieldtype": "Data", - "label": "Server IP" - }, - { - "depends_on": "enable_print_server", - "fieldname": "printer_name", - "fieldtype": "Select", - "label": "Printer Name" - }, - { - "default": "631", - "depends_on": "enable_print_server", - "fieldname": "port", - "fieldtype": "Int", - "label": "Port" + "label": "Enable Print Server", + "mandatory_depends_on": "enable_print_server" }, { "fieldname": "raw_printing_section", @@ -148,7 +127,7 @@ "label": "Print Style" }, { - "default": "Modern", + "default": "Redesign", "fieldname": "print_style", "fieldtype": "Link", "in_list_view": 1, @@ -183,7 +162,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2020-10-22 23:42:09.471022", + "modified": "2021-09-17 12:59:14.783694", "modified_by": "Administrator", "module": "Printing", "name": "Print Settings", diff --git a/frappe/printing/doctype/print_settings/print_settings.py b/frappe/printing/doctype/print_settings/print_settings.py index 610c083097..ff00317cf8 100644 --- a/frappe/printing/doctype/print_settings/print_settings.py +++ b/frappe/printing/doctype/print_settings/print_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2018, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe import _ @@ -12,26 +12,6 @@ class PrintSettings(Document): def on_update(self): frappe.clear_cache() - @frappe.whitelist() - def get_printers(self,ip="localhost",port=631): - printer_list = [] - try: - import cups - except ImportError: - frappe.throw(_("You need to install pycups to use this feature!")) - return - try: - cups.setServer(self.server_ip) - cups.setPort(self.port) - conn = cups.Connection() - printers = conn.getPrinters() - printer_list = printers.keys() - except RuntimeError: - frappe.throw(_("Failed to connect to server")) - except frappe.ValidationError: - frappe.throw(_("Failed to connect to server")) - return printer_list - @frappe.whitelist() def is_print_server_enabled(): if not hasattr(frappe.local, 'enable_print_server'): diff --git a/frappe/printing/doctype/print_settings/test_print_settings.py b/frappe/printing/doctype/print_settings/test_print_settings.py index d1dec861b2..82883eaee5 100644 --- a/frappe/printing/doctype/print_settings/test_print_settings.py +++ b/frappe/printing/doctype/print_settings/test_print_settings.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2018, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import unittest class TestPrintSettings(unittest.TestCase): diff --git a/frappe/printing/doctype/print_style/print_style.py b/frappe/printing/doctype/print_style/print_style.py index a91786795c..7985c006f4 100644 --- a/frappe/printing/doctype/print_style/print_style.py +++ b/frappe/printing/doctype/print_style/print_style.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe from frappe.model.document import Document diff --git a/frappe/printing/doctype/print_style/test_print_style.py b/frappe/printing/doctype/print_style/test_print_style.py index b717b23df8..cbf5c465d1 100644 --- a/frappe/printing/doctype/print_style/test_print_style.py +++ b/frappe/printing/doctype/print_style/test_print_style.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Copyright (c) 2017, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe import unittest diff --git a/frappe/printing/page/print/print.js b/frappe/printing/page/print/print.js index 233bbe0ce7..f10c703589 100644 --- a/frappe/printing/page/print/print.js +++ b/frappe/printing/page/print/print.js @@ -41,7 +41,11 @@ frappe.ui.form.PrintView = class {
- ` + +
+ +
+ ` ); this.print_settings = frappe.model.get_doc( @@ -72,7 +76,7 @@ frappe.ui.form.PrintView = class { this.page.add_button( __('PDF'), - () => this.render_page('/api/method/frappe.utils.print_format.download_pdf?'), + () => this.render_pdf(), { icon: 'small-file' } ); @@ -113,22 +117,20 @@ frappe.ui.form.PrintView = class { }, ).$input; - this.letterhead_selector = this.add_sidebar_item( + this.letterhead_selector_df = this.add_sidebar_item( { - fieldtype: 'Select', + fieldtype: 'Autocomplete', fieldname: 'letterhead', label: __('Select Letterhead'), - options: [ - this.get_default_option_for_select(__('Select Letterhead')), - __('No Letterhead') - ], + placeholder: __('Select Letterhead'), + options: [__('No Letterhead')], change: () => this.preview(), default: this.print_settings.with_letterhead ? __('No Letterhead') : __('Select Letterhead') }, - ).$input; - + ); + this.letterhead_selector = this.letterhead_selector_df.$input; this.sidebar_dynamic_section = $( `
` ).appendTo(this.sidebar); @@ -136,7 +138,7 @@ frappe.ui.form.PrintView = class { add_sidebar_item(df, is_dynamic) { if (df.fieldtype == 'Select') { - df.input_class = 'btn btn-default btn-sm'; + df.input_class = 'btn btn-default btn-sm text-left'; } let field = frappe.ui.form.make_control({ @@ -167,20 +169,23 @@ frappe.ui.form.PrintView = class { frappe.set_route('Form', 'Print Settings'); }); - if ( - frappe.model.get_doc(':Print Settings', 'Print Settings') - .enable_raw_printing == '1' - ) { + if (this.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')) { + if (frappe.model.can_create('Print Format')) { this.page.add_menu_item(__('Customize'), () => this.edit_print_format() ); } + + if (cint(this.print_settings.enable_print_server)) { + this.page.add_menu_item(__('Select Network Printer'), () => + this.network_printer_setting_dialog() + ); + } } show(frm) { @@ -189,6 +194,13 @@ frappe.ui.form.PrintView = class { this.set_breadcrumbs(); this.setup_customize_dialog(); + // print format builder beta + this.page.add_inner_message(` + + ${__('Try the new Print Format Builder')} + + `); + let tasks = [ this.refresh_print_options, this.set_default_print_language, @@ -232,7 +244,7 @@ frappe.ui.form.PrintView = class { let print_format = this.get_print_format(); let is_custom_format = print_format.name && - print_format.print_format_builder && + (print_format.print_format_builder || print_format.print_format_builder_beta) && print_format.standard === 'No'; let is_standard_but_editable = print_format.name && print_format.custom_format; @@ -242,7 +254,11 @@ frappe.ui.form.PrintView = class { return; } if (is_custom_format) { - frappe.set_route('print-format-builder', print_format.name); + if (print_format.print_format_builder_beta) { + frappe.set_route('print-format-builder-beta', print_format.name); + } else { + frappe.set_route('print-format-builder', print_format.name); + } return; } // start a new print format @@ -260,6 +276,11 @@ frappe.ui.form.PrintView = class { fieldtype: 'Read Only', default: print_format.name || 'Standard', }, + { + label: __('Use the new Print Format Builder'), + fieldname: 'beta', + fieldtype: 'Check' + }, ], (data) => { frappe.route_options = { @@ -267,6 +288,7 @@ frappe.ui.form.PrintView = class { doctype: this.frm.doctype, name: data.print_format_name, based_on: data.based_on, + beta: data.beta }; frappe.set_route('print-format-builder'); this.print_sel.val(data.print_format_name); @@ -336,23 +358,19 @@ frappe.ui.form.PrintView = class { } set_letterhead_options() { - let letterhead_options = [ - this.get_default_option_for_select(__('Select Letterhead')), - __('No Letterhead') - ]; + let letterhead_options = [__('No Letterhead')]; let default_letterhead; let doc_letterhead = this.frm.doc.letter_head; return frappe.db - .get_list('Letter Head', { fields: ['name', 'is_default'] }) + .get_list('Letter Head', { fields: ['name', 'is_default'], limit: 0 }) .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); + this.letterhead_selector_df.set_data(letterhead_options); let selected_letterhead = doc_letterhead || default_letterhead; if (selected_letterhead) this.letterhead_selector.val(selected_letterhead); @@ -383,6 +401,17 @@ frappe.ui.form.PrintView = class { } preview() { + let print_format = this.get_print_format(); + if (print_format.print_format_builder_beta) { + this.print_wrapper.find('.print-preview-wrapper').hide(); + this.print_wrapper.find('.preview-beta-wrapper').show(); + this.preview_beta(); + return; + } + + this.print_wrapper.find('.preview-beta-wrapper').hide(); + this.print_wrapper.find('.print-preview-wrapper').show(); + const $print_format = this.print_wrapper.find('iframe'); this.$print_format_body = $print_format.contents(); this.get_print_html((out) => { @@ -406,22 +435,32 @@ frappe.ui.form.PrintView = class { }); } + preview_beta() { + let print_format = this.get_print_format(); + const iframe = this.print_wrapper.find('.preview-beta-wrapper iframe'); + let params = new URLSearchParams({ + doctype: this.frm.doc.doctype, + name: this.frm.doc.name, + print_format: print_format.name + }); + let letterhead = this.get_letterhead(); + if (letterhead) { + params.append("letterhead", letterhead); + } + iframe.prop('src', `/printpreview?${params.toString()}`); + } + setup_print_format_dom(out, $print_format) { this.print_wrapper.find('.print-format-skeleton').remove(); let base_url = frappe.urllib.get_base_url(); - let print_css = frappe.assets.bundled_asset('print.bundle.css'); + let print_css = frappe.assets.bundled_asset('print.bundle.css', frappe.utils.is_rtl(this.lang_code)); + this.$print_format_body.find('html').attr('dir', frappe.utils.is_rtl(this.lang_code) ? 'rtl': 'ltr'); + this.$print_format_body.find('html').attr('lang', this.lang_code); this.$print_format_body.find('head').html( ` ` ); - if (frappe.utils.is_rtl(this.lang_code)) { - let rtl_css = frappe.assets.bundled_asset('frappe-rtl.bundle.css'); - this.$print_format_body.find('head').append( - `` - ); - } - this.$print_format_body.find('body').html( `` ); @@ -471,72 +510,128 @@ frappe.ui.form.PrintView = class { 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); - }); + + if (cint(me.print_settings.enable_print_server)) { + if (localStorage.getItem('network_printer')) { + me.print_by_server(); + } else { + me.network_printer_setting_dialog(() => me.print_by_server()); + } + } 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( + }); + } 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); + } + } + + print_by_server() { + let me = this; + if (localStorage.getItem('network_printer')) { + frappe.call({ + method: 'frappe.utils.print_format.print_by_server', + args: { + doctype: me.frm.doc.doctype, + name: me.frm.doc.name, + printer_setting: localStorage.getItem('network_printer'), + print_format: me.selected_format(), + no_letterhead: me.with_letterhead(), + letterhead: me.get_letterhead(), + }, + callback: function() {}, + }); + } + } + network_printer_setting_dialog(callback) { + frappe.call({ + method: 'frappe.printing.doctype.network_printer_settings.network_printer_settings.get_network_printer_settings', + callback: function(r) { + if (r.message) { + let d = new frappe.ui.Dialog({ + title: __('Select Network Printer'), + fields: [ { - 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', + "label": "Printer", + "fieldname": "printer", + "fieldtype": "Select", + "reqd": 1, + "options": r.message + } + ], + primary_action: function() { + localStorage.setItem('network_printer', d.get_values().printer); + if (typeof callback == "function") { + callback(); + } + d.hide(); }, - 14 - ); - me.printer_setting_dialog(); - } else { - me.render_page('/printview?', true); + primary_action_label: __('Select') + }); + d.show(); } }, }); } + render_pdf() { + let print_format = this.get_print_format(); + if (print_format.print_format_builder_beta) { + let params = new URLSearchParams({ + doctype: this.frm.doc.doctype, + name: this.frm.doc.name, + print_format: print_format.name, + letterhead: this.get_letterhead() + }); + let w = window.open(`/api/method/frappe.utils.weasyprint.download_pdf?${params}`); + if (!w) { + frappe.msgprint(__('Please enable pop-ups')); + return; + } + } else { + this.render_page('/api/method/frappe.utils.print_format.download_pdf?'); + } + } + render_page(method, printit = false) { let w = window.open( frappe.urllib.get_full_url( diff --git a/frappe/printing/page/print_format_builder/print_format_builder.js b/frappe/printing/page/print_format_builder/print_format_builder.js index ca2a8bc378..313e8da539 100644 --- a/frappe/printing/page/print_format_builder/print_format_builder.js +++ b/frappe/printing/page/print_format_builder/print_format_builder.js @@ -12,9 +12,9 @@ frappe.pages['print-format-builder'].on_page_show = function(wrapper) { }); } else if(frappe.route_options) { if(frappe.route_options.make_new) { - let { doctype, name, based_on } = frappe.route_options; + let { doctype, name, based_on, beta } = frappe.route_options; frappe.route_options = null; - frappe.print_format_builder.setup_new_print_format(doctype, name, based_on); + frappe.print_format_builder.setup_new_print_format(doctype, name, based_on, beta); } else { frappe.print_format_builder.print_format = frappe.route_options.doc; frappe.route_options = null; @@ -126,18 +126,22 @@ frappe.PrintFormatBuilder = class PrintFormatBuilder { }); } - setup_new_print_format(doctype, name, based_on) { + setup_new_print_format(doctype, name, based_on, beta) { frappe.call({ method: 'frappe.printing.page.print_format_builder.print_format_builder.create_custom_format', args: { doctype: doctype, name: name, - based_on: based_on + based_on: based_on, + beta: Boolean(beta) }, callback: (r) => { - if(!r.exc) { - if(r.message) { - this.print_format = r.message; + if (r.message) { + let print_format = r.message; + if (print_format.print_format_builder_beta) { + frappe.set_route('print-format-builder-beta', print_format.name); + } else { + this.print_format = print_format; this.refresh(); } } @@ -261,7 +265,7 @@ frappe.PrintFormatBuilder = class PrintFormatBuilder { } else if(f.fieldtype==="Column Break") { set_column(); - } else if(!in_list(["Section Break", "Column Break", "Fold"], f.fieldtype) + } else if (!in_list(["Section Break", "Column Break", "Tab Break", "Fold"], f.fieldtype) && f.label) { if(!column) set_column(); @@ -298,7 +302,7 @@ frappe.PrintFormatBuilder = class PrintFormatBuilder { init_visible_columns(f) { f.visible_columns = [] $.each(frappe.get_meta(f.options).fields, function(i, _f) { - if(!in_list(["Section Break", "Column Break"], _f.fieldtype) && + if (!in_list(["Section Break", "Column Break", "Tab Break"], _f.fieldtype) && !_f.print_hide && f.label) { // column names set as fieldname|width @@ -606,7 +610,7 @@ frappe.PrintFormatBuilder = class PrintFormatBuilder { // add remaining fields $.each(doc_fields, function(j, f) { if (f && !in_list(column_names, f.fieldname) - && !in_list(["Section Break", "Column Break"], f.fieldtype) && f.label) { + && !in_list(["Section Break", "Column Break", "Tab Break"], f.fieldtype) && f.label) { fields.push(f); } }) diff --git a/frappe/printing/page/print_format_builder/print_format_builder.py b/frappe/printing/page/print_format_builder/print_format_builder.py index d9f57762b0..fae564d3c3 100644 --- a/frappe/printing/page/print_format_builder/print_format_builder.py +++ b/frappe/printing/page/print_format_builder/print_format_builder.py @@ -1,11 +1,16 @@ import frappe @frappe.whitelist() -def create_custom_format(doctype, name, based_on='Standard'): +def create_custom_format(doctype, name, based_on='Standard', beta=False): doc = frappe.new_doc('Print Format') doc.doc_type = doctype doc.name = name - doc.print_format_builder = 1 + beta = frappe.parse_json(beta) + + if beta: + doc.print_format_builder_beta = 1 + else: + doc.print_format_builder = 1 doc.format_data = frappe.db.get_value('Print Format', based_on, 'format_data') \ if based_on != 'Standard' else None doc.insert() diff --git a/frappe/printing/page/print_format_builder/print_format_builder_sidebar.html b/frappe/printing/page/print_format_builder/print_format_builder_sidebar.html index 1ebb87ac31..c608eecbbd 100644 --- a/frappe/printing/page/print_format_builder/print_format_builder_sidebar.html +++ b/frappe/printing/page/print_format_builder/print_format_builder_sidebar.html @@ -4,7 +4,7 @@
Id + Time + State + Info + Progress +