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..aece5f543b 100644 --- a/.github/helper/documentation.py +++ b/.github/helper/documentation.py @@ -24,6 +24,8 @@ def docs_link_exists(body): parts = parsed_url.path.split('/') if len(parts) == 5 and parts[1] == "frappe" and parts[2] in docs_repos: return True + if parsed_url.netloc in ["docs.erpnext.com", "frappeframework.com"]: + return True if __name__ == "__main__": @@ -32,9 +34,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..19a7c68e19 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 update && 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 d9603e89aa..0000000000 --- a/.github/helper/semgrep_rules/frappe_correctness.yml +++ /dev/null @@ -1,133 +0,0 @@ -# This file specifies rules for correctness according to how frappe doctype data model works. - -rules: -- id: frappe-modifying-but-not-comitting - patterns: - - pattern: | - def $METHOD(self, ...): - ... - self.$ATTR = ... - - pattern-not: | - def $METHOD(self, ...): - ... - self.$ATTR = ... - ... - self.db_set(..., self.$ATTR, ...) - - pattern-not: | - def $METHOD(self, ...): - ... - self.$ATTR = $SOME_VAR - ... - self.db_set(..., $SOME_VAR, ...) - - pattern-not: | - def $METHOD(self, ...): - ... - self.$ATTR = $SOME_VAR - ... - self.save() - - metavariable-regex: - metavariable: '$ATTR' - # this is negative look-ahead, add more attrs to ignore like (ignore|ignore_this_too|ignore_me) - regex: '^(?!ignore_linked_doctypes|status_updater)(.*)$' - - metavariable-regex: - metavariable: "$METHOD" - regex: "(on_submit|on_cancel)" - message: | - DocType modified in self.$METHOD. Please check if modification of self.$ATTR is commited to database. - languages: [python] - severity: ERROR - -- id: frappe-modifying-but-not-comitting-other-method - patterns: - - pattern: | - class $DOCTYPE(...): - def $METHOD(self, ...): - ... - self.$ANOTHER_METHOD() - ... - - def $ANOTHER_METHOD(self, ...): - ... - self.$ATTR = ... - - pattern-not: | - class $DOCTYPE(...): - def $METHOD(self, ...): - ... - self.$ANOTHER_METHOD() - ... - - def $ANOTHER_METHOD(self, ...): - ... - self.$ATTR = ... - ... - self.db_set(..., self.$ATTR, ...) - - pattern-not: | - class $DOCTYPE(...): - def $METHOD(self, ...): - ... - self.$ANOTHER_METHOD() - ... - - def $ANOTHER_METHOD(self, ...): - ... - self.$ATTR = $SOME_VAR - ... - self.db_set(..., $SOME_VAR, ...) - - pattern-not: | - class $DOCTYPE(...): - def $METHOD(self, ...): - ... - self.$ANOTHER_METHOD() - ... - self.save() - def $ANOTHER_METHOD(self, ...): - ... - self.$ATTR = ... - - metavariable-regex: - metavariable: "$METHOD" - regex: "(on_submit|on_cancel)" - message: | - self.$ANOTHER_METHOD is called from self.$METHOD, check if changes to self.$ATTR are commited to database. - languages: [python] - severity: ERROR - -- id: frappe-print-function-in-doctypes - pattern: print(...) - message: | - Did you mean to leave this print statement in? Consider using msgprint or logger instead of print statement. - languages: [python] - severity: WARNING - paths: - include: - - "*/**/doctype/*" - -- id: frappe-modifying-child-tables-while-iterating - pattern-either: - - pattern: | - for $ROW in self.$TABLE: - ... - self.remove(...) - - pattern: | - for $ROW in self.$TABLE: - ... - self.append(...) - message: | - Child table being modified while iterating on it. - languages: [python] - severity: ERROR - paths: - include: - - "*/**/doctype/*" - -- id: frappe-same-key-assigned-twice - pattern-either: - - pattern: | - {..., $X: $A, ..., $X: $B, ...} - - pattern: | - dict(..., ($X, $A), ..., ($X, $B), ...) - - pattern: | - _dict(..., ($X, $A), ..., ($X, $B), ...) - message: | - key `$X` is uselessly assigned twice. This could be a potential bug. - languages: [python] - severity: ERROR diff --git a/.github/helper/semgrep_rules/security.py b/.github/helper/semgrep_rules/security.py deleted file mode 100644 index f477d7c176..0000000000 --- a/.github/helper/semgrep_rules/security.py +++ /dev/null @@ -1,6 +0,0 @@ -def function_name(input): - # ruleid: frappe-codeinjection-eval - eval(input) - -# ok: frappe-codeinjection-eval -eval("1 + 1") diff --git a/.github/helper/semgrep_rules/security.yml b/.github/helper/semgrep_rules/security.yml deleted file mode 100644 index 5a5098bf50..0000000000 --- a/.github/helper/semgrep_rules/security.yml +++ /dev/null @@ -1,25 +0,0 @@ -rules: -- id: frappe-codeinjection-eval - patterns: - - pattern-not: eval("...") - - pattern: eval(...) - message: | - Detected the use of eval(). eval() can be dangerous if used to evaluate - dynamic content. Avoid it or use safe_eval(). - languages: [python] - severity: ERROR - -- id: frappe-sqli-format-strings - patterns: - - pattern-inside: | - @frappe.whitelist() - def $FUNC(...): - ... - - pattern-either: - - pattern: frappe.db.sql("..." % ...) - - pattern: frappe.db.sql(f"...", ...) - - pattern: frappe.db.sql("...".format(...), ...) - message: | - Detected use of raw string formatting for SQL queries. This can lead to sql injection vulnerabilities. Refer security guidelines - https://github.com/frappe/erpnext/wiki/Code-Security-Guidelines - languages: [python] - severity: WARNING diff --git a/.github/helper/semgrep_rules/translate.js b/.github/helper/semgrep_rules/translate.js deleted file mode 100644 index 9cdfb75d0b..0000000000 --- a/.github/helper/semgrep_rules/translate.js +++ /dev/null @@ -1,44 +0,0 @@ -// ruleid: frappe-translation-empty-string -__("") -// ruleid: frappe-translation-empty-string -__('') - -// ok: frappe-translation-js-formatting -__('Welcome {0}, get started with ERPNext in just a few clicks.', [full_name]); - -// ruleid: frappe-translation-js-formatting -__(`Welcome ${full_name}, get started with ERPNext in just a few clicks.`); - -// ok: frappe-translation-js-formatting -__('This is fine'); - - -// ok: frappe-translation-trailing-spaces -__('This is fine'); - -// ruleid: frappe-translation-trailing-spaces -__(' this is not ok '); -// ruleid: frappe-translation-trailing-spaces -__('this is not ok '); -// ruleid: frappe-translation-trailing-spaces -__(' this is not ok'); - -// ok: frappe-translation-js-splitting -__('You have {0} subscribers in your mailing list.', [subscribers.length]) - -// todoruleid: frappe-translation-js-splitting -__('You have') + subscribers.length + __('subscribers in your mailing list.') - -// ruleid: frappe-translation-js-splitting -__('You have' + 'subscribers in your mailing list.') - -// ruleid: frappe-translation-js-splitting -__('You have {0} subscribers' + - 'in your mailing list', [subscribers.length]) - -// ok: frappe-translation-js-splitting -__("Ctrl+Enter to add comment") - -// ruleid: frappe-translation-js-splitting -__('You have {0} subscribers \ - in your mailing list', [subscribers.length]) diff --git a/.github/helper/semgrep_rules/translate.py b/.github/helper/semgrep_rules/translate.py deleted file mode 100644 index 9de6aa94f0..0000000000 --- a/.github/helper/semgrep_rules/translate.py +++ /dev/null @@ -1,61 +0,0 @@ -# Examples taken from https://frappeframework.com/docs/user/en/translations -# This file is used for testing the tests. - -from frappe import _ - -full_name = "Jon Doe" -# ok: frappe-translation-python-formatting -_('Welcome {0}, get started with ERPNext in just a few clicks.').format(full_name) - -# ruleid: frappe-translation-python-formatting -_('Welcome %s, get started with ERPNext in just a few clicks.' % full_name) -# ruleid: frappe-translation-python-formatting -_('Welcome %(name)s, get started with ERPNext in just a few clicks.' % {'name': full_name}) - -# ruleid: frappe-translation-python-formatting -_('Welcome {0}, get started with ERPNext in just a few clicks.'.format(full_name)) - - -subscribers = ["Jon", "Doe"] -# ok: frappe-translation-python-formatting -_('You have {0} subscribers in your mailing list.').format(len(subscribers)) - -# ruleid: frappe-translation-python-splitting -_('You have') + len(subscribers) + _('subscribers in your mailing list.') - -# ruleid: frappe-translation-python-splitting -_('You have {0} subscribers \ - in your mailing list').format(len(subscribers)) - -# ok: frappe-translation-python-splitting -_('You have {0} subscribers') \ - + 'in your mailing list' - -# ruleid: frappe-translation-trailing-spaces -msg = _(" You have {0} pending invoice ") -# ruleid: frappe-translation-trailing-spaces -msg = _("You have {0} pending invoice ") -# ruleid: frappe-translation-trailing-spaces -msg = _(" You have {0} pending invoice") - -# ok: frappe-translation-trailing-spaces -msg = ' ' + _("You have {0} pending invoices") + ' ' - -# ruleid: frappe-translation-python-formatting -_(f"can not format like this - {subscribers}") -# ruleid: frappe-translation-python-splitting -_(f"what" + f"this is also not cool") - - -# ruleid: frappe-translation-empty-string -_("") -# ruleid: frappe-translation-empty-string -_('') - - -class Test: - # ok: frappe-translation-python-splitting - def __init__( - args - ): - pass diff --git a/.github/helper/semgrep_rules/translate.yml b/.github/helper/semgrep_rules/translate.yml deleted file mode 100644 index 5f03fb9fd0..0000000000 --- a/.github/helper/semgrep_rules/translate.yml +++ /dev/null @@ -1,64 +0,0 @@ -rules: -- id: frappe-translation-empty-string - pattern-either: - - pattern: _("") - - pattern: __("") - message: | - Empty string is useless for translation. - Please refer: https://frappeframework.com/docs/user/en/translations - languages: [python, javascript, json] - severity: ERROR - -- id: frappe-translation-trailing-spaces - pattern-either: - - pattern: _("=~/(^[ \t]+|[ \t]+$)/") - - pattern: __("=~/(^[ \t]+|[ \t]+$)/") - message: | - Trailing or leading whitespace not allowed in translate strings. - Please refer: https://frappeframework.com/docs/user/en/translations - languages: [python, javascript, json] - severity: ERROR - -- id: frappe-translation-python-formatting - pattern-either: - - pattern: _("..." % ...) - - pattern: _("...".format(...)) - - pattern: _(f"...") - message: | - Only positional formatters are allowed and formatting should not be done before translating. - Please refer: https://frappeframework.com/docs/user/en/translations - languages: [python] - severity: ERROR - -- id: frappe-translation-js-formatting - patterns: - - pattern: __(`...`) - - pattern-not: __("...") - message: | - Template strings are not allowed for text formatting. - Please refer: https://frappeframework.com/docs/user/en/translations - languages: [javascript, json] - severity: ERROR - -- id: frappe-translation-python-splitting - pattern-either: - - pattern: _(...) + _(...) - - pattern: _("..." + "...") - - pattern-regex: '[\s\.]_\([^\)]*\\\s*' # lines broken by `\` - - pattern-regex: '[\s\.]_\(\s*\n' # line breaks allowed by python for using ( ) - message: | - Do not split strings inside translate function. Do not concatenate using translate functions. - Please refer: https://frappeframework.com/docs/user/en/translations - languages: [python] - severity: ERROR - -- id: frappe-translation-js-splitting - pattern-either: - - pattern-regex: '__\([^\)]*[\\]\s+' - - pattern: __('...' + '...', ...) - - pattern: __('...') + __('...') - message: | - Do not split strings inside translate function. Do not concatenate using translate functions. - Please refer: https://frappeframework.com/docs/user/en/translations - languages: [javascript, json] - severity: ERROR diff --git a/.github/helper/semgrep_rules/ux.js b/.github/helper/semgrep_rules/ux.js deleted file mode 100644 index ae73f9cc60..0000000000 --- a/.github/helper/semgrep_rules/ux.js +++ /dev/null @@ -1,9 +0,0 @@ - -// ok: frappe-missing-translate-function-js -frappe.msgprint('{{ _("Both login and password required") }}'); - -// ruleid: frappe-missing-translate-function-js -frappe.msgprint('What'); - -// ok: frappe-missing-translate-function-js -frappe.throw(' {{ _("Both login and password required") }}. '); diff --git a/.github/helper/semgrep_rules/ux.py b/.github/helper/semgrep_rules/ux.py deleted file mode 100644 index a00d3cd8ae..0000000000 --- a/.github/helper/semgrep_rules/ux.py +++ /dev/null @@ -1,31 +0,0 @@ -import frappe -from frappe import msgprint, throw, _ - - -# ruleid: frappe-missing-translate-function-python -throw("Error Occured") - -# ruleid: frappe-missing-translate-function-python -frappe.throw("Error Occured") - -# ruleid: frappe-missing-translate-function-python -frappe.msgprint("Useful message") - -# ruleid: frappe-missing-translate-function-python -msgprint("Useful message") - - -# ok: frappe-missing-translate-function-python -translatedmessage = _("Hello") - -# ok: frappe-missing-translate-function-python -throw(translatedmessage) - -# ok: frappe-missing-translate-function-python -msgprint(translatedmessage) - -# ok: frappe-missing-translate-function-python -msgprint(_("Helpful message")) - -# ok: frappe-missing-translate-function-python -frappe.throw(_("Error occured")) diff --git a/.github/helper/semgrep_rules/ux.yml b/.github/helper/semgrep_rules/ux.yml deleted file mode 100644 index dd667f36c0..0000000000 --- a/.github/helper/semgrep_rules/ux.yml +++ /dev/null @@ -1,30 +0,0 @@ -rules: -- id: frappe-missing-translate-function-python - pattern-either: - - patterns: - - pattern: frappe.msgprint("...", ...) - - pattern-not: frappe.msgprint(_("..."), ...) - - patterns: - - pattern: frappe.throw("...", ...) - - pattern-not: frappe.throw(_("..."), ...) - message: | - All user facing text must be wrapped in translate function. Please refer to translation documentation. https://frappeframework.com/docs/user/en/guides/basics/translations - languages: [python] - severity: ERROR - -- id: frappe-missing-translate-function-js - pattern-either: - - patterns: - - pattern: frappe.msgprint("...", ...) - - pattern-not: frappe.msgprint(__("..."), ...) - # ignore microtemplating e.g. msgprint("{{ _("server side translation") }}") - - pattern-not: frappe.msgprint("=~/\{\{.*\_.*\}\}/i", ...) - - patterns: - - pattern: frappe.throw("...", ...) - - pattern-not: frappe.throw(__("..."), ...) - # ignore microtemplating - - pattern-not: frappe.throw("=~/\{\{.*\_.*\}\}/i", ...) - message: | - All user facing text must be wrapped in translate function. Please refer to translation documentation. https://frappeframework.com/docs/user/en/guides/basics/translations - languages: [javascript] - severity: ERROR diff --git a/.github/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..c8294886a0 100644 --- a/.github/workflows/patch-mariadb-tests.yml +++ b/.github/workflows/patch-mariadb-tests.yml @@ -2,9 +2,15 @@ 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 + timeout-minutes: 60 name: Patch Test @@ -24,12 +30,29 @@ jobs: - name: Setup Python uses: actions/setup-python@v2 with: - python-version: 3.7 + python-version: '3.9' + + - name: Setup Node + uses: actions/setup-node@v2 + with: + node-version: 14 + check-latest: true + + - 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 +62,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 +75,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 +89,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 +97,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 e27b406df0..325411cf5c 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/semgrep.yml @@ -9,10 +9,14 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 + + - name: Download Semgrep rules + run: git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules + - uses: returntocorp/semgrep-action@v1 env: SEMGREP_TIMEOUT: 120 with: config: >- r/python.lang.correctness - .github/helper/semgrep_rules + ./frappe-semgrep-rules/rules diff --git a/.github/workflows/server-mariadb-tests.yml b/.github/workflows/server-mariadb-tests.yml index 2476102e3d..4edf74ba71 100644 --- a/.github/workflows/server-mariadb-tests.yml +++ b/.github/workflows/server-mariadb-tests.yml @@ -6,9 +6,15 @@ 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 + timeout-minutes: 60 strategy: fail-fast: false @@ -33,19 +39,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 +73,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 +86,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 +100,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 +108,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 diff --git a/.github/workflows/server-postgres-tests.yml b/.github/workflows/server-postgres-tests.yml index 4325eebaad..895af5184e 100644 --- a/.github/workflows/server-postgres-tests.yml +++ b/.github/workflows/server-postgres-tests.yml @@ -3,10 +3,17 @@ 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 + timeout-minutes: 60 strategy: fail-fast: false @@ -35,19 +42,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 +76,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 +89,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 +103,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 +111,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 f342c0709e..cb502f68a7 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -6,9 +6,14 @@ 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 + timeout-minutes: 60 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: "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 +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: Cache cypress binary + if: ${{ steps.check-build.outputs.build == 'strawberry' }} uses: actions/cache@v2 with: path: ~/.cache @@ -88,6 +109,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,15 +117,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..7e3d178630 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ dist/ frappe/docs/current frappe/public/dist .vscode +.vs node_modules .kdev4/ *.kdev4 @@ -67,6 +68,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 2dff157294..f7d759c123 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -3,14 +3,18 @@ # These owners will be the default owners for everything in # the repo. Unless a later match takes precedence, -* @frappe/frappe-review-team -templates/ @surajshetty3416 -www/ @surajshetty3416 -integrations/ @leela -patches/ @surajshetty3416 -email/ @leela -event_streaming/ @ruchamahabal -data_import* @netchampfaris -core/ @surajshetty3416 -requirements.txt @gavindsouza -commands/ @gavindsouza +* @frappe/frappe-review-team +templates/ @surajshetty3416 +www/ @surajshetty3416 +integrations/ @leela +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..bc59416d2f --- /dev/null +++ b/codecov.yml @@ -0,0 +1,36 @@ +codecov: + require_ci_to_pass: yes + +coverage: + status: + patch: off + project: + default: false + server: + target: auto + threshold: 0.5% + flags: + - server + patch: + default: false + server: + target: 85% + threshold: 0% + only_pulls: true + if_ci_failed: ignore + 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 e691150925..70e3f629e6 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 => { @@ -73,7 +73,7 @@ context('Control Link', () => { cy.get('.frappe-control[data-fieldname=link] input').type('{enter}', { delay: 100 }); 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('hash').should('eq', `#Form/ToDo/${todos[0]}`); @@ -110,4 +110,19 @@ context('Control Link', () => { }); }); }); + + 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 index d12be63f3b..ab7ada9034 100644 --- a/cypress/integration/form_tour.js +++ b/cypress/integration/form_tour.js @@ -9,7 +9,7 @@ context('Form Tour', () => { const open_test_form_tour = () => { cy.visit('/app/form-tour/Test Form Tour'); - cy.get('button[data-label="Show%20Tour"]').should('be.visible').and('contain', 'Show Tour').as('show_tour'); + cy.findByRole('button', {name: 'Show Tour'}).should('be.visible').as('show_tour'); cy.get('@show_tour').click(); cy.wait(500); cy.url().should('include', '/app/contact'); @@ -20,10 +20,10 @@ context('Form Tour', () => { it('navigates a form tour', () => { open_test_form_tour(); - cy.get('#driver-popover-item').should('be.visible'); + 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('.driver-next-btn').as('next_btn'); + 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(); @@ -39,7 +39,7 @@ context('Form Tour', () => { // assert field is highlighted cy.get('.frappe-control[data-fieldname="last_name"]').as('last_name'); cy.get('@last_name').should('have.class', 'driver-highlighted-element'); - + // after filling the field, next step should be highlighted cy.fill_field('last_name', 'Test Last Name', 'Data'); cy.wait(500); @@ -49,12 +49,12 @@ context('Form Tour', () => { // assert field is highlighted cy.get('.frappe-control[data-fieldname="phone_nos"]').as('phone_nos'); cy.get('@phone_nos').should('have.class', 'driver-highlighted-element'); - + // move to next step cy.wait(500); cy.get('@next_btn').click(); cy.wait(500); - + // assert add row btn is highlighted cy.get('@phone_nos').find('.grid-add-row').as('add_row'); cy.get('@add_row').should('have.class', 'driver-highlighted-element'); @@ -68,21 +68,21 @@ context('Form Tour', () => { 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 - cy.fill_table_field('phone_nos', '1', 'phone', '1234567890'); + let field = cy.fill_table_field('phone_nos', '1', 'phone', '1234567890'); + field.blur(); // move to collapse row step cy.wait(500); - cy.get('@next_btn').click(); + 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.get('@next_btn').should('contain', 'Save'); + cy.wait(500); + cy.get('.frappe-driver').findByRole('button', {name: 'Save'}).should('be.visible'); }); }); - \ No newline at end of file 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..84b3320282 100644 --- a/cypress/integration/grid_pagination.js +++ b/cypress/integration/grid_pagination.js @@ -13,7 +13,7 @@ context('Grid Pagination', () => { it('creates pages for child table', () => { cy.visit('/app/contact/Test Contact'); cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); - cy.get('@table').find('.current-page-number').should('contain', '1'); + cy.get('@table').find('.current-page-number').should('have.value', '1'); cy.get('@table').find('.total-page-number').should('contain', '20'); cy.get('@table').find('.grid-body .grid-row').should('have.length', 50); }); @@ -21,25 +21,46 @@ context('Grid Pagination', () => { cy.visit('/app/contact/Test Contact'); cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); cy.get('@table').find('.next-page').click(); - cy.get('@table').find('.current-page-number').should('contain', '2'); + cy.get('@table').find('.current-page-number').should('have.value', '2'); cy.get('@table').find('.grid-body .grid-row').first().should('have.attr', 'data-idx', '51'); cy.get('@table').find('.prev-page').click(); - cy.get('@table').find('.current-page-number').should('contain', '1'); + cy.get('@table').find('.current-page-number').should('have.value', '1'); cy.get('@table').find('.grid-body .grid-row').first().should('have.attr', 'data-idx', '1'); }); it('adds and deletes rows and changes page', () => { cy.visit('/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('.current-page-number').should('have.value', '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('.current-page-number').should('have.value', '20'); cy.get('@table').find('.total-page-number').should('contain', '20'); }); + it('go to specific page, use up and down arrow, type characters, 0 page and more than existing page', () => { + cy.visit('/app/contact/Test Contact'); + cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); + cy.get('@table').find('.current-page-number').focus().clear().type('17').blur(); + cy.get('@table').find('.grid-body .row-index').should('contain', 801); + + cy.get('@table').find('.current-page-number').focus().type('{uparrow}{uparrow}'); + cy.get('@table').find('.current-page-number').should('have.value', '19'); + + cy.get('@table').find('.current-page-number').focus().type('{downarrow}{downarrow}'); + cy.get('@table').find('.current-page-number').should('have.value', '17'); + + cy.get('@table').find('.current-page-number').focus().clear().type('700').blur(); + cy.get('@table').find('.current-page-number').should('have.value', '20'); + + cy.get('@table').find('.current-page-number').focus().clear().type('0').blur(); + cy.get('@table').find('.current-page-number').should('have.value', '1'); + + cy.get('@table').find('.current-page-number').focus().clear().type('abc').blur(); + cy.get('@table').find('.current-page-number').should('have.value', '1'); + }); // it('deletes all rows', ()=> { // cy.visit('/app/contact/Test Contact'); // cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); diff --git a/cypress/integration/list_view.js b/cypress/integration/list_view.js index 633d1335ab..b161af2df7 100644 --- a/cypress/integration/list_view.js +++ b/cypress/integration/list_view.js @@ -6,12 +6,23 @@ context('List View', () => { return frappe.xcall("frappe.tests.ui_test_helpers.setup_workflow"); }); }); + + it('Keep checkbox checked after Refresh', () => { + 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'); + cy.intercept('/api/method/frappe.desk.reportview.get').as('list-refresh'); + cy.get('button[data-original-title="Refresh"]').click(); + cy.wait('@list-refresh'); + 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 +35,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 index 7e1426aa46..b4e023c53e 100644 --- a/cypress/integration/navigation.js +++ b/cypress/integration/navigation.js @@ -1,7 +1,6 @@ context('Navigation', () => { before(() => { cy.login(); - cy.visit('/app/website'); }); it('Navigate to route with hash in document name', () => { cy.insert_doc('ToDo', {'__newname': 'ABC#123', 'description': 'Test this', 'ignore_duplicate': true}); @@ -11,4 +10,16 @@ context('Navigation', () => { 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..629ae72eb8 100644 --- a/cypress/integration/report_view.js +++ b/cypress/integration/report_view.js @@ -7,6 +7,8 @@ context('Report View', () => { cy.visit('/app/website'); cy.insert_doc('DocType', custom_submittable_doctype, true); cy.clear_cache(); + }); + it('Field with enabled allow_on_submit should be editable.', () => { cy.insert_doc(doctype_name, { 'title': 'Doc 1', 'description': 'Random Text', @@ -14,8 +16,6 @@ context('Report View', () => { // submit document 'docstatus': 1 }, true).as('doc'); - }); - it('Field with enabled allow_on_submit should be editable.', () => { cy.intercept('POST', 'api/method/frappe.client.set_value').as('value-update'); cy.visit(`/app/List/${doctype_name}/Report`); // check status column added from docstatus @@ -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 9074beae06..792cb56198 100644 --- a/esbuild/esbuild.js +++ b/esbuild/esbuild.js @@ -1,18 +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 rtlcss = require('rtlcss'); -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, @@ -26,7 +28,7 @@ let { get_redis_subscriber } = require("./utils"); -let argv = yargs +const argv = yargs .usage("Usage: node esbuild [options]") .option("apps", { type: "string", @@ -44,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" @@ -93,9 +100,6 @@ if (WATCH_MODE) { async function execute() { console.time(TOTAL_BUILD_TIME); - if (!FILES_TO_BUILD.length) { - await clean_dist_folders(APPS); - } let results; try { @@ -104,6 +108,9 @@ async function execute() { log_error("There were some problems during build"); log(); log(chalk.dim(e.stack)); + if (process.env.CI) { + process.kill(process.pid); + } return; } @@ -223,12 +230,13 @@ function get_files_to_build(files) { function build_files({ files, outdir }) { 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 }) { +function build_style_files({ files, outdir, rtl_style = false }) { let plugins = []; if (rtl_style) { plugins.push(rtlcss); @@ -236,6 +244,7 @@ function build_style_files({ files, outdir, rtl_style=false }) { let build_plugins = [ ignore_assets, + build_cleanup_plugin, postCssPlugin({ plugins: plugins, sassOptions: sass_options @@ -280,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 }); } } }; @@ -291,24 +314,6 @@ function get_watch_config() { return null; } -async function clean_dist_folders(apps) { - for (let app of apps) { - let public_path = get_public_path(app); - let paths = [ - path.resolve(public_path, "dist", "js"), - path.resolve(public_path, "dist", "css"), - path.resolve(public_path, "dist", "css-rtl") - ]; - for (let target of paths) { - if (fs.existsSync(target)) { - // rmdir is deprecated in node 16, this will work in both node 14 and 16 - let rmdir = fs.promises.rm || fs.promises.rmdir; - await rmdir(target, { recursive: true }); - } - } - } -} - function log_built_assets(results) { let outputs = {}; for (const result of results) { @@ -453,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", _ => { @@ -475,7 +480,9 @@ async function notify_redis({ error, success }) { } if (success) { payload = { - success: true + success: true, + changed_files, + live_reload: argv["live-reload"] }; } @@ -505,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); @@ -515,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(); -} \ No newline at end of file + return added_files; +} diff --git a/frappe/__init__.py b/frappe/__init__.py index b4728f9ac3..defa6e3336 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,10 +28,11 @@ from .exceptions import * from .utils.jinja import (get_jenv, get_template, render_template, get_email_from_template, get_jloader) from .utils.lazy_loader import lazy_import -from frappe.query_builder import get_query_builder - -# Lazy imports -faker = lazy_import('faker') +from frappe.query_builder import ( + get_query_builder, + patch_query_execute, + patch_query_aggregation, +) __version__ = '14.0.0-dev' @@ -44,7 +45,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): @@ -140,7 +142,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): @@ -208,6 +214,8 @@ def init(site, sites_path=None, new_site=False): local.qb = get_query_builder(local.conf.db_type or "mariadb") setup_module_map() + patch_query_execute() + patch_query_aggregation() local.initialised = True @@ -230,12 +238,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 @@ -484,11 +493,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**. @@ -518,6 +527,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) @@ -613,8 +630,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() @@ -624,6 +639,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**. @@ -694,18 +732,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)) @@ -755,7 +795,9 @@ def has_website_permission(doc=None, ptype='read', user=None, verbose=False, doc def is_table(doctype): """Returns True if `istable` property (indicating child Table) is set for given DocType.""" def get_tables(): - return db.sql_list("select name from tabDocType where istable=1") + return db.get_values( + "DocType", filters={"istable": 1}, order_by=None, pluck=True + ) tables = cache().get_value("is_table", get_tables) return doctype in tables @@ -1453,7 +1495,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 @@ -1485,8 +1530,8 @@ def format(*args, **kwargs): import frappe.utils.formatters return frappe.utils.formatters.format_value(*args, **kwargs) -def get_print(doctype=None, name=None, print_format=None, style=None, - html=None, as_pdf=False, doc=None, output=None, no_letterhead=0, password=None): +def get_print(doctype=None, name=None, print_format=None, style=None, html=None, + as_pdf=False, doc=None, output=None, no_letterhead=0, password=None, pdf_options=None): """Get Print Format for given document. :param doctype: DocType of document. @@ -1505,15 +1550,15 @@ def get_print(doctype=None, name=None, print_format=None, style=None, local.form_dict.doc = doc local.form_dict.no_letterhead = no_letterhead - options = None + pdf_options = pdf_options or {} if password: - options = {'password': password} + pdf_options['password'] = password if not html: html = get_response_content("printview") if as_pdf: - return get_pdf(html, output = output, options = options) + return get_pdf(html, options=pdf_options, output=output) else: return html @@ -1760,7 +1805,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 = [] @@ -1808,6 +1853,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): @@ -1817,7 +1863,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 636c6b2888..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 diff --git a/frappe/app.py b/frappe/app.py index 920628dda4..d73dd67983 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 @@ -120,6 +120,8 @@ def init_request(request): else: frappe.connect(set_admin_as_user=False) + request.max_content_length = frappe.local.conf.get('max_file_size') or 10 * 1024 * 1024 + make_form_dict(request) if request.method != "OPTIONS": @@ -183,7 +185,9 @@ def make_form_dict(request): if 'application/json' in (request.content_type or '') and request_data: args = json.loads(request_data) else: - args = request.form or request.args + args = {} + args.update(request.args or {}) + args.update(request.form or {}) if not isinstance(args, dict): frappe.throw(_("Invalid request arguments")) diff --git a/frappe/auth.py b/frappe/auth.py index fc1cb09e1a..078a6bb165 100644 --- a/frappe/auth.py +++ b/frappe/auth.py @@ -128,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')) @@ -154,7 +153,6 @@ class LoginManager: self.make_session() self.setup_boot_cache() self.set_user_info() - self.clear_preferred_language() def get_user_info(self): self.info = frappe.db.get_value("User", self.user, 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 8f02b67934..61511b736e 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 """ @@ -106,8 +106,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 c17ae583ed..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 @@ -145,10 +145,9 @@ def build_table_count_cache(): table_rows = frappe.qb.Field("table_rows").as_("count") information_schema = frappe.qb.Schema("information_schema") - query = frappe.qb.from_(information_schema.tables).select(table_name, table_rows) - - data = frappe.db.sql(query, as_dict=1) - + data = ( + frappe.qb.from_(information_schema.tables).select(table_name, table_rows) + ).run(as_dict=True) counts = {d.get('name').lstrip('tab'): d.get('count', None) for d in data} _cache.set_value("information_schema:counts", counts) 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..6641e471af 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 @@ -18,7 +18,7 @@ Requests via FrappeClient are also handled here. @frappe.whitelist() def get_list(doctype, fields=None, filters=None, order_by=None, - limit_start=None, limit_page_length=20, parent=None, debug=False, as_dict=True): + limit_start=None, limit_page_length=20, parent=None, debug=False, as_dict=True, or_filters=None): '''Returns a list of records by filters, fields, ordering and limit :param doctype: DocType of the data to be queried @@ -34,6 +34,7 @@ def get_list(doctype, fields=None, filters=None, order_by=None, doctype=doctype, fields=fields, filters=filters, + or_filters=or_filters, order_by=order_by, limit_start=limit_start, limit_page_length=limit_page_length, @@ -87,7 +88,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 +259,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 +283,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 +406,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 9ed333d034..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,9 +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 import commands as redis_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 - all_commands = scheduler_commands + site_commands + translate_commands + utils_commands + redis_commands - return list(set(all_commands)) commands = get_commands() diff --git a/frappe/commands/redis.py b/frappe/commands/redis_utils.py similarity index 97% rename from frappe/commands/redis.py rename to frappe/commands/redis_utils.py index 38a46c2142..3556050782 100644 --- a/frappe/commands/redis.py +++ b/frappe/commands/redis_utils.py @@ -3,7 +3,7 @@ import os import click import frappe -from frappe.utils.rq import RedisQueue +from frappe.utils.redis_queue import RedisQueue from frappe.installer import update_site_config @click.command('create-rq-users') diff --git a/frappe/commands/site.py b/frappe/commands/site.py index 22a063651c..6d3ed1af16 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 @@ -295,11 +447,10 @@ def disable_user(context, email): @pass_context def migrate(context, skip_failing=False, skip_search_index=False): "Run patches, sync schema and rebuild files/translations" - import re from frappe.migrate import migrate for site in context.sites: - print('Migrating', site) + click.secho(f"Migrating {site}", fg="green") frappe.init(site=site) frappe.connect() try: @@ -309,6 +460,7 @@ def migrate(context, skip_failing=False, skip_search_index=False): skip_search_index=skip_search_index ) finally: + print() frappe.destroy() if not context.sites: raise SiteNotSpecifiedError @@ -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) @@ -531,8 +696,7 @@ def _drop_site(site, root_login='root', root_password=None, archived_sites_path= drop_user_and_database(frappe.conf.db_name, root_login, root_password) - if not archived_sites_path: - archived_sites_path = os.path.join(frappe.get_app_path('frappe'), '..', '..', '..', 'archived_sites') + archived_sites_path = archived_sites_path or os.path.join(frappe.get_app_path('frappe'), '..', '..', '..', 'archived', 'sites') if not os.path.exists(archived_sites_path): os.mkdir(archived_sites_path) @@ -561,30 +725,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,22 +823,41 @@ 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''' - import webbrowser - site = context.sites[0] if context.sites else site + from frappe.auth import CookieManager, LoginManager + + site = get_site(context, raise_err=False) or site if not site: - click.echo('''Please provide site name\n\nUsage:\n\tbench browse [site-name]\nor\n\tbench --site [site-name] browse''') - return + raise SiteNotSpecifiedError - site = site.lower() + if site not in frappe.utils.get_sites(): + click.echo(f"\nSite named {click.style(site, bold=True)} doesn't exist\n", err=True) + sys.exit(1) - if site in frappe.utils.get_sites(): - webbrowser.open(frappe.utils.get_site_url(site), new=2) - else: - click.echo("\nSite named \033[1m{}\033[0m doesn't exist\n".format(site)) + 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: + click.echo("Please enable developer mode to login as a user") + + url = f'{frappe.utils.get_site_url(site)}{sid}' + + if user == "Administrator": + click.echo(f'Login URL: {url}') + + click.launch(url) @click.command('start-recording') @@ -714,6 +921,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 +1061,7 @@ commands = [ remove_from_installed_apps, restore, run_patch, + set_password, set_admin_password, uninstall, disable_user, @@ -741,5 +1074,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 8fc6877d4f..41b607b192 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -9,13 +9,12 @@ 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 = click.style( +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'.", - fg="yellow" + "Use `data-import` command instead to import data via 'Data Import'." ) @@ -364,7 +363,7 @@ 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): - click.secho(DATA_IMPORT_DEPRECATION) + click.secho(DATA_IMPORT_DEPRECATION, fg="yellow") sys.exit(1) @@ -408,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, @@ -434,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]) @@ -485,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 = [] @@ -509,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') @@ -524,58 +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 - from frappe.coverage import STANDARD_INCLUSIONS, STANDARD_EXCLUSIONS, FRAPPE_EXCLUSIONS + 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') - omit = STANDARD_EXCLUSIONS[:] - - if not app or app == 'frappe': - omit.extend(FRAPPE_EXCLUSIONS) - - cov = Coverage(source=[source_path], omit=omit, include=STANDARD_INCLUSIONS) - 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') @@ -585,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), '..')) @@ -607,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' 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) @@ -696,10 +791,11 @@ def request(context, args=None, path=None): @click.command('make-app') @click.argument('destination') @click.argument('app_name') -def make_app(destination, app_name): +@click.option('--no-git', is_flag=True, default=False, help='Do not initialize git repository for the app') +def make_app(destination, app_name, no_git=False): "Creates a boilerplate app" from frappe.utils.boilerplate import make_boilerplate - make_boilerplate(destination, app_name) + make_boilerplate(destination, app_name, no_git=no_git) @click.command('set-config') @@ -814,6 +910,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 e7f0f1a763..aa441b7d71 100644 --- a/frappe/config/__init__.py +++ b/frappe/config/__init__.py @@ -43,9 +43,13 @@ def get_all_empty_tables_by_module(): table_name = frappe.qb.Field("table_name") information_schema = frappe.qb.Schema("information_schema") - query = frappe.qb.from_(information_schema.tables).select(table_name).where(table_rows == 0) + empty_tables = ( + frappe.qb.from_(information_schema.tables) + .select(table_name) + .where(table_rows == 0) + ).run() - empty_tables = {r[0] for r in frappe.db.sql(query)} + empty_tables = {r[0] for r in empty_tables} results = frappe.get_all("DocType", fields=["name", "module"]) empty_tables_by_module = {} 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 2ea014f981..db2e64e868 100644 --- a/frappe/core/doctype/access_log/access_log.py +++ b/frappe/core/doctype/access_log/access_log.py @@ -1,6 +1,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 frappe +from frappe.utils import cstr +from tenacity import retry, retry_if_exception_type, stop_after_attempt from frappe.model.document import Document @@ -9,24 +11,53 @@ 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): +def make_access_log( + doctype=None, + document=None, + method=None, + file_type=None, + report_name=None, + filters=None, + page=None, + columns=None, +): + _make_access_log( + doctype, document, method, file_type, report_name, filters, page, columns, + ) + +@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. It must be tempting to put this block along with the in_request in the + # whitelisted method...yeah, don't do it. That part would be executed possibly on a read only DB conn + 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 19d7b77184..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 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 2706ab1c30..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 diff --git a/frappe/core/doctype/comment/test_comment.py b/frappe/core/doctype/comment/test_comment.py index 13db92e7a8..cd9af498aa 100644 --- a/frappe/core/doctype/comment/test_comment.py +++ b/frappe/core/doctype/comment/test_comment.py @@ -1,10 +1,19 @@ # -*- coding: utf-8 -*- # Copyright (c) 2019, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE import frappe, json import unittest class TestComment(unittest.TestCase): + def tearDown(self): + frappe.form_dict.comment = None + frappe.form_dict.comment_email = None + frappe.form_dict.comment_by = None + frappe.form_dict.reference_doctype = None + frappe.form_dict.reference_name = None + frappe.form_dict.route = None + frappe.local.request_ip = None + def test_comment_creation(self): test_doc = frappe.get_doc(dict(doctype = 'ToDo', description = 'test')) test_doc.insert() @@ -30,21 +39,31 @@ 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', - 'Blog Post', test_blog.name, test_blog.route) + + frappe.form_dict.comment = 'Good comment with 10 chars' + frappe.form_dict.comment_email = 'test@test.com' + frappe.form_dict.comment_by = 'Good Tester' + frappe.form_dict.reference_doctype = 'Blog Post' + frappe.form_dict.reference_name = test_blog.name + frappe.form_dict.route = test_blog.route + frappe.local.request_ip = '127.0.0.1' + + add_comment() self.assertEqual(frappe.get_all('Comment', fields = ['*'], filters = dict( reference_doctype = test_blog.doctype, 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) + frappe.form_dict.comment = 'pleez vizits my site http://mysite.com' + frappe.form_dict.comment_by = 'bad commentor' + + add_comment() self.assertEqual(len(frappe.get_all('Comment', fields = ['*'], filters = dict( reference_doctype = test_blog.doctype, 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.json b/frappe/core/doctype/communication/communication.json index 849df66a5f..175c64b9eb 100644 --- a/frappe/core/doctype/communication/communication.json +++ b/frappe/core/doctype/communication/communication.json @@ -51,6 +51,7 @@ "email_inbox", "message_id", "uid", + "imap_folder", "email_status", "has_attachment", "feedback_section", @@ -382,12 +383,19 @@ "label": "Timeline Links", "options": "Communication Link", "permlevel": 2 + }, + { + "fieldname": "imap_folder", + "fieldtype": "Data", + "hidden": 1, + "label": "IMAP Folder", + "read_only": 1 } ], "icon": "fa fa-comment", "idx": 1, "links": [], - "modified": "2021-03-25 09:44:28.963538", + "modified": "2021-11-30 09:03:25.728637", "modified_by": "Administrator", "module": "Core", "name": "Communication", 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 7ffbe6781d..54ddbce2c4 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 @@ -146,25 +146,43 @@ def add_attachments(name, attachments): }) _file.save(ignore_permissions=True) -@frappe.whitelist(allow_guest=True) -def mark_email_as_seen(name=None): +@frappe.whitelist(allow_guest=True, methods=("GET",)) +def mark_email_as_seen(name: str = None): try: - if name and frappe.db.exists("Communication", name) and not frappe.db.get_value("Communication", name, "read_by_recipient"): - frappe.db.set_value("Communication", name, "read_by_recipient", 1) - frappe.db.set_value("Communication", name, "delivery_status", "Read") - frappe.db.set_value("Communication", name, "read_by_recipient_on", get_datetime()) - frappe.db.commit() + update_communication_as_read(name) + frappe.db.commit() # nosemgrep: this will be called in a GET request + except Exception: frappe.log_error(frappe.get_traceback()) - finally: - # Return image as response under all circumstances - from PIL import Image - import io - im = Image.new('RGBA', (1, 1)) - im.putdata([(255,255,255,0)]) - buffered_obj = io.BytesIO() - im.save(buffered_obj, format="PNG") - frappe.response["type"] = 'binary' - frappe.response["filename"] = "imaginary_pixel.png" - frappe.response["filecontent"] = buffered_obj.getvalue() + finally: + frappe.response.update({ + "type": "binary", + "filename": "imaginary_pixel.png", + "filecontent": ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00" + b"\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\r" + b"IDATx\x9cc\xf8\xff\xff?\x03\x00\x08\xfc\x02\xfe\xa7\x9a\xa0" + b"\xa0\x00\x00\x00\x00IEND\xaeB`\x82" + ) + }) + +def update_communication_as_read(name): + if not name or not isinstance(name, str): + return + + communication = frappe.db.get_value( + "Communication", + name, + "read_by_recipient", + as_dict=True + ) + + if not communication or communication.read_by_recipient: + return + + frappe.db.set_value("Communication", name, { + "read_by_recipient": 1, + "delivery_status": "Read", + "read_by_recipient_on": get_datetime() + }) diff --git a/frappe/core/doctype/communication/mixins.py b/frappe/core/doctype/communication/mixins.py index 52cd370890..b6d8070d00 100644 --- a/frappe/core/doctype/communication/mixins.py +++ b/frappe/core/doctype/communication/mixins.py @@ -217,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): @@ -236,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..f26e70771b 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 @@ -291,6 +291,7 @@ def create_email_account(): "unreplied_for_mins": 20, "send_notification_to": "test_comm@example.com", "pop3_server": "pop.test.example.com", + "imap_folder": [{"folder_name": "INBOX", "append_to": "ToDo"}], "no_remaining":"0", "enable_automatic_linking": 1 }).insert(ignore_permissions=True) 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 ffd828bfdb..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 _ @@ -261,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 @@ -305,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 50469eeb4d..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 diff --git a/frappe/core/doctype/data_import/exporter.py b/frappe/core/doctype/data_import/exporter.py index acaa294a6f..21faf98e49 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 @@ -191,7 +191,7 @@ class Exporter: [format_column_name(df) for df in self.fields if df.parent == child_table_doctype] ) ) - data = frappe.db.get_list( + data = frappe.db.get_all( child_table_doctype, filters={ "parent": ("in", parent_names), diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py index bb922f1f5d..28880e7e38 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 @@ -199,7 +199,7 @@ class Importer: new_doc = frappe.new_doc(self.doctype) new_doc.update(doc) - if (meta.autoname or "").lower() != "prompt": + if not doc.name and (meta.autoname or "").lower() != "prompt": # name can only be set directly if autoname is prompt new_doc.set("name", None) @@ -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 7a4d185d8f..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 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..b907ebc0bc 100644 --- a/frappe/core/doctype/doctype/doctype.js +++ b/frappe/core/doctype/doctype/doctype.js @@ -1,16 +1,6 @@ // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // MIT License. See license.txt -// ------------- -// Menu Display -// ------------- - -// $(cur_frm.wrapper).on("grid-row-render", function(e, grid_row) { -// if(grid_row.doc && grid_row.doc.fieldtype=="Section Break") { -// $(grid_row.row).css({"font-weight": "bold"}); -// } -// }) - frappe.ui.form.on('DocType', { refresh: function(frm) { frm.set_query('role', 'permissions', function(doc) { @@ -61,9 +51,166 @@ 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 doctypes = frm.doc.fields + .filter(df => df.fieldtype == "Link") + .filter(df => df.options && df.fieldname != row.fieldname) + .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(); + } + }, + + fieldtype: function(frm) { + frm.trigger("max_attachments"); } -}) +}); + +extend_cscript(cur_frm.cscript, new frappe.model.DocTypeController({frm: cur_frm})); diff --git a/frappe/core/doctype/doctype/doctype.json b/frappe/core/doctype/doctype/doctype.json index 3024fb32a2..e67b78aef5 100644 --- a/frappe/core/doctype/doctype/doctype.json +++ b/frappe/core/doctype/doctype/doctype.json @@ -26,8 +26,10 @@ "fields_section_break", "fields", "sb1", + "naming_rule", "autoname", "name_case", + "allow_rename", "column_break_15", "description", "documentation", @@ -39,7 +41,6 @@ "column_break_23", "hide_toolbar", "allow_copy", - "allow_rename", "allow_import", "allow_events_in_timeline", "allow_auto_repeat", @@ -71,6 +72,8 @@ "actions", "links_section", "links", + "document_states_section", + "states", "web_view", "has_web_view", "allow_guest_to_view", @@ -79,7 +82,8 @@ "is_published_field", "website_search_field", "advanced", - "engine" + "engine", + "migration_hash" ], "fields": [ { @@ -149,7 +153,7 @@ "fieldtype": "Column Break" }, { - "default": "1", + "default": "0", "depends_on": "eval:!doc.istable", "description": "If enabled, changes to the document are tracked and shown in timeline", "fieldname": "track_changes", @@ -277,7 +281,7 @@ "oldfieldtype": "Check" }, { - "default": "0", + "default": "1", "fieldname": "allow_rename", "fieldtype": "Check", "label": "Allow Rename", @@ -556,6 +560,30 @@ "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 + }, + { + "fieldname": "states", + "fieldtype": "Table", + "label": "States", + "options": "DocType State" + }, + { + "collapsible": 1, + "fieldname": "document_states_section", + "fieldtype": "Section Break", + "label": "Document States" + }, { "default": "0", "fieldname": "show_title_field_in_link", @@ -642,7 +670,7 @@ "link_fieldname": "reference_doctype" } ], - "modified": "2021-08-03 13:41:50.319555", + "modified": "2021-12-15 14:53:10.717788", "modified_by": "Administrator", "module": "Core", "name": "DocType", diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index d2f62d0a15..a6a81cb195 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 @@ -23,6 +23,7 @@ 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 @@ -74,6 +75,7 @@ class DocType(Document): self.make_repeatable() self.validate_nestedset() self.validate_website() + self.ensure_minimum_max_attachment_limit() validate_links_table_fieldnames(self) if not self.is_new(): @@ -86,10 +88,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 +174,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' @@ -250,6 +247,22 @@ class DocType(Document): # clear website cache clear_cache() + def ensure_minimum_max_attachment_limit(self): + """Ensure that max_attachments is *at least* bigger than number of attach fields.""" + from frappe.model import attachment_fieldtypes + + + if not self.max_attachments: + return + + total_attach_fields = len([d for d in self.fields if d.fieldtype in attachment_fieldtypes]) + if total_attach_fields > self.max_attachments: + self.max_attachments = total_attach_fields + field_label = frappe.bold(self.meta.get_field("max_attachments").label) + frappe.msgprint(_("Number of attachment fields are more than {}, limit updated to {}.") + .format(field_label, total_attach_fields), + title=_("Insufficient attachment limit"), alert=True) + def change_modified_of_parent(self): """Change the timestamp of parent DocType if the current one is a child to clear caches.""" if frappe.flags.in_import: @@ -257,7 +270,7 @@ class DocType(Document): parent_list = frappe.db.get_all('DocField', 'parent', dict(fieldtype=['in', frappe.model.table_fields], options=self.name)) for p in parent_list: - frappe.db.sql('UPDATE `tabDocType` SET modified=%s WHERE `name`=%s', (now(), p.parent)) + frappe.db.update("DocType", p.parent, {}, for_update=False) def scrub_field_names(self): """Sluggify fieldnames if not set from Label.""" @@ -274,6 +287,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 +327,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 +365,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 @@ -463,7 +502,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, @@ -493,6 +532,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: @@ -566,17 +608,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.""" @@ -701,12 +743,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])) @@ -719,9 +762,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) @@ -931,6 +987,9 @@ def validate_fields(meta): 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 @@ -1011,6 +1070,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] @@ -1044,6 +1106,7 @@ 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) @@ -1200,8 +1263,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/chat/doctype/__init__.py b/frappe/core/doctype/doctype_state/__init__.py similarity index 100% rename from frappe/chat/doctype/__init__.py rename to frappe/core/doctype/doctype_state/__init__.py diff --git a/frappe/core/doctype/doctype_state/doctype_state.json b/frappe/core/doctype/doctype_state/doctype_state.json new file mode 100644 index 0000000000..79797b41c5 --- /dev/null +++ b/frappe/core/doctype/doctype_state/doctype_state.json @@ -0,0 +1,50 @@ +{ + "actions": [], + "creation": "2021-08-23 17:21:28.345841", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "title", + "color", + "custom" + ], + "fields": [ + { + "fieldname": "title", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Title", + "reqd": 1 + }, + { + "default": "Blue", + "fieldname": "color", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Color", + "options": "Blue\nCyan\nGray\nGreen\nLight Blue\nOrange\nPink\nPurple\nRed\nYellow", + "reqd": 1 + }, + { + "default": "0", + "fieldname": "custom", + "fieldtype": "Check", + "hidden": 1, + "label": "Custom" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-12-14 14:14:55.716378", + "modified_by": "Administrator", + "module": "Core", + "name": "DocType State", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/core/doctype/doctype_state/doctype_state.py b/frappe/core/doctype/doctype_state/doctype_state.py new file mode 100644 index 0000000000..3172834180 --- /dev/null +++ b/frappe/core/doctype/doctype_state/doctype_state.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + +class DocTypeState(Document): + pass 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 a8c7c6a747..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 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 3d66253b08..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 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/core/doctype/feedback/feedback.json b/frappe/core/doctype/feedback/feedback.json index cf8a180e27..f8380cfda6 100644 --- a/frappe/core/doctype/feedback/feedback.json +++ b/frappe/core/doctype/feedback/feedback.json @@ -8,40 +8,14 @@ "reference_doctype", "reference_name", "column_break_3", - "email", - "rating", - "section_break_6", - "feedback" + "like", + "ip_address" ], "fields": [ { "fieldname": "column_break_3", "fieldtype": "Column Break" }, - { - "fieldname": "email", - "fieldtype": "Data", - "label": "Email", - "reqd": 1 - }, - { - "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", @@ -56,11 +30,24 @@ "label": "Reference Name", "options": "reference_doctype", "reqd": 1 + }, + { + "fieldname": "ip_address", + "fieldtype": "Data", + "hidden": 1, + "label": "IP Address", + "read_only": 1 + }, + { + "default": "0", + "fieldname": "like", + "fieldtype": "Check", + "label": "Like" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2021-06-14 15:11:26.005805", + "modified": "2021-11-10 20:53:21.255593", "modified_by": "Administrator", "module": "Core", "name": "Feedback", diff --git a/frappe/core/doctype/feedback/feedback.py b/frappe/core/doctype/feedback/feedback.py index 655bed6eb1..3704ee66e0 100644 --- a/frappe/core/doctype/feedback/feedback.py +++ b/frappe/core/doctype/feedback/feedback.py @@ -1,5 +1,5 @@ # 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/feedback/test_feedback.py b/frappe/core/doctype/feedback/test_feedback.py index 702f9d8ac1..66f644ccd3 100644 --- a/frappe/core/doctype/feedback/test_feedback.py +++ b/frappe/core/doctype/feedback/test_feedback.py @@ -1,27 +1,39 @@ # Copyright (c) 2021, Frappe Technologies and Contributors -# See license.txt +# 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.like = 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.sql("delete from `tabFeedback` where reference_doctype = 'Blog Post'") + frappe.db.delete("Feedback", {"reference_doctype": "Blog Post"}) - from frappe.templates.includes.feedback.feedback import add_feedback, update_feedback - feedback = add_feedback('Blog Post', test_blog.name, 5, 'New feedback','test@test.com') + from frappe.templates.includes.feedback.feedback import give_feedback - self.assertEqual(feedback.feedback, 'New feedback') - self.assertEqual(feedback.rating, 5) + frappe.form_dict.reference_doctype = 'Blog Post' + frappe.form_dict.reference_name = test_blog.name + frappe.form_dict.like = True + frappe.local.request_ip = '127.0.0.1' - updated_feedback = update_feedback('Blog Post', test_blog.name, 6, 'Updated feedback', 'test@test.com') + feedback = give_feedback() - self.assertEqual(updated_feedback.feedback, 'Updated feedback') - self.assertEqual(updated_feedback.rating, 6) + self.assertEqual(feedback.like, True) - frappe.db.sql("delete from `tabFeedback` where reference_doctype = 'Blog Post'") + frappe.form_dict.like = False + + updated_feedback = give_feedback() + + self.assertEqual(updated_feedback.like, False) + + 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..91090bdd77 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,53 @@ 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() + + @staticmethod + def zip_files(files): + from six import string_types + + zip_file = io.BytesIO() + zf = zipfile.ZipFile(zip_file, "w", zipfile.ZIP_DEFLATED) + for _file in files: + if isinstance(_file, string_types): + _file = frappe.get_doc("File", _file) + if not isinstance(_file, File): + continue + if _file.is_folder: + continue + zf.writestr(_file.file_name, _file.get_content()) + zf.close() + return zip_file.getvalue() + + def on_doctype_update(): frappe.db.add_index("File", ["attached_to_doctype", "attached_to_name"]) @@ -621,7 +614,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): @@ -636,6 +630,16 @@ def move_file(file_list, new_parent, old_parent): frappe.get_doc("File", old_parent).save() frappe.get_doc("File", new_parent).save() + +@frappe.whitelist() +def zip_files(files): + files = frappe.parse_json(files) + zipped_files = File.zip_files(files) + frappe.response["filename"] = "files.zip" + frappe.response["filecontent"] = zipped_files + frappe.response["type"] = "download" + + def setup_folder_path(filename, new_parent): file = frappe.get_doc("File", filename) file.folder = new_parent @@ -672,7 +676,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 +707,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,49 +744,11 @@ 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 - - +@frappe.whitelist() 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 user = user or frappe.session.user @@ -824,6 +793,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 +839,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 +872,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 +888,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 +901,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 +926,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) @@ -1001,20 +968,14 @@ def get_files_by_search_text(text): def update_existing_file_docs(doc): # Update is private and file url of all file docs that point to the same file - frappe.db.sql(""" - UPDATE `tabFile` - SET - file_url = %(file_url)s, - is_private = %(is_private)s - WHERE - content_hash = %(content_hash)s - and name != %(file_name)s - """, dict( - file_url=doc.file_url, - is_private=doc.is_private, - content_hash=doc.content_hash, - file_name=doc.name - )) + file_doctype = frappe.qb.DocType("File") + ( + frappe.qb.update(file_doctype) + .set(file_doctype.file_url, doc.file_url) + .set(file_doctype.is_private, doc.is_private) + .where(file_doctype.content_hash == doc.content_hash) + .where(file_doctype.name != doc.name) + ).run() def attach_files_to_document(doc, event): """ Runs on on_update hook of all documents. 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.js b/frappe/core/doctype/module_profile/module_profile.js index 9c92042dda..3714d31ade 100644 --- a/frappe/core/doctype/module_profile/module_profile.js +++ b/frappe/core/doctype/module_profile/module_profile.js @@ -1,19 +1,23 @@ // Copyright (c) 2020, Frappe Technologies and contributors // For license information, please see license.txt -frappe.ui.form.on('Module Profile', { - refresh: function(frm) { +frappe.ui.form.on("Module Profile", { + refresh: function (frm) { if (has_common(frappe.user_roles, ["Administrator", "System Manager"])) { if (!frm.module_editor && frm.doc.__onload && frm.doc.__onload.all_modules) { - let module_area = $('
') - .appendTo(frm.fields_dict.module_html.wrapper); - + const module_area = $(frm.fields_dict.module_html.wrapper); frm.module_editor = new frappe.ModuleEditor(frm, module_area); } } if (frm.module_editor) { - frm.module_editor.refresh(); + frm.module_editor.show(); + } + }, + + validate: function (frm) { + if (frm.module_editor) { + frm.module_editor.set_modules_in_table(); } } }); diff --git a/frappe/core/doctype/module_profile/module_profile.json b/frappe/core/doctype/module_profile/module_profile.json index 0e4e56962e..32bc757427 100644 --- a/frappe/core/doctype/module_profile/module_profile.json +++ b/frappe/core/doctype/module_profile/module_profile.json @@ -34,11 +34,17 @@ } ], "index_web_pages_for_search": 1, - "links": [], - "modified": "2021-01-03 15:36:52.622696", + "links": [ + { + "link_doctype": "User", + "link_fieldname": "module_profile" + } + ], + "modified": "2021-12-03 15:47:21.296443", "modified_by": "Administrator", "module": "Core", "name": "Module Profile", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { 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..c46d0081b6 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 @@ -13,6 +13,9 @@ class NavbarSettings(Document): def validate_standard_navbar_items(self): doc_before_save = self.get_doc_before_save() + if not doc_before_save: + return + before_save_items = [item for item in \ doc_before_save.help_dropdown + doc_before_save.settings_dropdown if item.is_standard] @@ -22,7 +25,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: @@ -33,7 +35,3 @@ def get_app_logo(): def get_navbar_settings(): navbar_settings = frappe.get_single('Navbar Settings') return navbar_settings - - - - 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..266017dd71 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 @@ -51,6 +51,14 @@ class Report(Document): and not frappe.flags.in_patch): frappe.throw(_("You are not allowed to delete Standard Report")) delete_custom_role('report', self.name) + self.delete_prepared_reports() + + def delete_prepared_reports(self): + prepared_reports = frappe.get_all("Prepared Report", filters={'ref_report_doctype': self.name}, pluck='name') + + for report in prepared_reports: + frappe.delete_doc("Prepared Report", report, ignore_missing=True, force=True, + delete_permanently=True) def get_columns(self): return [d.as_dict(no_default_fields = True) for d in self.columns] @@ -105,7 +113,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 9c953db1f0..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 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 28b444e1e7..389e18dd4c 100644 --- a/frappe/core/doctype/role/role.py +++ b/frappe/core/doctype/role/role.py @@ -1,16 +1,23 @@ # 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") +STANDARD_ROLES = ( + "Administrator", + "System Manager", + "Script Manager", + "All", + "Guest" +) + class Role(Document): def before_rename(self, old, new, merge=False): - if old in ("Guest", "Administrator", "System Manager", "All"): + if old in STANDARD_ROLES: frappe.throw(frappe._("Standard roles cannot be renamed")) def after_insert(self): @@ -23,7 +30,7 @@ class Role(Document): self.set_desk_properties() def disable_role(self): - if self.name in ("Guest", "Administrator", "System Manager", "All"): + if self.name in STANDARD_ROLES: frappe.throw(frappe._("Standard roles cannot be disabled")) else: self.remove_roles() @@ -82,4 +89,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.json b/frappe/core/doctype/role_profile/role_profile.json index 4b3f35aa57..7cd60a16d1 100644 --- a/frappe/core/doctype/role_profile/role_profile.json +++ b/frappe/core/doctype/role_profile/role_profile.json @@ -1,175 +1,80 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "autoname": "role_profile", - "beta": 0, - "creation": "2017-08-31 04:16:38.764465", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", + "actions": [], + "autoname": "role_profile", + "creation": "2017-08-31 04:16:38.764465", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "role_profile", + "roles_html", + "roles" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "role_profile", - "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": "Role Name", - "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": 1, - "search_index": 0, - "set_only_once": 0, + "fieldname": "role_profile", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Role Name", + "reqd": 1, "unique": 1 - }, + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "roles_html", - "fieldtype": "HTML", - "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": "Roles HTML", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "roles_html", + "fieldtype": "HTML", + "label": "Roles HTML", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "roles", - "fieldtype": "Table", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Roles Assigned", - "length": 0, - "no_copy": 0, - "options": "Has Role", - "permlevel": 1, - "precision": "", - "print_hide": 1, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 + "fieldname": "roles", + "fieldtype": "Table", + "hidden": 1, + "label": "Roles Assigned", + "options": "Has Role", + "permlevel": 1, + "print_hide": 1, + "read_only": 1 } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2017-10-17 11:05:11.183066", - "modified_by": "Administrator", - "module": "Core", - "name": "Role Profile", - "name_case": "", - "owner": "Administrator", + ], + "links": [ + { + "link_doctype": "User", + "link_fieldname": "role_profile_name" + } + ], + "modified": "2021-12-03 15:45:45.270963", + "modified_by": "Administrator", + "module": "Core", + "name": "Role Profile", + "naming_rule": "Expression (old style)", + "owner": "Administrator", "permissions": [ { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, "write": 1 - }, + }, { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "email": 1, + "export": 1, + "permlevel": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, "write": 1 } - ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "title_field": "role_profile", - "track_changes": 1, - "track_seen": 0 + ], + "sort_field": "modified", + "sort_order": "DESC", + "title_field": "role_profile", + "track_changes": 1 } \ No newline at end of file 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 b6515b1e79..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,5 +1,5 @@ # Copyright (c) 2021, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import json from datetime import datetime 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..a11966c47e 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 @@ -10,7 +10,7 @@ from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs class TestScheduledJobType(unittest.TestCase): def setUp(self): frappe.db.rollback() - frappe.db.sql('truncate `tabScheduled Job Type`') + frappe.db.truncate("Scheduled Job Type") sync_jobs() frappe.db.commit() diff --git a/frappe/core/doctype/server_script/server_script.js b/frappe/core/doctype/server_script/server_script.js index dda39115bf..ca34af11ab 100644 --- a/frappe/core/doctype/server_script/server_script.js +++ b/frappe/core/doctype/server_script/server_script.js @@ -10,6 +10,13 @@ frappe.ui.form.on('Server Script', { frm.dashboard.hide(); } + if (!frm.is_new()) { + frm.add_custom_button(__('Compare Versions'), () => { + new frappe.ui.DiffView("Server Script", "script", frm.doc.name); + }); + } + + frm.call('get_autocompletion_items') .then(r => r.message) .then(items => { 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..bc92061f42 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() ''' ) ] @@ -66,7 +76,7 @@ class TestServerScript(unittest.TestCase): @classmethod def setUpClass(cls): frappe.db.commit() - frappe.db.sql('truncate `tabServer Script`') + frappe.db.truncate("Server Script") frappe.get_doc('User', 'Administrator').add_roles('Script Manager') for script in scripts: script_doc = frappe.get_doc(doctype ='Server Script') @@ -78,7 +88,7 @@ class TestServerScript(unittest.TestCase): @classmethod def tearDownClass(cls): frappe.db.commit() - frappe.db.sql('truncate `tabServer Script`') + frappe.db.truncate("Server Script") frappe.cache().delete_value('server_script_map') def setUp(self): @@ -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..dcec9b13c2 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", @@ -66,8 +67,8 @@ "prepared_report_section", "enable_prepared_report_auto_deletion", "prepared_report_expiry_period", - "chat", - "enable_chat" + "system_updates_section", + "disable_system_update_notification" ], "fields": [ { @@ -381,18 +382,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 +458,30 @@ "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" + }, + { + "collapsible": 1, + "fieldname": "system_updates_section", + "fieldtype": "Section Break", + "label": "System Updates" + }, + { + "default": "0", + "fieldname": "disable_system_update_notification", + "fieldtype": "Check", + "label": "Disable System Update Notification" } ], "icon": "fa fa-cog", "issingle": 1, "links": [], - "modified": "2021-03-30 11:47:47.330437", + "modified": "2021-11-29 18:09:53.601629", "modified_by": "Administrator", "module": "Core", "name": "System Settings", @@ -492,4 +499,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..0a480f6660 100644 --- a/frappe/core/doctype/transaction_log/transaction_log.py +++ b/frappe/core/doctype/transaction_log/transaction_log.py @@ -1,12 +1,14 @@ -# -*- 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 +16,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: @@ -29,26 +30,27 @@ class TransactionLog(Document): def hash_line(self): sha = hashlib.sha256() sha.update( - frappe.safe_encode(str(self.row_index)) + \ - frappe.safe_encode(str(self.timestamp)) + \ - frappe.safe_encode(str(self.data)) + frappe.safe_encode(str(self.row_index)) + + frappe.safe_encode(str(self.timestamp)) + + frappe.safe_encode(str(self.data)) ) return sha.hexdigest() def hash_chain(self): sha = hashlib.sha256() - sha.update( - frappe.safe_encode(str(self.transaction_hash)) + \ - frappe.safe_encode(str(self.previous_hash)) - ) + sha.update(frappe.safe_encode(str(self.transaction_hash)) + frappe.safe_encode(str(self.previous_hash))) return sha.hexdigest() 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..b3c85b22a1 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
''' @@ -264,35 +251,128 @@ class TestUser(unittest.TestCase): c = FrappeClient(url) res1 = c.session.post(url, data=data, verify=c.verify, headers=c.headers) res2 = c.session.post(url, data=data, verify=c.verify, headers=c.headers) - self.assertEqual(res1.status_code, 200) + self.assertEqual(res1.status_code, 400) 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 8c5b89c5fc..5b3a1affd9 100644 --- a/frappe/core/doctype/user/user.js +++ b/frappe/core/doctype/user/user.js @@ -50,7 +50,7 @@ frappe.ui.form.on('User', { let d = frm.add_child("block_modules"); d.module = v.module; }); - frm.module_editor && frm.module_editor.refresh(); + frm.module_editor && frm.module_editor.show(); } }); } @@ -166,7 +166,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 } @@ -180,7 +180,7 @@ frappe.ui.form.on('User', { frm.roles_editor.show(); } - frm.module_editor && frm.module_editor.refresh(); + frm.module_editor && frm.module_editor.show(); if(frappe.session.user==doc.name) { // update display settings @@ -263,6 +263,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..cf05ce0c15 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 }, { @@ -595,7 +599,7 @@ "fieldname": "desk_theme", "fieldtype": "Select", "label": "Desk Theme", - "options": "Light\nDark" + "options": "Light\nDark\nAutomatic" }, { "fieldname": "module_profile", @@ -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-11-17 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 5d799f8ee9..6c729901e5 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 @@ -15,17 +15,12 @@ from frappe.desk.doctype.notification_settings.notification_settings import crea from frappe.utils.user import get_system_managers from frappe.website.utils import is_signup_disabled from frappe.rate_limiter import rate_limit -from frappe.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 @@ -56,8 +51,6 @@ class User(Document): frappe.cache().delete_key('enabled_users') def validate(self): - self.check_demo() - # clear new password self.__new_password = self.new_password self.new_password = "" @@ -137,10 +130,6 @@ class User(Document): """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])) @@ -224,15 +213,12 @@ class User(Document): user_type_doc.update_modules_in_user(self) def has_desk_access(self): - '''Return true if any of the set roles has desk access''' + """Return true if any of the set roles has desk access""" if not self.roles: return False - return len(frappe.db.sql("""select name - from `tabRole` where desk_access=1 - and name in ({0}) limit 1""".format(', '.join(['%s'] * len(self.roles))), - [d.role for d in self.roles])) - + role_table = DocType("Role") + return frappe.db.count(role_table, ((role_table.desk_access == 1) & (role_table.name.isin([d.role for d in self.roles])))) def share_with_self(self): frappe.share.add(self.doctype, self.name, self.name, write=1, share=1, @@ -290,12 +276,20 @@ class User(Document): return link def get_other_system_managers(self): - return frappe.db.sql("""select distinct `user`.`name` from `tabHas Role` as `user_role`, `tabUser` as `user` - where user_role.role='System Manager' - and `user`.docstatus<2 - and `user`.enabled=1 - and `user_role`.parent = `user`.name - and `user_role`.parent not in ('Administrator', %s) limit 1""", (self.name,)) + user_doctype = DocType("User").as_("user") + user_role_doctype = DocType("Has Role").as_("user_role") + return ( + frappe.qb.from_(user_doctype) + .from_(user_role_doctype) + .select(user_doctype.name) + .where(user_role_doctype.role == 'System Manager') + .where(user_doctype.docstatus < 2) + .where(user_doctype.enabled == 1) + .where(user_role_doctype.parent == user_doctype.name) + .where(user_role_doctype.parent.notin(["Administrator", self.name])) + .limit(1) + .distinct() + ).run() def get_fullname(self): """get first_name space last_name""" @@ -369,8 +363,12 @@ class User(Document): # delete todos frappe.db.delete("ToDo", {"owner": self.name}) - frappe.db.sql("""UPDATE `tabToDo` SET `assigned_by`=NULL WHERE `assigned_by`=%s""", - (self.name,)) + todo_table = DocType("ToDo") + ( + frappe.qb.update(todo_table) + .set(todo_table.assigned_by, None) + .where(todo_table.assigned_by == self.name) + ).run() # delete events frappe.db.delete("Event", {"owner": self.name, "event_type": "Private"}) @@ -378,15 +376,21 @@ class User(Document): # delete shares frappe.db.delete("DocShare", {"user": self.name}) # delete messages - frappe.db.sql("""delete from `tabCommunication` - where communication_type in ('Chat', 'Notification') - and reference_doctype='User' - and (reference_name=%s or owner=%s)""", (self.name, self.name)) - + table = DocType("Communication") + frappe.db.delete( + table, + filters=( + (table.communication_type.isin(["Chat", "Notification"])) + & (table.reference_doctype == "User") + & ((table.reference_name == self.name) | table.owner == self.name) + ), + run=False, + ) # unlink contact - frappe.db.sql("""update `tabContact` - set `user`=null - where `user`=%s""", (self.name)) + table = DocType("Contact") + frappe.qb.update(table).where( + table.user == self.name + ).set(table.user, None).run() # delete notification settings frappe.delete_doc("Notification Settings", self.name, ignore_permissions=True) @@ -398,7 +402,6 @@ class User(Document): 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) @@ -427,16 +430,11 @@ 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)) + frappe.db.update("User", new_name, "email", new_name) def append_roles(self, *roles): """Add roles to user""" @@ -706,127 +704,36 @@ def has_email_account(email): @frappe.whitelist(allow_guest=False) def get_email_awaiting(user): - waiting = frappe.db.sql("""select email_account,email_id - from `tabUser Email` - where awaiting_password = 1 - and parent = %(user)s""", {"user":user}, as_dict=1) + waiting = frappe.get_all("User Email", fields=["email_account", "email_id"], filters={"awaiting_password": 1, "parent": user}) if waiting: return waiting else: - frappe.db.sql("""update `tabUser Email` - set awaiting_password =0 - where parent = %(user)s""",{"user":user}) + user_email_table = DocType("User Email") + frappe.qb.update(user_email_table).set(user_email_table.user_email_table, 0).where(user_email_table.parent == user).run() 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) - - password_list = [ user.get("user") for user in users ] + password_list = frappe.get_all("User Email", filters={"awaiting_password": True}, pluck="parent", distinct=True) 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) @@ -848,14 +755,12 @@ def sign_up(email, full_name, redirect_to): 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) @@ -887,7 +792,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' @@ -903,6 +808,7 @@ def reset_password(user): return frappe.msgprint(_("Password reset instructions have been sent to your email")) except frappe.DoesNotExistError: + frappe.local.response['http_status_code'] = 400 frappe.clear_messages() return 'not found' @@ -979,8 +885,7 @@ def get_active_users(): def get_website_users(): """Returns total no. of website users""" - return frappe.db.sql("""select count(*) from `tabUser` - where enabled = 1 and user_type = 'Website User'""")[0][0] + return frappe.db.count("User", filters={"enabled": True, "user_type": "Website User"}) def get_active_website_users(): """Returns No. of website users who logged in, in the last 3 days""" @@ -1048,91 +953,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 @@ -1150,15 +970,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 @@ -1217,22 +1028,22 @@ 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"]: + if theme in ["Dark", "Light", "Automatic"]: frappe.db.set_value("User", frappe.session.user, "desk_theme", theme) def get_enabled_users(): @@ -1240,4 +1051,4 @@ 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) \ No newline at end of file + 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 85db846982..cf905c2ce2 100644 --- a/frappe/core/doctype/user_permission/test_user_permission.py +++ b/frappe/core/doctype/user_permission/test_user_permission.py @@ -73,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)) @@ -84,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) @@ -101,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 5201ffef8d..1366ace115 100644 --- a/frappe/core/doctype/user_permission/user_permission.py +++ b/frappe/core/doctype/user_permission/user_permission.py @@ -1,5 +1,5 @@ # Copyright (c) 2021, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE import frappe, json from frappe.model.document import Document @@ -54,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, 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/form_tour/doctype/doctype.json b/frappe/core/form_tour/doctype/doctype.json new file mode 100644 index 0000000000..391d3ecf40 --- /dev/null +++ b/frappe/core/form_tour/doctype/doctype.json @@ -0,0 +1,56 @@ +{ + "creation": "2021-11-23 12:38:52.807353", + "docstatus": 0, + "doctype": "Form Tour", + "first_document": 0, + "idx": 0, + "include_name_field": 1, + "is_standard": 1, + "modified": "2021-11-25 17:03:01.646360", + "modified_by": "Administrator", + "module": "Core", + "name": "Doctype", + "owner": "Administrator", + "reference_doctype": "DocType", + "save_on_complete": 1, + "steps": [ + { + "description": "Select a Module to which this DocType would belong", + "field": "", + "fieldname": "module", + "fieldtype": "Link", + "has_next_condition": 0, + "is_table_field": 0, + "label": "Module", + "parent_field": "", + "position": "Right", + "title": "Module" + }, + { + "description": "Check this to make the DocType as Custom", + "field": "", + "fieldname": "custom", + "fieldtype": "Check", + "has_next_condition": 1, + "is_table_field": 0, + "label": "Custom?", + "next_step_condition": "eval: doc.custom", + "parent_field": "", + "position": "Left", + "title": "Custom " + }, + { + "description": "A Field (or a docfield) defines a property of a DocType. You can define the column name, label, datatype and more for DocFields. For instance, a ToDo doctype has fields description, status and priority. These ultimately become columns in the database table tabToDo.", + "field": "", + "fieldname": "fields", + "fieldtype": "Table", + "has_next_condition": 0, + "is_table_field": 0, + "label": "Fields", + "parent_field": "", + "position": "Top", + "title": "Fields" + } + ], + "title": "Doctype" +} \ No newline at end of file diff --git a/frappe/core/notifications.py b/frappe/core/notifications.py index 707de43f28..939cf52911 100644 --- a/frappe/core/notifications.py +++ b/frappe/core/notifications.py @@ -1,7 +1,10 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import frappe +from frappe.query_builder import DocType, Interval +from frappe.query_builder.functions import Now + def get_notification_config(): return { @@ -39,28 +42,40 @@ def get_todays_events(as_list=False): def get_unseen_likes(): """Returns count of unseen likes""" - return frappe.db.sql("""select count(*) from `tabComment` - where - comment_type='Like' - and modified >= (NOW() - INTERVAL '1' YEAR) - and owner is not null and owner!=%(user)s - and reference_owner=%(user)s - and seen=0 - """, {"user": frappe.session.user})[0][0] + + comment_doctype = DocType("Comment") + return frappe.db.count(comment_doctype, + filters=( + (comment_doctype.comment_type == "Like") + & (comment_doctype.modified >= Now() - Interval(years=1)) + & (comment_doctype.owner.notnull()) + & (comment_doctype.owner != frappe.session.user) + & (comment_doctype.reference_owner == frappe.session.user) + & (comment_doctype.seen == 0) + ) + ) + def get_unread_emails(): - "returns unread emails for a user" + "returns count of unread emails for a user" - return frappe.db.sql("""\ - SELECT count(*) - FROM `tabCommunication` - WHERE communication_type='Communication' - AND communication_medium='Email' - AND sent_or_received='Received' - AND email_status not in ('Spam', 'Trash') - AND email_account in ( - SELECT distinct email_account from `tabUser Email` WHERE parent=%(user)s + communication_doctype = DocType("Communication") + user_doctype = DocType("User") + distinct_email_accounts = ( + frappe.qb.from_(user_doctype) + .select(user_doctype.email_account) + .where(user_doctype.parent == frappe.session.user) + .distinct() + ) + + return frappe.db.count(communication_doctype, + filters=( + (communication_doctype.communication_type == "Communication") + & (communication_doctype.communication_medium == "Email") + & (communication_doctype.sent_or_received == "Received") + & (communication_doctype.email_status.notin(["spam", "Trash"])) + & (communication_doctype.email_account.isin(distinct_email_accounts)) + & (communication_doctype.modified >= Now() - Interval(years=1)) + & (communication_doctype.seen == 0) ) - AND modified >= (NOW() - INTERVAL '1' YEAR) - AND seen=0 - """, {"user": frappe.session.user})[0][0] + ) 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 1f3555e351..4d9deca526 100644 --- a/frappe/core/page/background_jobs/background_jobs.py +++ b/frappe/core/page/background_jobs/background_jobs.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 typing import TYPE_CHECKING, Dict, List @@ -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 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 2a99283dda..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 _ 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 index a59c24a714..1969cae141 100644 --- a/frappe/coverage.py +++ b/frappe/coverage.py @@ -33,3 +33,30 @@ FRAPPE_EXCLUSIONS = [ "*/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.js b/frappe/custom/doctype/client_script/client_script.js index 27d11af4d1..ad9c9e4e42 100644 --- a/frappe/custom/doctype/client_script/client_script.js +++ b/frappe/custom/doctype/client_script/client_script.js @@ -43,6 +43,12 @@ frappe.ui.form.on('Client Script', { d.show(); }); }); + + if (!frm.is_new()) { + frm.add_custom_button(__('Compare Versions'), () => { + new frappe.ui.DiffView("Client Script", "script", frm.doc.name); + }); + } } frm.set_query('dt', { 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 19462e79de..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\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 - }, - { - "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": "2021-07-12 04:54:12.042319", - "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 e266455f7a..8f7b21dd24 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 @@ -8,6 +8,7 @@ from frappe import _ from frappe.model.document import Document from frappe.model.docfield import supports_translation from frappe.model import core_doctypes_list +from frappe.query_builder.functions import IfNull class CustomField(Document): def autoname(self): @@ -18,7 +19,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")) @@ -115,9 +116,7 @@ def get_fields_label(doctype=None): def create_custom_field_if_values_exist(doctype, df): df = frappe._dict(df) if df.fieldname in frappe.db.get_table_columns(doctype) and \ - frappe.db.sql("""select count(*) from `tab{doctype}` - where ifnull({fieldname},'')!=''""".format(doctype=doctype, fieldname=df.fieldname))[0][0]: - + frappe.db.count(dt=doctype, filters=IfNull(df.fieldname, "") != ""): create_custom_field(doctype, df) def create_custom_field(doctype, df, ignore_validate=False): @@ -131,7 +130,7 @@ def create_custom_field(doctype, df, ignore_validate=False): "permlevel": 0, "fieldtype": 'Data', "hidden": 0, - # Looks like we always use this programatically? + # Looks like we always use this programatically? # "is_standard": 1 }) custom_field.update(df) @@ -146,24 +145,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.js b/frappe/custom/doctype/customize_form/customize_form.js index 4e00456f0d..4862185b99 100644 --- a/frappe/custom/doctype/customize_form/customize_form.js +++ b/frappe/custom/doctype/customize_form/customize_form.js @@ -114,6 +114,7 @@ frappe.ui.form.on("Customize Form", { frm.page.clear_icons(); if (frm.doc.doc_type) { + frm.page.set_title(__('Customize Form - {0}', [frm.doc.doc_type])); frappe.customize_form.set_primary_action(frm); frm.add_custom_button( @@ -276,6 +277,21 @@ frappe.ui.form.on("DocType Action", { } }); +// can't delete standard states +frappe.ui.form.on("DocType State", { + before_states_remove: function(frm, doctype, name) { + let row = frappe.get_doc(doctype, name); + if (!(row.custom || row.__islocal)) { + frappe.msgprint(__("Cannot delete standard document state.")); + throw "cannot delete standard document state"; + } + }, + states_add: function(frm, cdt, cdn) { + let f = frappe.model.get_doc(cdt, cdn); + f.custom = 1; + } +}); + frappe.customize_form.set_primary_action = function(frm) { frm.page.set_primary_action(__("Update"), function() { if (frm.doc.doc_type) { @@ -332,3 +348,4 @@ frappe.customize_form.clear_locals_and_refresh = function(frm) { frm.refresh(); } +extend_cscript(cur_frm.cscript, new frappe.model.DocTypeController({frm: cur_frm})); diff --git a/frappe/custom/doctype/customize_form/customize_form.json b/frappe/custom/doctype/customize_form/customize_form.json index cdcac1582a..d266b8688a 100644 --- a/frappe/custom/doctype/customize_form/customize_form.json +++ b/frappe/custom/doctype/customize_form/customize_form.json @@ -42,6 +42,8 @@ "actions", "document_links_section", "links", + "document_states_section", + "states", "section_break_8", "sort_field", "column_break_10", @@ -282,6 +284,20 @@ "fieldtype": "Data", "label": "Auto Name" }, + { + "collapsible": 1, + "collapsible_depends_on": "states", + "depends_on": "doc_type", + "fieldname": "document_states_section", + "fieldtype": "Section Break", + "label": "Document States" + }, + { + "fieldname": "states", + "fieldtype": "Table", + "label": "States", + "options": "DocType State" + }, { "default": "0", "fieldname": "show_title_field_in_link", @@ -295,10 +311,11 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-08-03 13:43:27.938781", + "modified": "2021-12-15 16:45:04.308690", "modified_by": "Administrator", "module": "Custom", "name": "Customize Form", + "naming_rule": "Expression (old style)", "owner": "Administrator", "permissions": [ { @@ -315,5 +332,6 @@ "search_fields": "doc_type", "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py index 1866f4d368..3d312d3de7 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -72,7 +72,7 @@ class CustomizeForm(Document): new_d[prop] = d.get(prop) self.append("fields", new_d) - for fieldname in ('links', 'actions'): + for fieldname in ('links', 'actions', 'states'): for d in meta.get(fieldname): self.append(fieldname, d) @@ -193,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"): @@ -248,7 +258,8 @@ class CustomizeForm(Document): ''' for doctype, fieldname, field_map in ( ('DocType Link', 'links', doctype_link_properties), - ('DocType Action', 'actions', doctype_action_properties) + ('DocType Action', 'actions', doctype_action_properties), + ('DocType State', 'states', doctype_state_properties), ): has_custom = False items = [] @@ -559,6 +570,11 @@ doctype_action_properties = { 'hidden': 'Check' } +doctype_state_properties = { + 'title': 'Data', + 'color': 'Select' +} + ALLOWED_FIELDTYPE_CHANGE = ( ('Currency', 'Float', 'Percent'), 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.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.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..9707f1ee1c 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", @@ -35,7 +37,7 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Applied On", - "options": "\nDocField\nDocType\nDocType Link\nDocType Action", + "options": "\nDocField\nDocType\nDocType Link\nDocType Action\nDocType State", "read_only_depends_on": "eval:!doc.__islocal", "reqd": 1 }, @@ -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-12-14 14:15:41.929071", "modified_by": "Administrator", "module": "Custom", "name": "Property Setter", @@ -129,5 +141,6 @@ "search_fields": "doc_type,property", "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/frappe/custom/doctype/property_setter/property_setter.py b/frappe/custom/doctype/property_setter/property_setter.py index 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/form_tour/custom_field/custom_field.json b/frappe/custom/form_tour/custom_field/custom_field.json new file mode 100644 index 0000000000..3279449e7c --- /dev/null +++ b/frappe/custom/form_tour/custom_field/custom_field.json @@ -0,0 +1,79 @@ +{ + "creation": "2021-11-23 12:22:32.922700", + "docstatus": 0, + "doctype": "Form Tour", + "first_document": 0, + "idx": 0, + "include_name_field": 0, + "is_standard": 1, + "modified": "2021-11-24 19:15:34.244244", + "modified_by": "Administrator", + "module": "Custom", + "name": "Custom Field", + "owner": "Administrator", + "reference_doctype": "Custom Field", + "save_on_complete": 1, + "steps": [ + { + "description": "Select a Document for which you want the Custom Field", + "field": "", + "fieldname": "dt", + "fieldtype": "Link", + "has_next_condition": 0, + "is_table_field": 0, + "label": "Document", + "parent_field": "", + "position": "Right", + "title": "Document" + }, + { + "description": "Enter a Label for this field", + "field": "", + "fieldname": "label", + "fieldtype": "Data", + "has_next_condition": 0, + "is_table_field": 0, + "label": "Label", + "parent_field": "", + "position": "Right", + "title": "Label" + }, + { + "description": "Select the label after which you want to insert new field.", + "field": "", + "fieldname": "insert_after", + "fieldtype": "Select", + "has_next_condition": 0, + "is_table_field": 0, + "label": "Insert After", + "parent_field": "", + "position": "Right", + "title": "Insert After" + }, + { + "description": "Select an appropriate Field Type that suits your requirements", + "field": "", + "fieldname": "fieldtype", + "fieldtype": "Select", + "has_next_condition": 0, + "is_table_field": 0, + "label": "Field Type", + "parent_field": "", + "position": "Left", + "title": "Field Type" + }, + { + "description": "Check this to make it a mandatory field", + "field": "", + "fieldname": "reqd", + "fieldtype": "Check", + "has_next_condition": 0, + "is_table_field": 0, + "label": "Is Mandatory Field", + "parent_field": "", + "position": "Left", + "title": "Is Mandatory Field" + } + ], + "title": "Custom Field" +} \ No newline at end of file diff --git a/frappe/custom/module_onboarding/customization/customization.json b/frappe/custom/module_onboarding/customization/customization.json new file mode 100644 index 0000000000..99b7cc1f2b --- /dev/null +++ b/frappe/custom/module_onboarding/customization/customization.json @@ -0,0 +1,44 @@ +{ + "allow_roles": [ + { + "role": "All" + } + ], + "creation": "2021-11-23 12:21:11.384229", + "docstatus": 0, + "doctype": "Module Onboarding", + "documentation_url": "https://docs.erpnext.com/docs/v13/user/manual/en/customize-erpnext", + "idx": 0, + "is_complete": 0, + "modified": "2021-11-24 17:04:31.523715", + "modified_by": "Administrator", + "module": "Custom", + "name": "Customization", + "owner": "Administrator", + "steps": [ + { + "step": "Custom Field" + }, + { + "step": "Custom Doctype" + }, + { + "step": "Naming Series" + }, + { + "step": "Workflows" + }, + { + "step": "Role Permissions" + }, + { + "step": "Print Format" + }, + { + "step": "Report Builder" + } + ], + "subtitle": "Custom Field, Custom Doctype, Naming Series, Role Permission, Workflow, Print Formats, Reports", + "success_message": "Customization onboarding is all done!", + "title": "Customization" +} \ No newline at end of file diff --git a/frappe/custom/onboarding_step/custom_doctype/custom_doctype.json b/frappe/custom/onboarding_step/custom_doctype/custom_doctype.json new file mode 100644 index 0000000000..1f8601abee --- /dev/null +++ b/frappe/custom/onboarding_step/custom_doctype/custom_doctype.json @@ -0,0 +1,21 @@ +{ + "action": "Create Entry", + "action_label": "Learn more about creating new DocTypes", + "creation": "2021-11-23 12:30:04.407568", + "description": "A DocType (Document Type) is used to insert forms in ERPNext. Forms such as Customer, Orders, and Invoices are Doctypes in the backend. You can also create new DocTypes to create new forms in ERPNext as per your business needs.", + "docstatus": 0, + "doctype": "Onboarding Step", + "idx": 0, + "is_complete": 0, + "is_single": 0, + "is_skipped": 0, + "modified": "2021-11-23 12:30:04.407568", + "modified_by": "Administrator", + "name": "Custom Doctype", + "owner": "Administrator", + "reference_document": "DocType", + "show_form_tour": 1, + "show_full_form": 1, + "title": "Custom Document Types", + "validate_action": 1 +} \ No newline at end of file diff --git a/frappe/custom/onboarding_step/custom_field/custom_field.json b/frappe/custom/onboarding_step/custom_field/custom_field.json new file mode 100644 index 0000000000..4044cf2456 --- /dev/null +++ b/frappe/custom/onboarding_step/custom_field/custom_field.json @@ -0,0 +1,21 @@ +{ + "action": "Create Entry", + "action_label": "Learn how to add Custom Fields", + "creation": "2021-11-23 12:21:09.479808", + "description": "Every form in ERPNext has a standard set of fields. If you need to capture some information, but there is no standard Field available for it, you can insert Custom Field for it.\n\nOnce custom fields are added, you can use them for reports and analytics charts as well.\n", + "docstatus": 0, + "doctype": "Onboarding Step", + "idx": 0, + "is_complete": 0, + "is_single": 0, + "is_skipped": 0, + "modified": "2021-11-23 12:21:09.479808", + "modified_by": "Administrator", + "name": "Custom Field", + "owner": "Administrator", + "reference_document": "Custom Field", + "show_form_tour": 1, + "show_full_form": 1, + "title": "Create Custom Fields", + "validate_action": 1 +} \ No newline at end of file diff --git a/frappe/custom/onboarding_step/naming_series/naming_series.json b/frappe/custom/onboarding_step/naming_series/naming_series.json new file mode 100644 index 0000000000..3b15e4afde --- /dev/null +++ b/frappe/custom/onboarding_step/naming_series/naming_series.json @@ -0,0 +1,20 @@ +{ + "action": "Watch Video", + "creation": "2021-11-23 13:57:45.091427", + "description": "Each document created in ERPNext can have a unique ID generated for it, using a prefix defined for it. Though each document has some prefix pre-configured, you can further customize it using tools like Naming Series Tool and Document Naming Rule.\n", + "docstatus": 0, + "doctype": "Onboarding Step", + "idx": 0, + "is_complete": 0, + "is_single": 0, + "is_skipped": 0, + "modified": "2021-11-24 15:04:14.662684", + "modified_by": "Administrator", + "name": "Naming Series", + "owner": "Administrator", + "show_form_tour": 0, + "show_full_form": 0, + "title": "Setup Naming Series", + "validate_action": 1, + "video_url": "https://youtu.be/IGyISSfI1qU" +} \ No newline at end of file diff --git a/frappe/custom/onboarding_step/print_format/print_format.json b/frappe/custom/onboarding_step/print_format/print_format.json new file mode 100644 index 0000000000..681ef85b95 --- /dev/null +++ b/frappe/custom/onboarding_step/print_format/print_format.json @@ -0,0 +1,21 @@ +{ + "action": "Create Entry", + "action_label": "Learn about Standard and Custom Print Formats", + "creation": "2021-11-23 15:04:12.728513", + "description": "Print Formats allow you can define looks for documents when printed or converted to PDF. You can also create a custom Print Format using drag-and-drop tools.", + "docstatus": 0, + "doctype": "Onboarding Step", + "idx": 0, + "is_complete": 0, + "is_single": 0, + "is_skipped": 0, + "modified": "2021-11-23 15:04:12.728513", + "modified_by": "Administrator", + "name": "Print Format", + "owner": "Administrator", + "reference_document": "Print Format", + "show_form_tour": 1, + "show_full_form": 1, + "title": "Customize Print Formats", + "validate_action": 1 +} \ No newline at end of file diff --git a/frappe/custom/onboarding_step/report_builder/report_builder.json b/frappe/custom/onboarding_step/report_builder/report_builder.json new file mode 100644 index 0000000000..4a0b5f9130 --- /dev/null +++ b/frappe/custom/onboarding_step/report_builder/report_builder.json @@ -0,0 +1,22 @@ +{ + "action": "Watch Video", + "action_label": "Learn more about Report Builders", + "creation": "2021-11-24 17:04:18.762838", + "description": "In each module, you will find a host of single-click reports, ranging from financial statements to sales and purchase analytics and stock tracking reports. If a required new report is not available out-of-the-box, you can create custom reports in ERPNext by pulling values from the same multiple ERPNext tables.\n", + "docstatus": 0, + "doctype": "Onboarding Step", + "idx": 0, + "is_complete": 0, + "is_single": 0, + "is_skipped": 0, + "modified": "2021-11-24 17:04:18.762838", + "modified_by": "Administrator", + "name": "Report Builder", + "owner": "Administrator", + "reference_document": "Report", + "show_form_tour": 0, + "show_full_form": 0, + "title": "Generate Custom Reports", + "validate_action": 1, + "video_url": "https://youtu.be/TxJGUNarcQs" +} \ No newline at end of file diff --git a/frappe/custom/onboarding_step/role_permissions/role_permissions.json b/frappe/custom/onboarding_step/role_permissions/role_permissions.json new file mode 100644 index 0000000000..a817126989 --- /dev/null +++ b/frappe/custom/onboarding_step/role_permissions/role_permissions.json @@ -0,0 +1,20 @@ +{ + "action": "Watch Video", + "creation": "2021-11-23 14:00:27.208500", + "description": "In ERPNext, you can add your Employees as Users, and give them restricted access. Tools like Role Permission and User Permission allow you to define rules which give restricted access to the user to masters and transactions.", + "docstatus": 0, + "doctype": "Onboarding Step", + "idx": 0, + "is_complete": 0, + "is_single": 0, + "is_skipped": 0, + "modified": "2021-11-24 15:04:14.615232", + "modified_by": "Administrator", + "name": "Role Permissions", + "owner": "Administrator", + "show_form_tour": 0, + "show_full_form": 0, + "title": "Setup Limited Access for a User", + "validate_action": 1, + "video_url": "https://youtu.be/g3mk45o1zAg" +} \ No newline at end of file diff --git a/frappe/custom/onboarding_step/workflows/workflows.json b/frappe/custom/onboarding_step/workflows/workflows.json new file mode 100644 index 0000000000..683b7a398a --- /dev/null +++ b/frappe/custom/onboarding_step/workflows/workflows.json @@ -0,0 +1,20 @@ +{ + "action": "Watch Video", + "creation": "2021-11-23 13:58:58.530044", + "description": "Workflows allow you to define custom rules for the approval process of a particular document in ERPNext. You can also set complex Workflow Rules and set approval conditions.", + "docstatus": 0, + "doctype": "Onboarding Step", + "idx": 0, + "is_complete": 0, + "is_single": 0, + "is_skipped": 0, + "modified": "2021-11-24 15:04:14.632144", + "modified_by": "Administrator", + "name": "Workflows", + "owner": "Administrator", + "show_form_tour": 0, + "show_full_form": 0, + "title": "Setup Approval Workflows", + "validate_action": 1, + "video_url": "https://youtu.be/yObJUg9FxFs" +} \ No newline at end of file diff --git a/frappe/custom/workspace/customization/customization.json b/frappe/custom/workspace/customization/customization.json index cdc3b73366..8938bdec9c 100644 --- a/frappe/custom/workspace/customization/customization.json +++ b/frappe/custom/workspace/customization/customization.json @@ -1,23 +1,20 @@ { - "category": "Administration", "charts": [], + "content": "[{\"type\":\"onboarding\",\"data\":{\"onboarding_name\":\"Customization\",\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\",\"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\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\",\"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-11-24 16:20:03.500885", "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..d13912b431 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,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 from frappe.modules import get_module_path, scrub_dt_dn @@ -8,6 +7,20 @@ from frappe.modules.export_file import export_to_files, create_init_py from frappe.custom.doctype.custom_field.custom_field import create_custom_field from frappe.model.document import Document + +def get_mapping_module(module, mapping_name): + app_name = frappe.db.get_value("Module Def", module, "app_name") + mapping_name = frappe.scrub(mapping_name) + module = frappe.scrub(module) + + try: + return frappe.get_module( + f"{app_name}.{module}.data_migration_mapping.{mapping_name}" + ) + except ImportError: + return None + + class DataMigrationPlan(Document): def on_update(self): # update custom fields in mappings @@ -54,26 +67,14 @@ class DataMigrationPlan(Document): frappe.flags.ignore_in_install = False def pre_process_doc(self, mapping_name, doc): - module = self.get_mapping_module(mapping_name) + module = get_mapping_module(self.module, mapping_name) if module and hasattr(module, 'pre_process'): return module.pre_process(doc) return doc def post_process_doc(self, mapping_name, local_doc=None, remote_doc=None): - module = self.get_mapping_module(mapping_name) + module = get_mapping_module(self.module, mapping_name) if module and hasattr(module, 'post_process'): return module.post_process(local_doc=local_doc, remote_doc=remote_doc) - - def get_mapping_module(self, mapping_name): - try: - module_def = frappe.get_doc("Module Def", self.module) - module = frappe.get_module('{app}.{module}.data_migration_mapping.{mapping_name}'.format( - app= module_def.app_name, - module=frappe.scrub(self.module), - mapping_name=frappe.scrub(mapping_name) - )) - return module - except ImportError: - return None 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 b1dec95139..0f325a746e 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.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 # -------------------- @@ -14,8 +14,13 @@ import frappe.model.meta from frappe import _ from time import time -from frappe.utils import now, getdate, cast_fieldtype, get_datetime, get_table_name +from frappe.utils import now, getdate, cast, get_datetime from frappe.model.utils.link_count import flush_local_link_count +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 @@ -105,6 +113,9 @@ class Database(object): """ 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) @@ -159,6 +170,12 @@ class Database(object): frappe.errprint('Syntax error in query:') frappe.errprint(query) + elif self.is_deadlocked(e): + raise frappe.QueryDeadlockError(e) + + elif self.is_timedout(e): + raise frappe.QueryTimeoutError(e) + 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: @@ -169,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) @@ -224,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: @@ -232,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 @@ -240,6 +260,7 @@ class Database(object): self.commit() self.sql(query, debug=debug) + def check_transaction_status(self, query): """Raises exception if more than 20,000 `INSERT`, `UPDATE` queries are executed in one transaction. This is to ensure that writes are always flushed otherwise this @@ -253,7 +274,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: @@ -310,65 +331,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="KEEP_DEFAULT_ORDERING", cache=False, for_update=False, run=True, pluck=False): """Returns a document property or list of properties. :param doctype: DocType name. @@ -395,12 +363,16 @@ 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, pluck=pluck) + + 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="KEEP_DEFAULT_ORDERING", update=None, cache=False, for_update=False, + run=True, pluck=False): """Returns multiple document properties. :param doctype: DocType name. @@ -424,10 +396,8 @@ 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) + out = self._get_value_for_many_names(doctype, filters, fieldname, order_by, debug=debug, run=run, pluck=pluck) else: fields = fieldname @@ -439,26 +409,49 @@ 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) + if order_by: + order_by = "modified" if order_by == "KEEP_DEFAULT_ORDERING" else order_by + out = self._get_values_from_table( + fields, + filters, + doctype, + as_dict, + debug, + order_by, + update, + for_update=for_update, + run=run, + pluck=pluck, + ) 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, pluck=pluck) 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, + pluck=False, + ): """Get values from `tabSingles` (Single DocTypes) (internal). :param fields: List of fields, @@ -484,11 +477,18 @@ class Database(object): return [map(values.get, fields)] else: - r = self.sql("""select field, value + 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) - + % (", ".join(["%s"] * len(fields)), "%s"), + tuple(fields) + (doctype,), + as_dict=False, + debug=debug, + run=run, + pluck=pluck, + ) + if not run: + return r if as_dict: if r: r = frappe._dict(r) @@ -511,15 +511,10 @@ class Database(object): # Get coulmn and value of the single doctype Accounts Settings account_settings = frappe.db.get_singles_dict("Accounts Settings") """ - result = self.sql(""" - SELECT field, value - FROM `tabSingles` - WHERE doctype = %s - """, doctype) - # result = _cast_result(doctype, result) - + result = self.query.get_sql( + "Singles", filters={"doctype": doctype}, fields=["field", "value"] + ).run() dict_ = frappe._dict(result) - return dict_ @staticmethod @@ -543,13 +538,16 @@ 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] - val = self.sql("""select `value` from - `tabSingles` where `doctype`=%s and `field`=%s""", (doctype, fieldname)) + val = self.query.get_sql( + table="Singles", + filters={"doctype": doctype, "field": fieldname}, + fields="value", + ).run() val = val[0][0] if val else None df = frappe.get_meta(doctype).get_field(fieldname) @@ -557,7 +555,7 @@ class Database(object): if not df: frappe.throw(_('Invalid field name: {0}').format(frappe.bold(fieldname)), self.InvalidColumnName) - val = cast_fieldtype(df.fieldtype, val) + val = cast(df.fieldtype, val) self.value_cache[doctype][fieldname] = val @@ -567,44 +565,61 @@ 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, + pluck=False, + ): + field_objects = [] + + if not isinstance(fields, Criterion): + for field in fields: + if "(" in str(field) or " as " in str(field): + field_objects.append(PseudoColumn(field)) else: - fl.append("`" + f + "`") - fl = ", ".join(fl) - else: - fl = fields - if fields=="*": - as_dict = True + field_objects.append(field) - 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) + query = self.query.get_sql( + table=doctype, + filters=filters, + orderby=order_by, + for_update=for_update, + field_objects=field_objects, + fields=fields, + ) + if ( + fields == "*" + and not isinstance(fields, (list, tuple)) + and not isinstance(fields, Criterion) + ): + as_dict = True + r = self.sql( + query, as_dict=as_dict, debug=debug, update=update, run=run, pluck=pluck + ) return r - def _get_value_for_many_names(self, doctype, names, field, debug=False): + def _get_value_for_many_names(self, doctype, names, field, order_by, debug=False, run=True, pluck=False): names = list(filter(None, names)) - if names: - return self.get_all(doctype, - fields=['name', field], - filters=[['name', 'in', names]], - debug=debug, as_list=1) + return self.get_all( + doctype, + fields=field, + filters=names, + order_by=order_by, + pluck=pluck, + debug=debug, + as_list=1, + run=run, + ) else: return {} @@ -649,7 +664,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) @@ -826,18 +841,14 @@ class Database(object): cache_count = frappe.cache().get_value('doctype:count:{}'.format(dt)) if cache_count is not None: return cache_count + query = self.query.get_sql(table=dt, filters=filters, fields=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 +907,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 +939,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): @@ -961,16 +972,9 @@ class Database(object): """ values = () filters = filters or kwargs.get("conditions") - table = get_table_name(doctype) - query = f"DELETE FROM `{table}`" - + query = self.query.build_conditions(table=doctype, filters=filters).delete() if "debug" not in kwargs: kwargs["debug"] = debug - - if filters: - conditions, values = self.build_conditions(filters) - query = f"{query} WHERE {conditions}" - return self.sql(query, values, **kwargs) def truncate(self, doctype: str): @@ -1052,19 +1056,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 5dd6d9e58a..2f6d640743 100644 --- a/frappe/database/mariadb/database.py +++ b/frappe/database/mariadb/database.py @@ -22,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', ''), @@ -51,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): @@ -134,8 +135,8 @@ class MariaDBDatabase(Database): table_name = get_table_name(doctype) return self.sql(f"DESC `{table_name}`") - def change_column_type(self, table: str, column: str, type: str) -> Union[List, Tuple]: - table_name = get_table_name(table) + 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 @@ -194,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(): @@ -255,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 a74ece8478..0def9ef748 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, @@ -225,9 +227,10 @@ CREATE TABLE `tabDocType` ( `subject_field` varchar(255) DEFAULT NULL, `sender_field` varchar(255) DEFAULT NULL, `show_title_field_in_link` int(1) NOT NULL DEFAULT 0, + `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` @@ -238,7 +241,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; -- @@ -255,7 +258,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; -- @@ -268,7 +271,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` @@ -282,7 +285,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` @@ -310,7 +313,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` @@ -333,4 +336,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 0b73c8b44b..bfa5515111 100644 --- a/frappe/database/postgres/database.py +++ b/frappe/database/postgres/database.py @@ -4,6 +4,7 @@ from typing import List, Tuple, Union import psycopg2 import psycopg2.extensions from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT +from psycopg2.errorcodes import STRING_DATA_RIGHT_TRUNCATION import frappe from frappe.database.database import Database @@ -31,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', ''), @@ -60,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): @@ -170,7 +172,7 @@ 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) @@ -181,8 +183,8 @@ class PostgresDatabase(Database): 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, table: str, column: str, type: str) -> Union[List, Tuple]: - table_name = get_table_name(table) + 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): @@ -257,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): diff --git a/frappe/database/postgres/framework_postgres.sql b/frappe/database/postgres/framework_postgres.sql index f65a832bc6..d90d26cb90 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, @@ -230,6 +232,7 @@ CREATE TABLE "tabDocType" ( "subject_field" varchar(255) DEFAULT NULL, "sender_field" varchar(255) DEFAULT NULL, "show_title_field_in_link" smallint NOT NULL DEFAULT 0, + "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..6d2be5fa25 --- /dev/null +++ b/frappe/database/query.py @@ -0,0 +1,329 @@ +import operator +import re +from typing import Any, Dict, List, Tuple, Union + +import frappe +from frappe import _ +from frappe.query_builder import Criterion, Field, Order + + +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: + conditions = self.add_conditions(conditions, **kwargs) + 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, int) or isinstance(filters, str): + filters = {"name": str(filters)} + + if isinstance(filters, Criterion): + criterion = self.criterion_query(table, filters, **kwargs) + + elif isinstance(filters, (list, tuple)): + criterion = self.misc_query(table, filters, **kwargs) + + else: + criterion = self.dict_query(filters=filters, table=table, **kwargs) + + return criterion + + def get_sql( + self, + table: str, + fields: Union[List, Tuple], + filters: Union[Dict[str, Union[str, int]], str, int] = None, + **kwargs + ): + criterion = self.build_conditions(table, filters, **kwargs) + if isinstance(fields, (list, tuple)): + query = criterion.select(*kwargs.get("field_objects", fields)) + + elif isinstance(fields, Criterion): + query = criterion.select(fields) + + else: + query = criterion.select(fields) + + return query + + +class Permission: + @classmethod + def check_permissions(cls, query, **kwargs): + if not isinstance(query, str): + query = query.get_sql() + + doctype = cls.get_tables_from_query(query) + if isinstance(doctype, str): + doctype = [doctype] + + for dt in doctype: + dt = re.sub("tab", "", dt) + if not frappe.has_permission( + dt, + "select", + user=kwargs.get("user"), + parent_doctype=kwargs.get("parent_doctype"), + ) and not frappe.has_permission( + dt, + "read", + user=kwargs.get("user"), + parent_doctype=kwargs.get("parent_doctype"), + ): + frappe.throw( + _("Insufficient Permission for {0}").format(frappe.bold(dt)) + ) + + @staticmethod + def get_tables_from_query(query: str): + return [table for table in re.findall(r"\w+", query) if table.startswith("tab")] 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 d4c338388d..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,14 +117,11 @@ def set_default(key, value, parent, parenttype="__default"): :param value: Default value. :param parent: Usually, **User** to whom the default belongs. :param parenttype: [optional] default is `__default`.""" - if frappe.db.sql(''' - select - defkey - from - `tabDefaultValue` - where - defkey=%s and parent=%s - for update''', (key, parent)): + table = DocType("DefaultValue") + key_exists = frappe.qb.from_(table).where( + (table.defkey == key) & (table.parent == parent) + ).select(table.defkey).for_update().run() + if key_exists: frappe.db.delete("DefaultValue", { "defkey": key, "parent": parent @@ -191,8 +189,12 @@ def get_defaults_for(parent="__default"): if defaults==None: # sort descending because first default must get precedence - res = frappe.db.sql("""select defkey, defvalue from `tabDefaultValue` - where parent = %s order by creation""", (parent,), as_dict=1) + table = DocType("DefaultValue") + res = frappe.qb.from_(table).where( + table.parent == parent + ).select( + table.defkey, table.defvalue + ).orderby("creation").run(as_dict=True) defaults = frappe._dict({}) for d in res: diff --git a/frappe/deferred_insert.py b/frappe/deferred_insert.py index 499fc5e41b..b1338a73b0 100644 --- a/frappe/deferred_insert.py +++ b/frappe/deferred_insert.py @@ -5,7 +5,6 @@ from frappe.utils import cstr queue_prefix = 'insert_queue_for_' -@frappe.whitelist() def deferred_insert(doctype, records): frappe.cache().rpush(queue_prefix + doctype, records) 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 273b2654bf..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 _ 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 ca53e6cba4..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,59 +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") @@ -439,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= @@ -460,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 = \ @@ -540,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 @@ -575,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 9f10522b12..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 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 28c5a670cb..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 _ 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.json b/frappe/desk/doctype/event/event.json index 5768f00f32..2f67c36fc0 100644 --- a/frappe/desk/doctype/event/event.json +++ b/frappe/desk/doctype/event/event.json @@ -53,7 +53,7 @@ }, { "fieldname": "subject", - "fieldtype": "Data", + "fieldtype": "Small Text", "in_global_search": 1, "in_list_view": 1, "label": "Subject", @@ -277,10 +277,11 @@ "icon": "fa fa-calendar", "idx": 1, "links": [], - "modified": "2020-01-14 21:47:15.825287", + "modified": "2021-11-18 05:06:24.881742", "modified_by": "Administrator", "module": "Desk", "name": "Event", + "naming_rule": "Expression (old style)", "owner": "Administrator", "permissions": [ { diff --git a/frappe/desk/doctype/event/event.py b/frappe/desk/doctype/event/event.py index e7e7be530b..86f0656bc6 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 @@ -11,6 +11,7 @@ from frappe import _ from frappe.model.document import Document from frappe.utils.user import get_enabled_system_users from frappe.desk.reportview import get_filters_cond +from frappe.desk.doctype.notification_settings.notification_settings import is_email_notifications_enabled_for_type weekdays = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] communication_mapping = {"": "Event", "Event": "Event", "Meeting": "Meeting", "Call": "Phone", "Sent/Received Email": "Email", "Other": "Other"} @@ -141,7 +142,12 @@ def has_permission(doc, user): def send_event_digest(): today = nowdate() - for user in get_enabled_system_users(): + + # select only those users that have event reminder email notifications enabled + users = [user for user in get_enabled_system_users() if + is_email_notifications_enabled_for_type(user.name, 'Event Reminders')] + + for user in users: events = get_events(today, today, user.name, for_reminder=True) if events: frappe.set_user_lang(user.name, user.language) 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/desk/doctype/form_tour/form_tour.js b/frappe/desk/doctype/form_tour/form_tour.js index 8d70dcd3dc..6a7c736fac 100644 --- a/frappe/desk/doctype/form_tour/form_tour.js +++ b/frappe/desk/doctype/form_tour/form_tour.js @@ -15,10 +15,13 @@ frappe.ui.form.on('Form Tour', { frm.add_custom_button(__('Show Tour'), async () => { const issingle = await check_if_single(frm.doc.reference_doctype); + const name = await get_first_document(frm.doc.reference_doctype); let route_changed = null; if (issingle) { route_changed = frappe.set_route('Form', frm.doc.reference_doctype); + } else if (frm.doc.first_document) { + route_changed = frappe.set_route('Form', frm.doc.reference_doctype, name); } else { route_changed = frappe.set_route('Form', frm.doc.reference_doctype, 'new'); } @@ -120,4 +123,15 @@ function get_child_field(child_table, child_name, 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 +} + +async function get_first_document(doctype) { + let docname; + + await frappe.db.get_list(doctype, { order_by: "creation" }).then(res => { + if (Array.isArray(res) && res.length) + docname = res[0].name; + }); + + return docname || 'new'; +} diff --git a/frappe/desk/doctype/form_tour/form_tour.json b/frappe/desk/doctype/form_tour/form_tour.json index e4ea528fcc..6f3bd56a4e 100644 --- a/frappe/desk/doctype/form_tour/form_tour.json +++ b/frappe/desk/doctype/form_tour/form_tour.json @@ -9,8 +9,11 @@ "title", "reference_doctype", "module", + "column_break_6", "is_standard", "save_on_complete", + "first_document", + "include_name_field", "section_break_3", "steps" ], @@ -62,14 +65,32 @@ "label": "Module", "options": "Module Def", "read_only": 1 + }, + { + "fieldname": "column_break_6", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "first_document", + "fieldtype": "Check", + "label": "Show First Document Tour" + }, + { + "default": "0", + "depends_on": "eval:!doc.first_document", + "fieldname": "include_name_field", + "fieldtype": "Check", + "label": "Include Name Field" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2021-06-06 20:32:54.068774", + "modified": "2021-11-24 12:03:45.449311", "modified_by": "Administrator", "module": "Desk", "name": "Form Tour", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { diff --git a/frappe/desk/doctype/form_tour/form_tour.py b/frappe/desk/doctype/form_tour/form_tour.py index dbc667ce28..82d47224dd 100644 --- a/frappe/desk/doctype/form_tour/form_tour.py +++ b/frappe/desk/doctype/form_tour/form_tour.py @@ -1,5 +1,5 @@ # 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 @@ -25,7 +25,7 @@ class FormTour(Document): 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 = "" diff --git a/frappe/desk/doctype/form_tour/test_form_tour.py b/frappe/desk/doctype/form_tour/test_form_tour.py index a4a796ce41..3670cbc218 100644 --- a/frappe/desk/doctype/form_tour/test_form_tour.py +++ b/frappe/desk/doctype/form_tour/test_form_tour.py @@ -1,5 +1,5 @@ # Copyright (c) 2021, Frappe Technologies and Contributors -# See license.txt +# License: MIT. See LICENSE # import frappe import unittest diff --git a/frappe/desk/doctype/form_tour_step/form_tour_step.py b/frappe/desk/doctype/form_tour_step/form_tour_step.py index 0df5665c63..bbc8edea08 100644 --- a/frappe/desk/doctype/form_tour_step/form_tour_step.py +++ b/frappe/desk/doctype/form_tour_step/form_tour_step.py @@ -1,5 +1,5 @@ # 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/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..e9a47cecd1 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 @@ -33,7 +33,7 @@ class GlobalSearchSettings(Document): def get_doctypes_for_global_search(): def get_from_db(): - doctypes = frappe.get_list("Global Search DocType", fields=["document_type"], order_by="idx ASC") + doctypes = frappe.get_all("Global Search DocType", fields=["document_type"], order_by="idx ASC") return [d.document_type for d in doctypes] or [] return frappe.cache().hget("global_search", "search_priorities", get_from_db) 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.json b/frappe/desk/doctype/kanban_board_column/kanban_board_column.json index 95d9294e9a..c0acde5da5 100644 --- a/frappe/desk/doctype/kanban_board_column/kanban_board_column.json +++ b/frappe/desk/doctype/kanban_board_column/kanban_board_column.json @@ -1,155 +1,55 @@ { - "allow_copy": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2016-10-19 12:26:42.569185", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 1, - "engine": "InnoDB", + "actions": [], + "creation": "2016-10-19 12:26:42.569185", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "column_name", + "status", + "indicator", + "order" + ], "fields": [ { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_name", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Column Name", - "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 - }, + "fieldname": "column_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Column Name" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "Active", - "fieldname": "status", - "fieldtype": "Select", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Status", - "length": 0, - "no_copy": 0, - "options": "Active\nArchived", - "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": "Active", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "Active\nArchived" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "darkgrey", - "fieldname": "indicator", - "fieldtype": "Select", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "in_standard_filter": 0, - "label": "Indicator", - "length": 0, - "no_copy": 0, - "options": "blue\norange\nred\ngreen\ndarkgrey\npurple\nyellow\nlightblue", - "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": "Gray", + "fieldname": "indicator", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Indicator", + "options": "Blue\nCyan\nGray\nGreen\nLight Blue\nOrange\nPink\nPurple\nRed\nRed\nYellow" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "order", - "fieldtype": "Code", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Order", - "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 + "fieldname": "order", + "fieldtype": "Code", + "label": "Order" } - ], - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - - "is_submittable": 0, - "issingle": 0, - "istable": 1, - "max_attachments": 0, - "modified": "2017-01-17 15:23:43.520379", - "modified_by": "Administrator", - "module": "Desk", - "name": "Kanban Board Column", - "name_case": "", - "owner": "Administrator", - "permissions": [], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1, - "track_seen": 0 + ], + "istable": 1, + "links": [], + "modified": "2021-12-14 13:13:38.804259", + "modified_by": "Administrator", + "module": "Desk", + "name": "Kanban Board Column", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 } \ No newline at end of file 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.css b/frappe/desk/doctype/note/note.css deleted file mode 100644 index b5026d2e46..0000000000 --- a/frappe/desk/doctype/note/note.css +++ /dev/null @@ -1,3 +0,0 @@ -.like-disabled-input{ - background-color: #fff; -} \ No newline at end of file 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 d7d7f68b74..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 _ 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.json b/frappe/desk/doctype/notification_settings/notification_settings.json index fc12022e89..1a6efd5a0d 100644 --- a/frappe/desk/doctype/notification_settings/notification_settings.json +++ b/frappe/desk/doctype/notification_settings/notification_settings.json @@ -14,8 +14,11 @@ "enable_email_assignment", "enable_email_energy_point", "enable_email_share", + "enable_email_event_reminders", "user", - "seen" + "seen", + "system_notifications_section", + "energy_points_system_notifications" ], "fields": [ { @@ -84,15 +87,34 @@ "fieldtype": "Check", "hidden": 1, "label": "Seen" + }, + { + "fieldname": "system_notifications_section", + "fieldtype": "Section Break", + "label": "System Notifications" + }, + { + "default": "1", + "fieldname": "energy_points_system_notifications", + "fieldtype": "Check", + "label": "Energy Points" + }, + { + "default": "1", + "depends_on": "enable_email_notifications", + "fieldname": "enable_email_event_reminders", + "fieldtype": "Check", + "label": "Event Reminders" } ], "in_create": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2020-11-04 12:54:57.989317", + "modified": "2021-11-24 14:45:31.931154", "modified_by": "Administrator", "module": "Desk", "name": "Notification Settings", + "naming_rule": "Set by user", "owner": "Administrator", "permissions": [ { 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_settings/test_notification_settings.py b/frappe/desk/doctype/notification_settings/test_notification_settings.py new file mode 100644 index 0000000000..e3dac0af5f --- /dev/null +++ b/frappe/desk/doctype/notification_settings/test_notification_settings.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies and Contributors +# See license.txt + +# import frappe +import unittest + +class TestNotificationSettings(unittest.TestCase): + pass 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.js b/frappe/desk/doctype/onboarding_step/onboarding_step.js index 793e044d98..3c9bbab9ac 100644 --- a/frappe/desk/doctype/onboarding_step/onboarding_step.js +++ b/frappe/desk/doctype/onboarding_step/onboarding_step.js @@ -2,6 +2,17 @@ // For license information, please see license.txt frappe.ui.form.on("Onboarding Step", { + + setup: function(frm) { + frm.set_query("form_tour", function() { + return { + filters: { + reference_doctype: frm.doc.reference_document + } + }; + }); + }, + refresh: function(frm) { frappe.boot.developer_mode && frm.set_intro( diff --git a/frappe/desk/doctype/onboarding_step/onboarding_step.json b/frappe/desk/doctype/onboarding_step/onboarding_step.json index f71e821f65..b5d7851eca 100644 --- a/frappe/desk/doctype/onboarding_step/onboarding_step.json +++ b/frappe/desk/doctype/onboarding_step/onboarding_step.json @@ -20,6 +20,7 @@ "reference_document", "show_full_form", "show_form_tour", + "form_tour", "is_single", "reference_report", "report_reference_doctype", @@ -206,13 +207,21 @@ "fieldname": "show_form_tour", "fieldtype": "Check", "label": "Show Form Tour" + }, + { + "depends_on": "show_form_tour", + "fieldname": "form_tour", + "fieldtype": "Link", + "label": "Form Tour", + "options": "Form Tour" } ], "links": [], - "modified": "2020-10-30 14:54:06.646513", + "modified": "2021-12-02 10:56:04.448580", "modified_by": "Administrator", "module": "Desk", "name": "Onboarding Step", + "naming_rule": "Set by user", "owner": "Administrator", "permissions": [ { 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 95872440c7..a49d5d5418 100644 --- a/frappe/desk/doctype/route_history/route_history.py +++ b/frappe/desk/doctype/route_history/route_history.py @@ -1,9 +1,13 @@ # Copyright (c) 2021, Frappe Technologies and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE + +import json import frappe +from frappe.deferred_insert import deferred_insert as _deferred_insert from frappe.model.document import Document + class RouteHistory(Document): pass @@ -35,3 +39,16 @@ def flush_old_route_records(): "modified": ("<=", last_record_to_keep[0].modified), "user": user }) + +@frappe.whitelist() +def deferred_insert(routes): + routes = [ + { + "user": frappe.session.user, + "route": route.get("route"), + "creation": route.get("creation"), + } + for route in frappe.parse_json(routes) + ] + + _deferred_insert("Route History", json.dumps(routes)) 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 2341d721e2..381c24a765 100644 --- a/frappe/desk/doctype/tag/tag.py +++ b/frappe/desk/doctype/tag/tag.py @@ -1,9 +1,10 @@ # 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 @@ -11,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() @@ -42,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): @@ -128,46 +132,35 @@ def delete_tags_for_document(doc): }) 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.delete("Tag Link", { - "document_type": dt, - "document_name": dn, - "tag": 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 6eb7219c26..b9c6e0b744 100644 --- a/frappe/desk/doctype/tag/test_tag.py +++ b/frappe/desk/doctype/tag/test_tag.py @@ -6,7 +6,7 @@ from frappe.desk.doctype.tag.tag import add_tag class TestTag(unittest.TestCase): def setUp(self) -> None: - frappe.db.sql("DELETE from `tabTag`") + frappe.db.delete("Tag") frappe.db.sql("UPDATE `tabDocType` set _user_tags=''") def test_tag_count_query(self): 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 09297b4e5e..6f3f4160e6 100644 --- a/frappe/desk/doctype/todo/todo.py +++ b/frappe/desk/doctype/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 import json @@ -29,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" } 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 41b0227f2a..94114e3918 100644 --- a/frappe/desk/doctype/workspace/workspace.py +++ b/frappe/desk/doctype/workspace/workspace.py @@ -1,43 +1,38 @@ # -*- 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] } @@ -55,7 +50,7 @@ class Workspace(Document): 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..14ea2712e2 100644 --- a/frappe/desk/form/linked_with.py +++ b/frappe/desk/form/linked_with.py @@ -1,7 +1,9 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt +# License: MIT. See LICENSE import json from collections import defaultdict +import itertools +from typing import List import frappe import frappe.desk.form.load @@ -12,72 +14,299 @@ from frappe.modules import load_doctype_module @frappe.whitelist() -def get_submitted_linked_docs(doctype, name, docs=None, visited=None): +def get_submitted_linked_docs(doctype: str, name: str) -> List[tuple]: + """ Get all the nested submitted documents those are present in referencing tables (dependent tables). + + :param doctype: Document type + :param name: Name of the document + + Usecase: + * User should be able to cancel the linked documents along with the one user trying to cancel. + + Case1: If document sd1-n1 (document name n1 from sumittable doctype sd1) is linked to sd2-n2 and sd2-n2 is linked to sd3-n3, + Getting submittable linked docs of `sd1-n1`should give both sd2-n2 and sd3-n3. + Case2: If document sd1-n1 (document name n1 from sumittable doctype sd1) is linked to d2-n2 and d2-n2 is linked to sd3-n3, + Getting submittable linked docs of `sd1-n1`should give None. (because d2-n2 is not a submittable doctype) + Case3: If document sd1-n1 (document name n1 from submittable doctype sd1) is linked to d2-n2 & sd2-n2. d2-n2 is linked to sd3-n3. + Getting submittable linked docs of `sd1-n1`should give sd2-n2. + + Logic: + ----- + 1. We can find linked documents only if we know how the doctypes are related. + 2. As we need only submittable documents, we can limit doctype relations search to submittable doctypes by + finding the relationships(Foreign key references) across submittable doctypes. + 3. Searching for links is going to be a tree like structure where at every level, + you will be finding documents using parent document and parent document links. """ - Get all nested submitted linked doctype linkinfo + tree = SubmittableDocumentTree(doctype, name) + visited_documents = tree.get_all_children() + docs = [] - Arguments: - doctype (str) - The doctype for which get all linked doctypes - name (str) - The docname for which get all linked doctypes + for dt, names in visited_documents.items(): + docs.extend([{'doctype': dt, 'name': name, 'docstatus': 1} for name in names]) - Keyword Arguments: - docs (list of dict) - (Optional) Get list of dictionary for linked doctype. - - Returns: - dict - Return list of documents and link count - """ - - if not docs: - docs = [] - - if not visited: - visited = {} - - if doctype not in visited: - visited[doctype] = [] - - if name in visited[doctype]: - return - - linkinfo = get_linked_doctypes(doctype) - linked_docs = get_linked_docs(doctype, name, linkinfo) - - link_count = 0 - visited[doctype].append(name) - - for link_doctype, link_names in linked_docs.items(): - - for link in link_names: - if link['name'] == name: - continue - - docinfo = link.update({"doctype": link_doctype}) - validated_doc = validate_linked_doc(docinfo) - - if not validated_doc: - continue - - link_count += 1 - - links = get_submitted_linked_docs(link_doctype, link.name, docs, visited) - if links: - docs.append({ - "doctype": link_doctype, - "name": link.name, - "docstatus": link.docstatus, - "link_count": links.get("count") - }) - - # sort linked documents by ascending number of links - docs.sort(key=lambda doc: doc.get("link_count")) return { "docs": docs, - "count": link_count + "count": len(docs) } +class SubmittableDocumentTree: + def __init__(self, doctype: str, name: str): + """Construct a tree for the submitable linked documents. + + * Node has properties like doctype and docnames. Represented as Node(doctype, docnames). + * Nodes are linked by doctype relationships like table, link and dynamic links. + * Node is referenced(linked) by many other documents and those are the child nodes. + + NOTE: child document is a property of child node (not same as Frappe child docs of a table field). + """ + self.root_doctype = doctype + self.root_docname = name + + # Documents those are yet to be visited for linked documents. + self.to_be_visited_documents = {doctype: [name]} + self.visited_documents = defaultdict(list) + + self._submittable_doctypes = None # All submittable doctypes in the system + self._references_across_doctypes = None # doctype wise links/references + + def get_all_children(self): + """Get all nodes of a tree except the root node (all the nested submitted + documents those are present in referencing tables (dependent tables). + """ + while self.to_be_visited_documents: + next_level_children = defaultdict(list) + for parent_dt in list(self.to_be_visited_documents): + parent_docs = self.to_be_visited_documents.get(parent_dt) + if not parent_docs: + del self.to_be_visited_documents[parent_dt] + continue + + child_docs = self.get_next_level_children(parent_dt, parent_docs) + self.visited_documents[parent_dt].extend(parent_docs) + for linked_dt, linked_names in child_docs.items(): + not_visited_child_docs = set(linked_names) - set(self.visited_documents.get(linked_dt, [])) + next_level_children[linked_dt].extend(not_visited_child_docs) + + self.to_be_visited_documents = next_level_children + + # Remove root node from visited documents + if self.root_docname in self.visited_documents.get(self.root_doctype, []): + self.visited_documents[self.root_doctype].remove(self.root_docname) + + return self.visited_documents + + def get_next_level_children(self, parent_dt, parent_names): + """Get immediate children of a Node(parent_dt, parent_names) + """ + referencing_fields = self.get_doctype_references(parent_dt) + + child_docs = defaultdict(list) + for field in referencing_fields: + links = get_referencing_documents(parent_dt, parent_names.copy(), field, get_parent_if_child_table_doc=True, + parent_filters=[('docstatus', '=', 1)], allowed_parents=self.get_link_sources()) or {} + for dt, names in links.items(): + child_docs[dt].extend(names) + return child_docs + + def get_doctype_references(self, doctype): + """Get references for a given document. + """ + if self._references_across_doctypes is None: + get_links_to = self.get_document_sources() + limit_link_doctypes = self.get_link_sources() + self._references_across_doctypes = get_references_across_doctypes( + get_links_to, limit_link_doctypes) + return self._references_across_doctypes.get(doctype, []) + + def get_document_sources(self): + """Returns list of doctypes from where we access submittable documents. + """ + return list(set(self.get_link_sources() + [self.root_doctype])) + + def get_link_sources(self): + """limit doctype links to these doctypes. + """ + return list(set(self.get_submittable_doctypes()) - set(get_exempted_doctypes() or [])) + + def get_submittable_doctypes(self) -> List[str]: + """Returns list of submittable doctypes. + """ + if not self._submittable_doctypes: + self._submittable_doctypes = frappe.db.get_list('DocType', {'is_submittable': 1}, pluck='name') + return self._submittable_doctypes + + +def get_child_tables_of_doctypes(doctypes: List[str]=None): + """Returns child tables by doctype. + """ + filters=[['fieldtype','=', 'Table']] + filters_for_docfield = filters + filters_for_customfield = filters + + if doctypes: + filters_for_docfield = filters + [['parent', 'in', tuple(doctypes)]] + filters_for_customfield = filters + [['dt', 'in', tuple(doctypes)]] + + links = frappe.get_all("DocField", + fields=["parent", "fieldname", "options as child_table"], + filters=filters_for_docfield, + as_list=1) + + links+= frappe.get_all("Custom Field", + fields=["dt as parent", "fieldname", "options as child_table"], + filters=filters_for_customfield, + as_list=1) + + child_tables_by_doctype = defaultdict(list) + for doctype, fieldname, child_table in links: + child_tables_by_doctype[doctype].append( + {'doctype': doctype, 'fieldname': fieldname, 'child_table': child_table}) + return child_tables_by_doctype + + +def get_references_across_doctypes(to_doctypes: List[str]=None, limit_link_doctypes: List[str]=None) -> List: + """Find doctype wise foreign key references. + + :param to_doctypes: Get links of these doctypes. + :param limit_link_doctypes: limit links to these doctypes. + + * Include child table, link and dynamic link references. + """ + if limit_link_doctypes: + child_tables_by_doctype = get_child_tables_of_doctypes(limit_link_doctypes) + all_child_tables = [each['child_table'] for each in itertools.chain(*child_tables_by_doctype.values())] + limit_link_doctypes = limit_link_doctypes + all_child_tables + else: + child_tables_by_doctype = get_child_tables_of_doctypes() + all_child_tables = [each['child_table'] for each in itertools.chain(*child_tables_by_doctype.values())] + + references_by_link_fields = get_references_across_doctypes_by_link_field(to_doctypes, limit_link_doctypes) + references_by_dlink_fields = get_references_across_doctypes_by_dynamic_link_field(to_doctypes, limit_link_doctypes) + + references = references_by_link_fields.copy() + for k, v in references_by_dlink_fields.items(): + references.setdefault(k, []).extend(v) + + for doctype, links in references.items(): + for link in links: + link['is_child'] = (link['doctype'] in all_child_tables) + return references + + +def get_references_across_doctypes_by_link_field(to_doctypes: List[str]=None, limit_link_doctypes: List[str]=None): + """Find doctype wise foreign key references based on link fields. + + :param to_doctypes: Get links to these doctypes. + :param limit_link_doctypes: limit links to these doctypes. + """ + filters=[['fieldtype','=', 'Link']] + + if to_doctypes: + filters += [['options', 'in', tuple(to_doctypes)]] + + filters_for_docfield = filters[:] + filters_for_customfield = filters[:] + + if limit_link_doctypes: + filters_for_docfield += [['parent', 'in', tuple(limit_link_doctypes)]] + filters_for_customfield += [['dt', 'in', tuple(limit_link_doctypes)]] + + links = frappe.get_all("DocField", + fields=["parent", "fieldname", "options as linked_to"], + filters=filters_for_docfield, + as_list=1) + + links+= frappe.get_all("Custom Field", + fields=["dt as parent", "fieldname", "options as linked_to"], + filters=filters_for_customfield, + as_list=1) + + links_by_doctype = defaultdict(list) + for doctype, fieldname, linked_to in links: + links_by_doctype[linked_to].append({'doctype': doctype, 'fieldname': fieldname}) + return links_by_doctype + + +def get_references_across_doctypes_by_dynamic_link_field(to_doctypes: List[str]=None, limit_link_doctypes: List[str]=None): + """Find doctype wise foreign key references based on dynamic link fields. + + :param to_doctypes: Get links to these doctypes. + :param limit_link_doctypes: limit links to these doctypes. + """ + + filters=[['fieldtype','=', 'Dynamic Link']] + + filters_for_docfield = filters[:] + filters_for_customfield = filters[:] + + if limit_link_doctypes: + filters_for_docfield += [['parent', 'in', tuple(limit_link_doctypes)]] + filters_for_customfield += [['dt', 'in', tuple(limit_link_doctypes)]] + + # find dynamic links of parents + links = frappe.get_all("DocField", + fields=["parent as doctype", "fieldname", "options as doctype_fieldname"], + filters=filters_for_docfield, + as_list=1) + + links += frappe.get_all("Custom Field", + fields=["dt as doctype", "fieldname", "options as doctype_fieldname"], + filters=filters_for_customfield, + as_list=1) + + links_by_doctype = defaultdict(list) + for doctype, fieldname, doctype_fieldname in links: + try: + filters = [[doctype_fieldname, 'in', to_doctypes]] if to_doctypes else [] + for linked_to in frappe.db.get_all(doctype, pluck=doctype_fieldname, filters = filters, distinct=1): + if linked_to: + links_by_doctype[linked_to].append({'doctype': doctype, 'fieldname': fieldname, 'doctype_fieldname': doctype_fieldname}) + except frappe.db.ProgrammingError: + # TODO: FIXME + continue + return links_by_doctype + +def get_referencing_documents(reference_doctype: str, reference_names: List[str], + link_info: dict, get_parent_if_child_table_doc: bool=True, + parent_filters: List[list]=None, child_filters=None, allowed_parents=None): + """Get linked documents based on link_info. + + :param reference_doctype: reference doctype to find links + :param reference_names: reference document names to find links for + :param link_info: linking details to get the linked documents + Ex: {'doctype': 'Purchase Invoice Advance', 'fieldname': 'reference_name', + 'doctype_fieldname': 'reference_type', 'is_child': True} + :param get_parent_if_child_table_doc: Get parent record incase linked document is a child table record. + :param parent_filters: filters to apply on if not a child table. + :param child_filters: apply filters if it is a child table. + :param allowed_parents: list of parents allowed in case of get_parent_if_child_table_doc + is enabled. + """ + from_table = link_info['doctype'] + filters = [[link_info['fieldname'], 'in', tuple(reference_names)]] + if link_info.get('doctype_fieldname'): + filters.append([link_info['doctype_fieldname'], '=', reference_doctype]) + + if not link_info.get('is_child'): + filters.extend(parent_filters or []) + return {from_table: frappe.db.get_all(from_table, filters, pluck='name')} + + + filters.extend(child_filters or []) + res = frappe.db.get_all(from_table, filters = filters, fields = ['name', 'parenttype', 'parent']) + documents = defaultdict(list) + + for parent, rows in itertools.groupby(res, key = lambda row: row['parenttype']): + if allowed_parents and parent not in allowed_parents: + continue + filters = (parent_filters or []) + [['name', 'in', tuple([row.parent for row in rows])]] + documents[parent].extend(frappe.db.get_all(parent, filters=filters, pluck='name') or []) + return documents + @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 +314,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 +327,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. @@ -107,9 +338,8 @@ def validate_linked_doc(docinfo, ignore_doctypes_on_cancel_all=[]): Returns: bool: True if linked document passes all validations, else False """ - #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 @@ -130,7 +360,6 @@ def validate_linked_doc(docinfo, ignore_doctypes_on_cancel_all=[]): def get_exempted_doctypes(): """ Get list of doctypes exempted from being auto-cancelled """ - auto_cancel_exempt_doctypes = [] for doctypes in frappe.get_hooks('auto_cancel_exempted_doctypes'): auto_cancel_exempt_doctypes.append(doctypes) @@ -181,11 +410,11 @@ def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None): try: if link.get("filters"): - ret = frappe.get_list(doctype=dt, fields=fields, filters=link.get("filters")) + ret = frappe.get_all(doctype=dt, fields=fields, filters=link.get("filters")) elif link.get("get_parent"): if me and me.parent and me.parenttype == dt: - ret = frappe.get_list(doctype=dt, fields=fields, + ret = frappe.get_all(doctype=dt, fields=fields, filters=[[dt, "name", '=', me.parent]]) else: ret = None @@ -197,7 +426,7 @@ def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None): if link.get("doctype_fieldname"): filters.append([link.get('child_doctype'), link.get("doctype_fieldname"), "=", doctype]) - ret = frappe.get_list(doctype=dt, fields=fields, filters=filters, or_filters=or_filters, distinct=True) + ret = frappe.get_all(doctype=dt, fields=fields, filters=filters, or_filters=or_filters, distinct=True) else: link_fieldnames = link.get("fieldname") @@ -208,7 +437,7 @@ def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None): # dynamic link if link.get("doctype_fieldname"): filters.append([dt, link.get("doctype_fieldname"), "=", doctype]) - ret = frappe.get_list(doctype=dt, fields=fields, filters=filters, or_filters=or_filters) + ret = frappe.get_all(doctype=dt, fields=fields, filters=filters, or_filters=or_filters) else: ret = None diff --git a/frappe/desk/form/load.py b/frappe/desk/form/load.py index 994a50f938..949bbb59db 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): set_link_titles(doc) 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.js b/frappe/desk/page/setup_wizard/setup_wizard.js index f44a57e339..7e90bc01ad 100644 --- a/frappe/desk/page/setup_wizard/setup_wizard.js +++ b/frappe/desk/page/setup_wizard/setup_wizard.js @@ -197,6 +197,8 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides { callback: (r) => { if (r.message.status === 'ok') { this.post_setup_success(); + } else if (r.message.status === 'registered') { + this.update_setup_message(__("starting the setup...")); } else if (r.message.fail !== undefined) { this.abort_setup(r.message.fail); } @@ -238,6 +240,9 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides { if (data.fail_msg) { this.abort_setup(data.fail_msg); } + if (data.status === 'ok') { + this.post_setup_success(); + } }) } diff --git a/frappe/desk/page/setup_wizard/setup_wizard.py b/frappe/desk/page/setup_wizard/setup_wizard.py index 5edb44e182..83a5e16009 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 @@ -54,9 +54,17 @@ def setup_complete(args): return {'status': 'ok'} args = parse_args(args) - stages = get_setup_stages(args) + is_background_task = frappe.conf.get('trigger_site_setup_in_background') + if is_background_task: + process_setup_stages.enqueue(stages=stages, user_input=args, is_background_task=True) + return {'status': 'registered'} + else: + return process_setup_stages(stages, args) + +@frappe.task() +def process_setup_stages(stages, user_input, is_background_task=False): try: frappe.flags.in_setup_wizard = True current_task = None @@ -68,11 +76,16 @@ def setup_complete(args): current_task = task task.get('fn')(task.get('args')) except Exception: - handle_setup_exception(args) - return {'status': 'fail', 'fail': current_task.get('fail_msg')} + handle_setup_exception(user_input) + if not is_background_task: + return {'status': 'fail', 'fail': current_task.get('fail_msg')} + frappe.publish_realtime('setup_task', + {'status': 'fail', "fail_msg": current_task.get('fail_msg')}, user=frappe.session.user) else: - run_setup_success(args) - return {'status': 'ok'} + run_setup_success(user_input) + if not is_background_task: + return {'status': 'ok'} + frappe.publish_realtime('setup_task', {"status": 'ok'}, user=frappe.session.user) finally: frappe.flags.in_setup_wizard = False diff --git a/frappe/desk/page/user_profile/user_profile_controller.js b/frappe/desk/page/user_profile/user_profile_controller.js index c1a89f316e..40b542d5c3 100644 --- a/frappe/desk/page/user_profile/user_profile_controller.js +++ b/frappe/desk/page/user_profile/user_profile_controller.js @@ -17,21 +17,15 @@ class UserProfile { show() { let route = frappe.get_route(); this.user_id = route[1] || frappe.session.user; - - //validate if user - if (route.length > 1) { - frappe.dom.freeze(__('Loading user profile') + '...'); - frappe.db.exists('User', this.user_id).then(exists => { - frappe.dom.unfreeze(); - if (exists) { - this.make_user_profile(); - } else { - frappe.msgprint(__('User does not exist')); - } - }); - } else { - frappe.set_route('user-profile', frappe.session.user); - } + frappe.dom.freeze(__('Loading user profile') + '...'); + frappe.db.exists('User', this.user_id).then(exists => { + frappe.dom.unfreeze(); + if (exists) { + this.make_user_profile(); + } else { + frappe.msgprint(__('User does not exist')); + } + }); } make_user_profile() { @@ -74,8 +68,7 @@ class UserProfile { primary_action_label: __('Go'), primary_action: ({ user }) => { dialog.hide(); - this.user_id = user; - this.make_user_profile(); + frappe.set_route('user-profile', user); } }); dialog.show(); diff --git a/frappe/desk/page/user_profile/user_profile_sidebar.html b/frappe/desk/page/user_profile/user_profile_sidebar.html index 4a35c6cf9c..9f8889fd03 100644 --- a/frappe/desk/page/user_profile/user_profile_sidebar.html +++ b/frappe/desk/page/user_profile/user_profile_sidebar.html @@ -51,10 +51,10 @@

{%=__("Edit Profile") %}

- {%=__("Leaderboard") %}

- \ No newline at end of file + 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 1dbc52eb5b..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) @@ -478,12 +488,11 @@ def get_stats(stats, doctype, filters=[]): 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 6d259e3a3d..23b5c47fd1 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 @@ -299,6 +299,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..54f0d2372d 100644 --- a/frappe/email/doctype/email_account/email_account.js +++ b/frappe/email/doctype/email_account/email_account.js @@ -109,6 +109,15 @@ frappe.ui.form.on("Email Account", { onload: function(frm) { frm.set_df_property("append_to", "only_select", true); frm.set_query("append_to", "frappe.email.doctype.email_account.email_account.get_append_to"); + frm.set_query("append_to", "imap_folder", function() { + return { + query: "frappe.email.doctype.email_account.email_account.get_append_to" + }; + }); + if (frm.doc.__islocal) { + frm.add_child("imap_folder", {"folder_name": "INBOX"}); + frm.refresh_field("imap_folder"); + } }, refresh: function(frm) { @@ -117,7 +126,7 @@ frappe.ui.form.on("Email Account", { frm.events.notify_if_unreplied(frm); frm.events.show_gmail_message_for_less_secure_apps(frm); - if(frappe.route_flags.delete_user_from_locals && frappe.route_flags.linked_user) { + if (frappe.route_flags.delete_user_from_locals && frappe.route_flags.linked_user) { delete frappe.route_flags.delete_user_from_locals; delete locals['User'][frappe.route_flags.linked_user]; } @@ -125,7 +134,7 @@ frappe.ui.form.on("Email Account", { show_gmail_message_for_less_secure_apps: function(frm) { frm.dashboard.clear_headline(); - if(frm.doc.service==="GMail") { + if (frm.doc.service==="GMail") { frm.dashboard.set_headline_alert('Gmail will only work if you allow access for less secure \ apps in Gmail settings. Read this for details'); @@ -137,8 +146,8 @@ frappe.ui.form.on("Email Account", { frm.events.update_domain(frm); }, - update_domain: function(frm){ - if (!frm.doc.email_id && !frm.doc.service){ + update_domain: function(frm) { + if (!frm.doc.email_id && !frm.doc.service) { return; } @@ -148,28 +157,16 @@ frappe.ui.form.on("Email Account", { args: { "email_id": frm.doc.email_id }, - callback: function (r) { + 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); - }); - } - ); } } }); }, set_domain_fields: function(frm, args) { - if(!args){ + if (!args) { args = frappe.route_flags.set_domain_values? frappe.route_options: {}; } @@ -184,10 +181,8 @@ frappe.ui.form.on("Email Account", { email_sync_option: function(frm) { // confirm if the ALL sync option is selected - if(frm.doc.email_sync_option == "ALL"){ - var msg = __("You are selecting Sync Option as ALL, It will resync all \ - read as well as unread message from server. This may also cause the duplication\ - of Communication (emails)."); + if (frm.doc.email_sync_option == "ALL") { + var msg = __("You are selecting Sync Option as ALL, It will resync all read as well as unread message from server. This may also cause the duplication of Communication (emails)."); frappe.confirm(msg, null, function() { frm.set_value("email_sync_option", "UNSEEN"); }); @@ -196,8 +191,7 @@ frappe.ui.form.on("Email Account", { warn_autoreply_on_incoming: function(frm) { if (frm.doc.enable_incoming && frm.doc.enable_auto_reply && frm.doc.__islocal) { - var msg = __("Enabling auto reply on an incoming email account will send automated replies \ - to all the synchronized emails. Do you wish to continue?"); + var msg = __("Enabling auto reply on an incoming email account will send automated replies to all the synchronized emails. Do you wish to continue?"); frappe.confirm(msg, null, function() { frm.set_value("enable_auto_reply", 0); frappe.show_alert({message: __("Disabled Auto Reply"), indicator: "blue"}); diff --git a/frappe/email/doctype/email_account/email_account.json b/frappe/email/doctype/email_account/email_account.json index 6d811b801f..65053bab3d 100644 --- a/frappe/email/doctype/email_account/email_account.json +++ b/frappe/email/doctype/email_account/email_account.json @@ -7,30 +7,36 @@ "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_25", + "imap_folder", "section_break_12", + "append_emails_to_sent_folder", + "append_to", + "create_contact", "enable_automatic_linking", "section_break_13", "notify_if_unreplied", @@ -42,6 +48,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 +87,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 +129,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 +137,7 @@ "hide_seconds": 1, "in_list_view": 1, "in_standard_filter": 1, - "label": "Domain", + "label": "Domain (optional)", "options": "Email Domain" }, { @@ -145,18 +146,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, @@ -205,7 +206,7 @@ "label": "Attachment Limit (MB)" }, { - "depends_on": "enable_incoming", + "depends_on": "eval: doc.enable_incoming && !doc.use_imap", "description": "Append as communication against this DocType (must have fields, \"Status\", \"Subject\")", "fieldname": "append_to", "fieldtype": "Link", @@ -227,7 +228,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 +238,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 +250,7 @@ { "depends_on": "enable_incoming", "fieldname": "section_break_13", - "fieldtype": "Section Break", + "fieldtype": "Column Break", "hide_days": 1, "hide_seconds": 1 }, @@ -282,7 +284,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 +339,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 +380,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 +405,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 +431,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 +487,8 @@ "fieldname": "section_break_12", "fieldtype": "Section Break", "hide_days": 1, - "hide_seconds": 1 + "hide_seconds": 1, + "label": "Document Linking" }, { "default": "0", @@ -527,15 +538,54 @@ "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" + }, + { + "depends_on": "eval: doc.use_imap && doc.enable_incoming", + "fieldname": "imap_folder", + "fieldtype": "Table", + "label": "IMAP Folder", + "options": "IMAP Folder" + }, + { + "fieldname": "section_break_25", + "fieldtype": "Section Break", + "label": "IMAP Details" } ], "icon": "fa fa-inbox", "index_web_pages_for_search": 1, "links": [], - "modified": "2021-01-21 10:05:24.820597", + "modified": "2021-11-30 09:03:25.728637", "modified_by": "Administrator", "module": "Email", "name": "Email Account", + "naming_rule": "By fieldname", "owner": "Administrator", "permissions": [ { @@ -554,4 +604,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..ef1d49302f 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 @@ -67,6 +67,10 @@ class EmailAccount(Document): else: self.login_id = None + # validate the imap settings + if self.enable_incoming and self.use_imap and len(self.imap_folder) <= 0: + frappe.throw(_("You need to set one IMAP folder for {0}").format(frappe.bold(self.email_id))) + duplicate_email_account = frappe.get_all("Email Account", filters={ "email_id": self.email_id, "name": ("!=", self.name) @@ -100,10 +104,11 @@ class EmailAccount(Document): for e in self.get_unreplied_notification_emails(): validate_email_address(e, True) - if self.enable_incoming and self.append_to: - valid_doctypes = [d[0] for d in get_append_to()] - if self.append_to not in valid_doctypes: - frappe.throw(_("Append To can be one of {0}").format(comma_or(valid_doctypes))) + for folder in self.imap_folder: + if self.enable_incoming and folder.append_to: + valid_doctypes = [d[0] for d in get_append_to()] + if folder.append_to not in valid_doctypes: + frappe.throw(_("Append To can be one of {0}").format(comma_or(valid_doctypes))) def validate_smtp_conn(self): if not self.smtp_server: @@ -137,8 +142,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, @@ -179,13 +182,13 @@ class EmailAccount(Document): return None args = frappe._dict({ + "email_account_name": self.email_account_name, "email_account": self.name, "host": self.email_server, "use_ssl": self.use_ssl, "username": getattr(self, "login_id", None) or self.email_id, "use_imap": self.use_imap, "email_sync_rule": email_sync_rule, - "uid_validity": self.uidvalidity, "incoming_port": get_port(self), "initial_sync_count": self.initial_sync_count or 100 }) @@ -459,6 +462,14 @@ class EmailAccount(Document): """retrive and return inbound mails. """ + mails = [] + + def process_mail(messages): + for index, message in enumerate(messages.get("latest_messages", [])): + uid = messages['uid_list'][index] if messages.get('uid_list') else None + seen_status = 1 if messages.get('seen_status', {}).get(uid) == 'SEEN' else 0 + mails.append(InboundMail(message, self, uid, seen_status)) + if frappe.local.flags.in_test: return [InboundMail(msg, self) for msg in test_mails or []] @@ -468,17 +479,23 @@ class EmailAccount(Document): email_sync_rule = self.build_email_sync_rule() try: email_server = self.get_incoming_server(in_receive=True, email_sync_rule=email_sync_rule) - messages = email_server.get_messages() or {} + if self.use_imap: + # process all given imap folder + for folder in self.imap_folder: + email_server.select_imap_folder(folder.folder_name) + email_server.settings['uid_validity'] = folder.uidvalidity + messages = email_server.get_messages(folder=folder.folder_name) or {} + process_mail(messages) + else: + # process the pop3 account + messages = email_server.get_messages() or {} + process_mail(messages) + # close connection to mailserver + email_server.logout() except Exception: frappe.log_error(title=_("Error while connecting to email account {0}").format(self.name)) return [] - mails = [] - for index, message in enumerate(messages.get("latest_messages", [])): - uid = messages['uid_list'][index] if messages.get('uid_list') else None - seen_status = 1 if messages.get('seen_status', {}).get(uid)=='SEEN' else 0 - mails.append(InboundMail(message, self, uid, seen_status)) - return mails def handle_bad_emails(self, uid, raw, reason): @@ -532,9 +549,11 @@ 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 + Communication = frappe.qb.DocType("Communication") + frappe.qb.update(Communication) \ + .set(Communication.email_account, "") \ + .where(Communication.email_account == self.name).run() - frappe.db.sql("update `tabCommunication` set email_account='' where email_account=%s", self.name) remove_user_email_inbox(email_account=self.name) def after_rename(self, old, new, merge=False): @@ -551,23 +570,26 @@ class EmailAccount(Document): else: return self.email_sync_option or "UNSEEN" - def mark_emails_as_read_unread(self): + def mark_emails_as_read_unread(self, email_server=None, folder_name="INBOX"): """ mark Email Flag Queue of self.email_account mails as read""" - if not self.use_imap: return - flags = frappe.db.sql("""select name, communication, uid, action from - `tabEmail Flag Queue` where is_completed=0 and email_account={email_account} - """.format(email_account=frappe.db.escape(self.name)), as_dict=True) + EmailFlagQ = frappe.qb.DocType("Email Flag Queue") + flags = ( + frappe.qb.from_(EmailFlagQ) + .select(EmailFlagQ.name, EmailFlagQ.communication, EmailFlagQ.uid, EmailFlagQ.action) + .where(EmailFlagQ.is_completed == 0) + .where(EmailFlagQ.email_account == frappe.db.escape(self.name)) + ).run(as_dict=True) uid_list = { flag.get("uid", None): flag.get("action", "Read") for flag in flags } if flags and uid_list: - email_server = self.get_incoming_server() + if not email_server: + email_server = self.get_incoming_server() if not email_server: return - - email_server.update_flag(uid_list=uid_list) + email_server.update_flag(folder_name, uid_list=uid_list) # mark communication as read docnames = ",".join("'%s'"%flag.get("communication") for flag in flags \ @@ -580,16 +602,20 @@ class EmailAccount(Document): self.set_communication_seen_status(docnames, seen=0) docnames = ",".join([ "'%s'"%flag.get("name") for flag in flags ]) - frappe.db.sql(""" update `tabEmail Flag Queue` set is_completed=1 - where name in ({docnames})""".format(docnames=docnames)) + + EmailFlagQueue = frappe.qb.DocType("Email Flag Queue") + frappe.qb.update(EmailFlagQueue) \ + .set(EmailFlagQueue.is_completed, 1) \ + .where(EmailFlagQueue.name.isin(docnames)).run() def set_communication_seen_status(self, docnames, seen=0): """ mark Email Flag Queue of self.email_account mails as read""" if not docnames: return - - frappe.db.sql(""" update `tabCommunication` set seen={seen} - where name in ({docnames})""".format(docnames=docnames, seen=seen)) + Communication = frappe.qb.from_("Communication") + frappe.qb.update(Communication) \ + .set(Communication.seen == seen) \ + .where(Communication.name.isin(docnames)).run() def check_automatic_linking_email_account(self): if self.enable_automatic_linking: @@ -655,15 +681,19 @@ def test_internet(host="8.8.8.8", port=53, timeout=3): def notify_unreplied(): """Sends email notifications if there are unreplied Communications and `notify_if_unreplied` is set as true.""" - for email_account in frappe.get_all("Email Account", "name", filters={"enable_incoming": 1, "notify_if_unreplied": 1}): email_account = frappe.get_doc("Email Account", email_account.name) - if email_account.append_to: + if email_account.use_imap: + append_to = [folder.get("append_to") for folder in email_account.imap_folder] + else: + append_to = email_account.append_to + + if append_to: # get open communications younger than x mins, for given doctype for comm in frappe.get_all("Communication", "name", filters=[ {"sent_or_received": "Received"}, - {"reference_doctype": email_account.append_to}, + {"reference_doctype": ("in", append_to)}, {"unread_notification_sent": 0}, {"email_account":email_account.name}, {"creation": ("<", datetime.now() - timedelta(seconds = (email_account.unreplied_for_mins or 30) * 60))}, @@ -706,9 +736,6 @@ def pull_from_email_account(email_account): email_account = frappe.get_doc("Email Account", email_account) email_account.receive() - # mark Email Flag Queue mail as read - email_account.mark_emails_as_read_unread() - def get_max_email_uid(email_account): # get maximum uid of emails max_uid = 1 @@ -724,3 +751,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: + UserEmail = frappe.qb.from_("User Email") + frappe.qb.update(UserEmail) \ + .set(UserEmail.awaiting_password == awaiting_password or 0) \ + .set(UserEmail.enable_outgoing == enable_outgoing) \ + .where(UserEmail.email_account == email_account).run() + + 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 diff --git a/frappe/email/doctype/email_account/test_email_account.py b/frappe/email/doctype/email_account/test_email_account.py index 35cacac45a..6d26f9f070 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 @@ -25,6 +25,7 @@ class TestEmailAccount(unittest.TestCase): email_account = frappe.get_doc("Email Account", "_Test Email Account 1") email_account.db_set("enable_incoming", 1) email_account.db_set("enable_auto_reply", 1) + email_account.db_set("use_imap", 1) @classmethod def tearDownClass(cls): @@ -34,8 +35,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 +61,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 +184,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() @@ -229,6 +230,22 @@ class TestEmailAccount(unittest.TestCase): email_account.handle_bad_emails(uid=-1, raw=mail_content, reason="Testing") self.assertTrue(frappe.db.get_value("Unhandled Email", {'message_id': message_id})) + def test_imap_folder(self): + # assert tests if imap_folder >= 1 and imap is checked + email_account = frappe.get_doc("Email Account", "_Test Email Account 1") + + self.assertTrue(email_account.use_imap) + self.assertTrue(email_account.enable_incoming) + self.assertTrue(len(email_account.imap_folder) > 0) + + def test_imap_folder_missing(self): + # Test the Exception in validate() that verifies the imap_folder list + email_account = frappe.get_doc("Email Account", "_Test Email Account 1") + email_account.imap_folder = [] + + with self.assertRaises(Exception): + email_account.validate() + class TestInboundMail(unittest.TestCase): @classmethod def setUpClass(cls): @@ -242,8 +259,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_account/test_records.json b/frappe/email/doctype/email_account/test_records.json index 15ca2a886e..450895d7a6 100644 --- a/frappe/email/doctype/email_account/test_records.json +++ b/frappe/email/doctype/email_account/test_records.json @@ -4,7 +4,6 @@ "is_global": 1, "doctype": "Email Account", "domain":"example.com", - "append_to": "ToDo", "email_account_name": "_Test Email Account 1", "enable_outgoing": 1, "smtp_server": "test.example.com", @@ -20,6 +19,8 @@ "send_notification_to": "test_unreplied@example.com", "pop3_server": "pop.test.example.com", "no_remaining":"0", + "append_to": "ToDo", + "imap_folder": [{"folder_name": "INBOX", "append_to": "ToDo"}], "track_email_status": 1 }, { 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.json b/frappe/email/doctype/email_flag_queue/email_flag_queue.json index 165e8f9ea9..14b1ec4f53 100644 --- a/frappe/email/doctype/email_flag_queue/email_flag_queue.json +++ b/frappe/email/doctype/email_flag_queue/email_flag_queue.json @@ -1,213 +1,67 @@ { - "allow_copy": 1, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, - "beta": 0, - "creation": "2016-04-20 15:29:39.785172", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "", - "editable_grid": 0, + "actions": [], + "allow_copy": 1, + "creation": "2016-04-20 15:29:39.785172", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "is_completed", + "communication", + "action", + "email_account", + "uid" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "is_completed", - "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Is Completed", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 1, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "default": "0", + "fieldname": "is_completed", + "fieldtype": "Check", + "label": "Is Completed", + "read_only": 1 + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "communication", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Communication", - "length": 0, - "no_copy": 0, - "options": "", - "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 - }, + "fieldname": "communication", + "fieldtype": "Data", + "label": "Communication" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "action", - "fieldtype": "Select", - "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": "Action", - "length": 0, - "no_copy": 0, - "options": "Read\nUnread", - "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 - }, + "fieldname": "action", + "fieldtype": "Select", + "label": "Action", + "options": "Read\nUnread" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "email_account", - "fieldtype": "Data", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Email Account", - "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 - }, + "fieldname": "email_account", + "fieldtype": "Data", + "hidden": 1, + "label": "Email Account" + }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "uid", - "fieldtype": "Data", - "hidden": 1, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "UID", - "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 + "fieldname": "uid", + "fieldtype": "Data", + "hidden": 1, + "label": "UID" } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 1, - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2017-09-20 15:27:12.142079", - "modified_by": "Administrator", - "module": "Email", - "name": "Email Flag Queue", - "name_case": "", - "owner": "Administrator", + ], + "in_create": 1, + "links": [], + "modified": "2021-11-30 09:51:34.489932", + "modified_by": "Administrator", + "module": "Email", + "name": "Email Flag Queue", + "owner": "Administrator", "permissions": [ { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 0, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, - "write": 0 + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1 } - ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 0, - "track_seen": 0 + ], + "sort_field": "modified", + "sort_order": "DESC" } \ No newline at end of file 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.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..d89a3d83be 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 @@ -18,7 +18,7 @@ from frappe import _, safe_encode, task from frappe.model.document import Document from frappe.email.queue import get_unsubcribed_url, get_unsubscribe_message from frappe.email.email_body import add_attachment, get_formatted_html, get_email -from frappe.utils import cint, split_emails, add_days, nowdate, cstr +from frappe.utils import cint, split_emails, add_days, nowdate, cstr, get_hook_method from frappe.email.doctype.email_account.email_account import EmailAccount @@ -121,9 +121,13 @@ class EmailQueue(Document): continue message = ctx.build_message(recipient.recipient) - if not frappe.flags.in_test: - ctx.smtp_session.sendmail(from_addr=self.sender, to_addrs=recipient.recipient, msg=message) - ctx.add_to_sent_list(recipient) + method = get_hook_method('override_email_send') + if method: + method(self, self.sender, recipient.recipient, message) + else: + if not frappe.flags.in_test: + ctx.smtp_session.sendmail(from_addr=self.sender, to_addrs=recipient.recipient, msg=message) + ctx.add_to_sent_list(recipient) if frappe.flags.in_test: frappe.flags.sent_mail = message @@ -283,9 +287,14 @@ class SendMailContext: if attachment.get('fcontent'): continue - fid = attachment.get("fid") - if fid: - _file = frappe.get_doc("File", fid) + file_filters = {} + if attachment.get('fid'): + file_filters['name'] = attachment.get('fid') + elif attachment.get('file_url'): + file_filters['file_url'] = attachment.get('file_url') + + if file_filters: + _file = frappe.get_doc("File", file_filters) fcontent = _file.get_content() attachment.update({ 'fname': _file.file_name, @@ -293,6 +302,7 @@ class SendMailContext: 'parent': message_obj }) attachment.pop("fid", None) + attachment.pop("file_url", None) add_attachment(**attachment) elif attachment.get("print_format_attachment") == 1: @@ -503,7 +513,7 @@ class QueueBuilder: if self._attachments: # store attachments with fid or print format details, to be attached on-demand later for att in self._attachments: - if att.get('fid'): + if att.get('fid') or att.get('file_url'): attachments.append(att) elif att.get("print_format_attachment") == 1: if not att.get('lang', None): 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/chat/doctype/chat_room_user/__init__.py b/frappe/email/doctype/imap_folder/__init__.py similarity index 100% rename from frappe/chat/doctype/chat_room_user/__init__.py rename to frappe/email/doctype/imap_folder/__init__.py diff --git a/frappe/email/doctype/imap_folder/imap_folder.json b/frappe/email/doctype/imap_folder/imap_folder.json new file mode 100644 index 0000000000..bab50dea39 --- /dev/null +++ b/frappe/email/doctype/imap_folder/imap_folder.json @@ -0,0 +1,53 @@ +{ + "actions": [], + "creation": "2021-09-21 11:38:13.521979", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "folder_name", + "append_to", + "uidvalidity", + "uidnext" + ], + "fields": [ + { + "fieldname": "folder_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Folder Name", + "reqd": 1 + }, + { + "fieldname": "append_to", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Append To", + "options": "DocType" + }, + { + "fieldname": "uidvalidity", + "fieldtype": "Data", + "hidden": 1, + "label": "UIDVALIDITY" + }, + { + "fieldname": "uidnext", + "fieldtype": "Data", + "hidden": 1, + "label": "UIDNEXT" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-09-21 11:53:00.811236", + "modified_by": "Administrator", + "module": "Email", + "name": "IMAP Folder", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} diff --git a/frappe/email/doctype/imap_folder/imap_folder.py b/frappe/email/doctype/imap_folder/imap_folder.py new file mode 100644 index 0000000000..b0bb36b677 --- /dev/null +++ b/frappe/email/doctype/imap_folder/imap_folder.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + +class IMAPFolder(Document): + pass 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.js b/frappe/email/doctype/newsletter/newsletter.js index 3277d8e9ee..55805ad485 100644 --- a/frappe/email/doctype/newsletter/newsletter.js +++ b/frappe/email/doctype/newsletter/newsletter.js @@ -4,69 +4,137 @@ frappe.ui.form.on('Newsletter', { refresh(frm) { let doc = frm.doc; - if (!doc.__islocal && !cint(doc.email_sent) && !doc.__unsaved - && in_list(frappe.boot.user.can_write, doc.doctype)) { - frm.add_custom_button(__('Send Now'), function() { - frappe.confirm(__("Do you really want to send this email newsletter?"), function() { - frm.call('send_emails').then(() => { - frm.refresh(); - }); + let can_write = in_list(frappe.boot.user.can_write, doc.doctype); + if (!frm.is_new() && !frm.is_dirty() && !doc.email_sent && can_write) { + frm.add_custom_button(__('Send a test email'), () => { + frm.events.send_test_email(frm); + }, __('Preview')); + + frm.add_custom_button(__('Check broken links'), () => { + frm.dashboard.set_headline(__('Checking broken links...')); + frm.call('find_broken_links').then(r => { + frm.dashboard.set_headline(''); + let links = r.message; + if (links && links.length) { + let html = '
    ' + links.map(link => `
  • ${link}
  • `).join('') + '
'; + frm.dashboard.set_headline(__("Following links are broken in the email content: {0}", [html])); + } else { + frm.dashboard.set_headline(__("No broken links found in the email content")); + setTimeout(() => { + frm.dashboard.set_headline(''); + }, 3000); + } }); - }, "fa fa-play", "btn-success"); + }, __('Preview')); + + frm.add_custom_button(__('Send now'), () => { + if (frm.doc.schedule_send) { + frappe.confirm(__("This newsletter was scheduled to send on a later date. Are you sure you want to send it now?"), function () { + frm.call('send_emails').then(() => frm.refresh()); + }); + return; + } + frappe.confirm(__("Are you sure you want to send this newsletter now?"), function () { + frm.call('send_emails').then(() => frm.refresh()); + }); + }, __('Send')); + + frm.add_custom_button(__('Schedule sending'), () => { + frm.events.schedule_send_dialog(frm); + }, __('Send')); } frm.events.setup_dashboard(frm); + frm.events.setup_sending_status(frm); - if (doc.__islocal && !doc.send_from) { + if (frm.is_new() && !doc.sender_email) { let { fullname, email } = frappe.user_info(doc.owner); - frm.set_value('send_from', `${fullname} <${email}>`); + frm.set_value('sender_email', email); + frm.set_value('sender_name', fullname); } + + frm.trigger('update_schedule_message'); }, - onload_post_render(frm) { - frm.trigger('setup_schedule_send'); - }, - - setup_schedule_send(frm) { - let today = new Date(); - - // setting datepicker options to set min date & min time - today.setHours(today.getHours() + 1 ); - frm.get_field('schedule_send').$input.datepicker({ - maxMinutes: 0, - minDate: today, - timeFormat: 'hh:00:00', - onSelect: function (fd, d, picker) { - if (!d) return; - var date = d.toDateString(); - if (date === today.toDateString()) { - picker.update({ - minHours: (today.getHours() + 1) - }); - } else { - picker.update({ - minHours: 0 - }); - } - frm.get_field('schedule_send').$input.trigger('change'); + schedule_send_dialog(frm) { + let hours = frappe.utils.range(24); + let time_slots = hours.map(hour => { + return `${(hour + '').padStart(2, '0')}:00`; + }); + let d = new frappe.ui.Dialog({ + title: __('Schedule Newsletter'), + fields: [ + { + label: __('Date'), + fieldname: 'date', + fieldtype: 'Date', + options: { + minDate: new Date() + } + }, + { + label: __('Time'), + fieldname: 'time', + fieldtype: 'Select', + options: time_slots, + }, + ], + primary_action_label: __('Schedule'), + primary_action({ date, time }) { + frm.set_value('schedule_sending', 1); + frm.set_value('schedule_send', `${date} ${time}:00`); + d.hide(); + frm.save(); + }, + secondary_action_label: __('Cancel Scheduling'), + secondary_action() { + frm.set_value('schedule_sending', 0); + frm.set_value('schedule_send', ''); + d.hide(); + frm.save(); } }); + if (frm.doc.schedule_sending) { + let parts = frm.doc.schedule_send.split(' '); + if (parts.length === 2) { + let [date, time] = parts; + d.set_value('date', date); + d.set_value('time', time.slice(0, 5)); + } + } + d.show(); + }, - - const $tp = frm.get_field('schedule_send').datepicker.timepicker; - $tp.$minutes.parent().css('display', 'none'); - $tp.$minutesText.css('display', 'none'); - $tp.$minutesText.prev().css('display', 'none'); - $tp.$seconds.parent().css('display', 'none'); + send_test_email(frm) { + let d = new frappe.ui.Dialog({ + title: __('Send Test Email'), + fields: [ + { + label: __('Email'), + fieldname: 'email', + fieldtype: 'Data', + options: 'Email', + } + ], + primary_action_label: __('Send'), + primary_action({ email }) { + d.get_primary_btn().text(__('Sending...')).prop('disabled', true); + frm.call('send_test_email', { email }) + .then(() => { + d.get_primary_btn().text(__('Send again')).prop('disabled', false); + }); + } + }); + d.show(); }, setup_dashboard(frm) { - if(!frm.doc.__islocal && cint(frm.doc.email_sent) + if (!frm.doc.__islocal && cint(frm.doc.email_sent) && frm.doc.__onload && frm.doc.__onload.status_count) { var stat = frm.doc.__onload.status_count; var total = frm.doc.scheduled_to_send; - if(total) { - $.each(stat, function(k, v) { + if (total) { + $.each(stat, function (k, v) { stat[k] = flt(v * 100 / total, 2) + '%'; }); @@ -94,5 +162,58 @@ frappe.ui.form.on('Newsletter', { ]); } } + }, + + setup_sending_status(frm) { + frm.call('get_sending_status').then(r => { + if (r.message) { + frm.events.update_sending_progress(frm, r.message.sent, r.message.total); + } + if (r.message.sent >= r.message.total) { + return; + } + if (frm.sending_status) return; + + frm.sending_status = setInterval(() => { + if (frm.doc.email_sent && frm.$wrapper.is(':visible')) { + frm.call('get_sending_status').then(r => { + if (r.message) { + let { sent, total } = r.message; + frm.events.update_sending_progress(frm, sent, total); + + if (sent >= total) { + clearInterval(frm.sending_status); + frm.sending_status = null; + return; + } + } + }); + } + }, 5000); + }); + }, + + update_sending_progress(frm, sent, total) { + if (sent >= total) { + frm.dashboard.hide_progress(); + return; + } + frm.dashboard.show_progress(__('Sending emails'), sent * 100 / total, __("{0} of {1} sent", [sent, total])); + }, + + on_hide(frm) { + if (frm.sending_status) { + clearInterval(frm.sending_status); + frm.sending_status = null; + } + }, + + update_schedule_message(frm) { + if (!frm.doc.email_sent && frm.doc.schedule_send) { + let datetime = frappe.datetime.global_date_format(frm.doc.schedule_send); + frm.dashboard.set_headline_alert(__('This newsletter is scheduled to be sent on {0}', [datetime.bold()])); + } else { + frm.dashboard.clear_headline(); + } } }); diff --git a/frappe/email/doctype/newsletter/newsletter.json b/frappe/email/doctype/newsletter/newsletter.json index dcd19ed33c..baabd4991e 100644 --- a/frappe/email/doctype/newsletter/newsletter.json +++ b/frappe/email/doctype/newsletter/newsletter.json @@ -7,48 +7,59 @@ "document_type": "Other", "engine": "InnoDB", "field_order": [ + "status_section", + "email_sent_at", + "column_break_3", + "total_recipients", + "column_break_12", + "email_sent", + "from_section", + "sender_name", + "column_break_5", + "sender_email", + "column_break_7", "send_from", - "schedule_sending", - "schedule_send", "recipients", "email_group", - "email_sent", - "newsletter_content", + "subject_section", "subject", + "newsletter_content", "content_type", "message", "message_md", "message_html", - "section_break_13", + "attachments", "send_unsubscribe_link", - "send_attachments", - "column_break_9", - "published", "send_webview_link", - "route", - "test_the_newsletter", - "test_email_id", - "test_send", - "scheduled_to_send" + "schedule_settings_section", + "scheduled_to_send", + "schedule_sending", + "schedule_send", + "publish_as_a_web_page_section", + "published", + "route" ], "fields": [ { "fieldname": "email_group", "fieldtype": "Table", "in_standard_filter": 1, - "label": "Email Group", - "options": "Newsletter Email Group" + "label": "Audience", + "options": "Newsletter Email Group", + "reqd": 1 }, { "fieldname": "send_from", "fieldtype": "Data", "ignore_xss_filter": 1, - "label": "Sender" + "label": "Sender", + "read_only": 1 }, { "default": "0", "fieldname": "email_sent", "fieldtype": "Check", + "hidden": 1, "label": "Email Sent", "no_copy": 1, "read_only": 1 @@ -87,32 +98,12 @@ "label": "Published" }, { + "depends_on": "published", "fieldname": "route", "fieldtype": "Data", - "hidden": 1, "label": "Route", "read_only": 1 }, - { - "collapsible": 1, - "fieldname": "test_the_newsletter", - "fieldtype": "Section Break", - "label": "Testing" - }, - { - "description": "A Lead with this Email Address should exist", - "fieldname": "test_email_id", - "fieldtype": "Data", - "label": "Test Email Address", - "options": "Email" - }, - { - "depends_on": "eval: doc.test_email_id", - "fieldname": "test_send", - "fieldtype": "Button", - "label": "Test", - "options": "test_send" - }, { "fieldname": "scheduled_to_send", "fieldtype": "Int", @@ -122,21 +113,16 @@ { "fieldname": "recipients", "fieldtype": "Section Break", - "label": "Recipients" + "label": "To" }, { "depends_on": "eval: doc.schedule_sending", "fieldname": "schedule_send", "fieldtype": "Datetime", - "label": "Schedule Send", + "label": "Send Email At", + "read_only": 1, "read_only_depends_on": "eval: doc.email_sent" }, - { - "default": "0", - "fieldname": "send_attachments", - "fieldtype": "Check", - "label": "Send Attachments" - }, { "fieldname": "content_type", "fieldtype": "Select", @@ -161,23 +147,87 @@ "default": "0", "fieldname": "schedule_sending", "fieldtype": "Check", - "label": "Schedule Sending", + "label": "Schedule sending at a later time", "read_only_depends_on": "eval: doc.email_sent" }, - { - "fieldname": "column_break_9", - "fieldtype": "Column Break" - }, { "default": "0", - "depends_on": "published", "fieldname": "send_webview_link", "fieldtype": "Check", "label": "Send Web View Link" }, { - "fieldname": "section_break_13", - "fieldtype": "Section Break" + "fieldname": "from_section", + "fieldtype": "Section Break", + "label": "From" + }, + { + "fieldname": "sender_name", + "fieldtype": "Data", + "label": "Sender Name" + }, + { + "fieldname": "sender_email", + "fieldtype": "Data", + "label": "Sender Email", + "options": "Email", + "reqd": 1 + }, + { + "fieldname": "column_break_5", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_7", + "fieldtype": "Column Break" + }, + { + "fieldname": "subject_section", + "fieldtype": "Section Break", + "label": "Subject" + }, + { + "fieldname": "publish_as_a_web_page_section", + "fieldtype": "Section Break", + "label": "Publish as a web page" + }, + { + "depends_on": "schedule_sending", + "fieldname": "schedule_settings_section", + "fieldtype": "Section Break", + "label": "Scheduled Sending" + }, + { + "fieldname": "attachments", + "fieldtype": "Table", + "label": "Attachments", + "options": "Newsletter Attachment" + }, + { + "fieldname": "email_sent_at", + "fieldtype": "Datetime", + "label": "Email Sent At", + "read_only": 1 + }, + { + "fieldname": "total_recipients", + "fieldtype": "Int", + "label": "Total Recipients", + "read_only": 1 + }, + { + "depends_on": "email_sent", + "fieldname": "status_section", + "fieldtype": "Section Break", + "label": "Status" + }, + { + "fieldname": "column_break_12", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" } ], "has_web_view": 1, @@ -187,7 +237,7 @@ "is_published_field": "published", "links": [], "max_attachments": 3, - "modified": "2021-02-22 14:33:56.095380", + "modified": "2021-12-06 20:09:37.963141", "modified_by": "Administrator", "module": "Email", "name": "Newsletter", diff --git a/frappe/email/doctype/newsletter/newsletter.py b/frappe/email/doctype/newsletter/newsletter.py old mode 100755 new mode 100644 index 97d77549b7..aa6fa2c40a --- a/frappe/email/doctype/newsletter/newsletter.py +++ b/frappe/email/doctype/newsletter/newsletter.py @@ -1,279 +1,355 @@ -# 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 - 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() + self.validate_publishing() + + @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) - frappe.msgprint(_("Test email sent to {0}").format(self.test_email_id)) + def get_sending_status(self): + count_by_status = frappe.get_all("Email Queue", + filters={"reference_doctype": self.doctype, "reference_name": self.name}, + fields=["status", "count(name) as count"], + group_by="status", + order_by="status" + ) + sent = 0 + total = 0 + for row in count_by_status: + if row.status == "Sent": + sent = row.count + total += row.count + + return {'sent': sent, 'total': total} + + @frappe.whitelist() + def send_test_email(self, email): + test_emails = frappe.utils.validate_email_address(email, throw=True) + self.send_newsletter(emails=test_emails) + frappe.msgprint(_("Test email sent to {0}").format(email), alert=True) + + @frappe.whitelist() + def find_broken_links(self): + from bs4 import BeautifulSoup + import requests + + html = self.get_message() + soup = BeautifulSoup(html, "html.parser") + links = soup.find_all("a") + images = soup.find_all("img") + broken_links = [] + for el in links + images: + url = el.attrs.get("href") or el.attrs.get("src") + try: + response = requests.head(url, verify=False, timeout=5) + if response.status_code >= 400: + broken_links.append(url) + except: + broken_links.append(url) + return broken_links @frappe.whitelist() def send_emails(self): - """send emails to leads and customers""" - 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)) + """queue sending emails to recipients""" + self.schedule_sending = False + self.schedule_send = None + self.queue_all() + frappe.msgprint(_("Email queued to {0} recipients").format(self.total_recipients)) def validate_send(self): + """Validate if Newsletter can be sent. + """ + self.validate_newsletter_status() + self.validate_newsletter_recipients() + + def validate_newsletter_status(self): + if self.email_sent: + frappe.throw(_("Newsletter has already been sent"), exc=NewsletterAlreadySentError) + if self.get("__islocal"): - throw(_("Please save the Newsletter before sending")) + frappe.throw(_("Please save the Newsletter before sending"), exc=NewsletterNotSavedError) - if not self.recipients: - frappe.throw(_("Newsletter should have at least one recipient")) + 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 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.local.flags.redirect_location = frappe.local.response.location - raise frappe.Redirect - else: - context.attachments = get_attachments(self.name) - context.no_cache = 1 - context.show_sidebar = True + def validate_sender_address(self): + """Validate self.send_from is a valid email address or not. + """ + if self.sender_email: + frappe.utils.validate_email_address(self.sender_email, throw=True) + self.send_from = f"{self.sender_name} <{self.sender_email}>" if self.sender_name else self.sender_email + 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_attachments(name): - return frappe.get_all("File", + def validate_publishing(self): + if self.send_webview_link and not self.published: + frappe.throw(_("Newsletter must be published to send webview link in email")) + + 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): + """Queue Newsletter to all the recipients generated from the `Email Group` table + """ + self.validate() + self.validate_send() + + recipients = self.get_pending_recipients() + self.send_newsletter(emails=recipients) + + self.email_sent = True + self.email_sent_at = frappe.utils.now() + self.total_recipients = len(recipients) + self.save() + + def get_newsletter_attachments(self) -> List[Dict[str, str]]: + """Get list of attachments on current Newsletter + """ + return [{"file_url": row.attachment} for row in self.attachments] + + def send_newsletter(self, emails: List[str]): + """Trigger email generation for `emails` and add it in Email Queue. + """ + attachments = self.get_newsletter_attachments() + sender = self.send_from or frappe.utils.get_formatted_email(self.owner) + args = self.as_dict() + args["message"] = self.get_message() + + 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, + 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: + message = self.message + if self.content_type == "Markdown": + message = frappe.utils.md_to_html(self.message_md) + if self.content_type == "HTML": + message = self.message_html + + return frappe.render_template(message, {"doc": self.as_dict()}) + + 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": 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"}) + filters={ + "attached_to_name": self.name, + "attached_to_doctype": "Newsletter", + "is_private": 0, + }, + ) @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): context.update({ - "show_sidebar": True, "show_search": True, - 'no_breadcrumbs': True, - "title": _("Newsletter"), - "get_list": get_newsletter_list, + "no_breadcrumbs": True, + "title": _("Newsletters"), + "filters": {"published": 1}, "row_template": "email/doctype/newsletter/templates/newsletter_row.html", }) -def get_newsletter_list(doctype, txt, filters, limit_start, limit_page_length=20, order_by="modified"): - email_group_list = frappe.db.sql('''SELECT eg.name - FROM `tabEmail Group` eg, `tabEmail Group Member` egm - WHERE egm.unsubscribed=0 - AND eg.name=egm.email_group - AND egm.email = %s''', frappe.session.user) - email_group_list = [d[0] for d in email_group_list] - - if email_group_list: - return frappe.db.sql('''SELECT n.name, n.subject, n.message, n.modified - FROM `tabNewsletter` n, `tabNewsletter Email Group` neg - WHERE n.name = neg.parent - AND n.email_sent=1 - AND n.published=1 - AND neg.email_group in ({0}) - ORDER BY n.modified DESC LIMIT {1} OFFSET {2} - '''.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/templates/newsletter.html b/frappe/email/doctype/newsletter/templates/newsletter.html index 733c7df6af..1244f4c49a 100644 --- a/frappe/email/doctype/newsletter/templates/newsletter.html +++ b/frappe/email/doctype/newsletter/templates/newsletter.html @@ -1,6 +1,6 @@ {% extends "templates/web.html" %} -{% block title %} {{ _("Newsletter") }} {% endblock %} +{% block title %} {{ doc.subject }} {% endblock %} {% block page_content %} diff --git a/frappe/public/js/frappe/file_uploader/FileUploader.vue b/frappe/public/js/frappe/file_uploader/FileUploader.vue index 06f9275711..167b4955fa 100644 --- a/frappe/public/js/frappe/file_uploader/FileUploader.vue +++ b/frappe/public/js/frappe/file_uploader/FileUploader.vue @@ -46,7 +46,7 @@
{{ __('Library') }}
- `).click(() => { - this.expanded = !this.expanded; - this.refresh_height(); - this.toggle_label(); - }).appendTo(this.$input_wrapper); + // styling this.ace_editor_target.addClass('border rounded'); this.ace_editor_target.css('height', 300); + if (this.df.max_height) { + this.ace_editor_target.css('max-height', this.df.max_height); + } + // initialize const ace = window.ace; this.editor = ace.edit(this.ace_editor_target.get(0)); + + if (this.df.max_lines || this.df.min_lines || this.df.max_height) { + if (this.df.max_lines) + this.editor.setOption("maxLines", this.df.max_lines); + if (this.df.min_lines) + this.editor.setOption("minLines", this.df.min_lines); + } else { + this.expanded = false; + this.$expand_button = $(``).click(() => { + this.expanded = !this.expanded; + this.refresh_height(); + this.toggle_label(); + }).appendTo(this.$input_wrapper); + } + this.editor.setTheme('ace/theme/tomorrow'); this.editor.setOption("showPrintMargin", false); + this.editor.setOption("wrap", this.df.wrap); this.set_language(); // events diff --git a/frappe/public/js/frappe/form/controls/comment.js b/frappe/public/js/frappe/form/controls/comment.js index 7c10b61366..b9b2d6a987 100644 --- a/frappe/public/js/frappe/form/controls/comment.js +++ b/frappe/public/js/frappe/form/controls/comment.js @@ -104,8 +104,10 @@ frappe.ui.form.ControlComment = class ControlComment extends frappe.ui.form.Cont return [ ['bold', 'italic', 'underline'], ['blockquote', 'code-block'], + [{ 'direction': "rtl" }], ['link', 'image'], [{ 'list': 'ordered' }, { 'list': 'bullet' }], + [{ 'align': [] }], ['clean'] ]; } diff --git a/frappe/public/js/frappe/form/controls/control.js b/frappe/public/js/frappe/form/controls/control.js index 168da2717c..bd04938e35 100644 --- a/frappe/public/js/frappe/form/controls/control.js +++ b/frappe/public/js/frappe/form/controls/control.js @@ -39,6 +39,7 @@ import './multiselect_pills'; import './multiselect_list'; import './rating'; import './duration'; +import './icon'; frappe.ui.form.make_control = function (opts) { var control_class_name = "Control" + opts.df.fieldtype.replace(/ /g, ""); diff --git a/frappe/public/js/frappe/form/controls/data.js b/frappe/public/js/frappe/form/controls/data.js index 977789fc1b..f4c9849528 100644 --- a/frappe/public/js/frappe/form/controls/data.js +++ b/frappe/public/js/frappe/form/controls/data.js @@ -15,11 +15,6 @@ frappe.ui.form.ControlData = class ControlData extends frappe.ui.form.ControlInp .addClass("input-with-feedback form-control") .prependTo(this.input_area); - if (in_list(['Data', 'Link', 'Dynamic Link', 'Password', 'Select', 'Read Only', 'Attach', 'Attach Image'], - this.df.fieldtype)) { - this.$input.attr("maxlength", this.df.length || 140); - } - this.$input.on('paste', (e) => { let pasted_data = frappe.utils.get_clipboard_data(e); let maxlength = this.$input.attr('maxlength'); @@ -67,6 +62,10 @@ frappe.ui.form.ControlData = class ControlData extends frappe.ui.form.ControlInp if (this.df.options == 'URL') { this.setup_url_field(); } + + if (this.df.options == 'Barcode') { + this.setup_barcode_field(); + } } setup_url_field() { @@ -113,6 +112,32 @@ frappe.ui.form.ControlData = class ControlData extends frappe.ui.form.ControlInp }); } + setup_barcode_field() { + this.$wrapper.find('.control-input').append( + ` + + ${frappe.utils.icon('scan', 'sm')} + + ` + ); + + this.$scan_btn = this.$wrapper.find('.link-btn'); + this.$scan_btn.toggle(true); + + const me = this; + this.$scan_btn.on('click', 'a', () => { + new frappe.ui.Scanner({ + dialog: true, + multiple: false, + on_scan(data) { + if (data && data.result && data.result.text) { + me.set_value(data.result.text); + } + } + }); + }); + } + bind_change_event() { const change_handler = e => { if (this.change) this.change(e); @@ -122,7 +147,7 @@ frappe.ui.form.ControlData = class ControlData extends frappe.ui.form.ControlInp } }; this.$input.on("change", change_handler); - if (this.constructor.trigger_change_on_input_event) { + if (this.constructor.trigger_change_on_input_event && !this.in_grid()) { // debounce to avoid repeated validations on value change this.$input.on("input", frappe.utils.debounce(change_handler, 500)); } @@ -158,6 +183,13 @@ frappe.ui.form.ControlData = class ControlData extends frappe.ui.form.ControlInp } } set_input_attributes() { + if (in_list( + ['Data', 'Link', 'Dynamic Link', 'Password', 'Select', 'Read Only'], + this.df.fieldtype + )) { + this.$input.attr("maxlength", this.df.length || 140); + } + this.$input .attr("data-fieldtype", this.df.fieldtype) .attr("data-fieldname", this.df.fieldname) @@ -226,4 +258,7 @@ frappe.ui.form.ControlData = class ControlData extends frappe.ui.form.ControlInp let el = this.$input.parents(el_class)[0]; if (el) $(el).toggleClass(scroll_class, add); } + in_grid() { + return this.grid || this.layout && this.layout.grid; + } }; diff --git a/frappe/public/js/frappe/form/controls/date.js b/frappe/public/js/frappe/form/controls/date.js index 9ad81c7e46..b9945060cd 100644 --- a/frappe/public/js/frappe/form/controls/date.js +++ b/frappe/public/js/frappe/form/controls/date.js @@ -73,7 +73,8 @@ frappe.ui.form.ControlDate = class ControlDate extends frappe.ui.form.ControlDat .text(this.today_text); this.update_datepicker_position(); - } + }, + ...(this.get_df_options()) }; } set_datepicker() { @@ -150,4 +151,19 @@ frappe.ui.form.ControlDate = class ControlDate extends frappe.ui.form.ControlDat } return value; } + get_df_options() { + let options = {}; + let df_options = this.df.options || ''; + if (typeof df_options === 'string') { + try { + options = JSON.parse(df_options); + } catch (error) { + console.warn(`Invalid JSON in options of "${this.df.fieldname}"`); + } + } + else if (typeof df_options === 'object') { + options = df_options; + } + return options; + } }; diff --git a/frappe/public/js/frappe/form/controls/datepicker_i18n.js b/frappe/public/js/frappe/form/controls/datepicker_i18n.js new file mode 100644 index 0000000000..f010325c2e --- /dev/null +++ b/frappe/public/js/frappe/form/controls/datepicker_i18n.js @@ -0,0 +1,62 @@ +import "air-datepicker/dist/js/i18n/datepicker.cs.js"; +import "air-datepicker/dist/js/i18n/datepicker.da.js"; +import "air-datepicker/dist/js/i18n/datepicker.de.js"; +import "air-datepicker/dist/js/i18n/datepicker.en.js"; +import "air-datepicker/dist/js/i18n/datepicker.es.js"; +import "air-datepicker/dist/js/i18n/datepicker.fi.js"; +import "air-datepicker/dist/js/i18n/datepicker.fr.js"; +import "air-datepicker/dist/js/i18n/datepicker.hu.js"; +import "air-datepicker/dist/js/i18n/datepicker.nl.js"; +import "air-datepicker/dist/js/i18n/datepicker.pl.js"; +import "air-datepicker/dist/js/i18n/datepicker.pt-BR.js"; +import "air-datepicker/dist/js/i18n/datepicker.pt.js"; +import "air-datepicker/dist/js/i18n/datepicker.ro.js"; +import "air-datepicker/dist/js/i18n/datepicker.sk.js"; +import "air-datepicker/dist/js/i18n/datepicker.zh.js"; + +(function ($) { + $.fn.datepicker.language['ar'] = { + days: ['الأحد', 'الأثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعه', 'السبت'], + daysShort: ['الأحد', 'الأثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعه', 'السبت'], + daysMin: ['الأحد', 'الأثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعه', 'السبت'], + months: ['يناير', 'فبراير', 'مارس', 'أبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'اكتوبر', 'نوفمبر', 'ديسمبر'], + monthsShort: ['يناير', 'فبراير', 'مارس', 'أبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'اكتوبر', 'نوفمبر', 'ديسمبر'], + today: 'اليوم', + clear: 'Clear', + dateFormat: 'dd/mm/yyyy', + timeFormat: 'hh:ii aa', + firstDay: 0 + }; +})(jQuery); + +(function ($) { + $.fn.datepicker.language['gr'] = { + days: ['Κυριακή', 'Δευτέρα', 'Τρίτη', 'Τετάρτη', 'Πέμπτη', 'Παρασκευή', 'Σάββατο'], + daysShort: ['Κυρ', 'Δευ', 'Τρι', 'Τετ', 'Πεμ', 'Παρ', 'Σαβ'], + daysMin: ['Κυ', 'Δε', 'Τρ', 'Τε', 'Πε', 'Πα', 'Σα'], + months: ['Ιανουάριος', 'Φεβρουάριος', 'Μάρτιος', 'Απρίλιος', 'Μάιος', 'Ιούνιος', 'Ιούλιος', 'Αύγουστος', 'Σεπτέμβριος', 'Οκτώβριος', 'Νοέμβριος', 'Δεκέμβριος'], + monthsShort: ['Ιαν', 'Φεβ', 'Μαρ', 'Απρ', 'Μάι', 'Ι/ν', 'Ι/λ', 'Αυγ', 'Σεπ', 'Οκτ', 'Νοε', 'Δεκ'], + today: 'Σήμερα', + clear: 'Καθαρισμός', + dateFormat: 'dd/mm/yyyy', + timeFormat: 'hh:ii aa', + firstDay: 0 + }; +})(jQuery); + + +(function ($) { + $.fn.datepicker.language['it'] = { + days: ['Domenica', 'Lunedì', 'Martedì', 'Mercoledì', 'Giovedì', 'Venerdì', 'Sabato'], + daysShort: ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'], + daysMin: ['Do', 'Lu', 'Ma', 'Me', 'Gi', 'Ve', 'Sa'], + months: ['Gennaio', 'Febbraio', 'Marzo', 'Aprile', 'Maggio', 'Giugno', 'Luglio', 'Agosto', + 'Settembre', 'Ottobre', 'Novembre', 'Dicembre'], + monthsShort: ['Gen', 'Feb', 'Mar', 'Apr', 'Mag', 'Giu', 'Lug', 'Ago', 'Set', 'Ott', 'Nov', 'Dic'], + today: 'Oggi', + clear: 'Reset', + dateFormat: 'dd/mm/yyyy', + timeFormat: 'hh:ii', + firstDay: 1 + }; +})(jQuery); diff --git a/frappe/public/js/frappe/form/controls/datetime.js b/frappe/public/js/frappe/form/controls/datetime.js index 341a933066..f7a2798a99 100644 --- a/frappe/public/js/frappe/form/controls/datetime.js +++ b/frappe/public/js/frappe/form/controls/datetime.js @@ -36,4 +36,9 @@ frappe.ui.form.ControlDatetime = class ControlDatetime extends frappe.ui.form.Co $tp.$secondsText.prev().css('display', 'none'); } } + + get_model_value() { + let value = super.get_model_value(); + return frappe.datetime.get_datetime_as_string(value); + } }; diff --git a/frappe/public/js/frappe/form/controls/float.js b/frappe/public/js/frappe/form/controls/float.js index 89f8f23cc5..e00f74238c 100644 --- a/frappe/public/js/frappe/form/controls/float.js +++ b/frappe/public/js/frappe/form/controls/float.js @@ -1,4 +1,17 @@ frappe.ui.form.ControlFloat = class ControlFloat extends frappe.ui.form.ControlInt { + + make_input() { + super.make_input(); + const change_handler = e => { + if (this.change) this.change(e); + else { + let value = this.get_input_value(); + this.parse_validate_and_set_in_model(value, e); + } + }; + // convert to number format on focusout since focus converts it to flt. + this.$input.on("focusout", change_handler); + } parse(value) { value = this.eval_expression(value); return isNaN(parseFloat(value)) ? null : flt(value, this.get_precision()); diff --git a/frappe/public/js/frappe/form/controls/geolocation.js b/frappe/public/js/frappe/form/controls/geolocation.js index 080a1cbb48..280eac3941 100644 --- a/frappe/public/js/frappe/form/controls/geolocation.js +++ b/frappe/public/js/frappe/form/controls/geolocation.js @@ -3,6 +3,11 @@ frappe.provide('frappe.utils.utils'); frappe.ui.form.ControlGeolocation = class ControlGeolocation extends frappe.ui.form.ControlData { static horizontal = false + async make() { + await frappe.require(this.required_libs); + super.make(); + } + make_wrapper() { // Create the elements for map area super.make_wrapper(); @@ -196,4 +201,17 @@ frappe.ui.form.ControlGeolocation = class ControlGeolocation extends frappe.ui.f this.editableLayers.removeLayer(l); }); } + + get required_libs() { + return [ + "assets/frappe/js/lib/leaflet/easy-button.css", + "assets/frappe/js/lib/leaflet/L.Control.Locate.css", + "assets/frappe/js/lib/leaflet/leaflet.draw.css", + "assets/frappe/js/lib/leaflet/leaflet.css", + "assets/frappe/js/lib/leaflet/leaflet.js", + "assets/frappe/js/lib/leaflet/easy-button.js", + "assets/frappe/js/lib/leaflet/leaflet.draw.js", + "assets/frappe/js/lib/leaflet/L.Control.Locate.js", + ]; + } }; diff --git a/frappe/public/js/frappe/form/controls/icon.js b/frappe/public/js/frappe/form/controls/icon.js new file mode 100644 index 0000000000..7ab2e11f24 --- /dev/null +++ b/frappe/public/js/frappe/form/controls/icon.js @@ -0,0 +1,93 @@ +import Picker from '../../icon_picker/icon_picker'; + +frappe.ui.form.ControlIcon = class ControlIcon extends frappe.ui.form.ControlData { + make_input() { + this.df.placeholder = this.df.placeholder || __('Choose an icon'); + super.make_input(); + this.get_all_icons(); + this.make_icon_input(); + } + + get_all_icons() { + frappe.symbols = []; + $("#frappe-symbols > symbol[id]").each(function() { + frappe.symbols.push(this.id.replace('icon-', '')); + }); + } + + make_icon_input() { + let picker_wrapper = $('
'); + this.picker = new Picker({ + parent: picker_wrapper, + icon: this.get_icon(), + icons: frappe.symbols + }); + + this.$wrapper.popover({ + trigger: 'manual', + offset: `${-this.$wrapper.width() / 4.5}, 5`, + boundary: 'viewport', + placement: 'bottom', + template: ` +
+
+
+
+ `, + content: () => picker_wrapper, + html: true + }).on('show.bs.popover', () => { + setTimeout(() => { + this.picker.refresh(); + }, 10); + }).on('hidden.bs.popover', () => { + $('body').off('click.icon-popover'); + $(window).off('hashchange.icon-popover'); + }); + + this.picker.on_change = (icon) => { + this.set_value(icon); + }; + + if (!this.selected_icon) { + this.selected_icon = $(`
${frappe.utils.icon("folder-normal", "md")}
`); + this.selected_icon.insertAfter(this.$input); + } + + this.$wrapper.find('.selected-icon').parent().on('click', (e) => { + this.$wrapper.popover('toggle'); + if (!this.get_icon()) { + this.$input.val(''); + } + e.stopPropagation(); + $('body').on('click.icon-popover', (ev) => { + if (!$(ev.target).parents().is('.popover')) { + this.$wrapper.popover('hide'); + } + }); + $(window).on('hashchange.icon-popover', () => { + this.$wrapper.popover('hide'); + }); + }); + } + + refresh() { + super.refresh(); + let icon = this.get_icon(); + if (this.picker && this.picker.icon !== icon) { + this.picker.icon = icon; + this.picker.refresh(); + } + } + + set_formatted_input(value) { + super.set_formatted_input(value); + this.$input.val(value); + this.selected_icon.find("use").attr("href", "#icon-"+(value || "folder-normal")); + this.selected_icon.toggleClass('no-value', !value); + } + + get_icon() { + return this.get_value() || 'folder-normal'; + } +}; diff --git a/frappe/public/js/frappe/form/controls/link.js b/frappe/public/js/frappe/form/controls/link.js index 82481d5303..a3c876fae2 100644 --- a/frappe/public/js/frappe/form/controls/link.js +++ b/frappe/public/js/frappe/form/controls/link.js @@ -525,67 +525,64 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat } validate(value) { // validate the value just entered - if(this.df.options=="[Select]" || this.df.ignore_link_validation) { + if ( + this._validated + || this.df.options=="[Select]" + || this.df.ignore_link_validation + ) { return value; } return this.validate_link_and_fetch(this.df, this.get_options(), this.docname, value); } - validate_link_and_fetch(df, doctype, docname, value) { - let me = this; + validate_link_and_fetch(df, options, docname, value) { + if (!value) { + this.reset_value(); + this.reset_fetch_values(df, docname); + }; - if (value) { - return new Promise((resolve) => { - let fetch = ''; + const fetch_map = this.fetch_map; + const columns_to_fetch = Object.values(fetch_map); - if (this.frm && this.frm.fetch_dict[df.fieldname]) { - fetch = this.frm.fetch_dict[df.fieldname].columns.join(', '); - } - // if default and no fetch, no need to validate - if (!fetch && df.__default_value && df.__default_value===value) { - resolve(value); - } - - this.fetch_and_validate_link(resolve, df, doctype, docname, value, fetch); - }); - } else { - me.reset_value(); - me.reset_fetch_values(df, docname); + // if default and no fetch, no need to validate + if (!columns_to_fetch.length && df.__default_value === value) { + return value; } - } - fetch_and_validate_link(resolve, df, doctype, docname, value, fetch) { - let me = this; - frappe.call({ - method: 'frappe.desk.form.utils.validate_link', - type: "GET", - args: { - 'value': value, - 'options': doctype, - 'fetch': fetch - }, - no_spinner: true, - callback: (r) => { - if (r.message=='Ok') { - if (r.fetch_values && docname) { - this.set_fetch_values(df, docname, r.fetch_values); - } - resolve(r.valid_value); - } else { - me.reset_value(); - me.reset_fetch_values(df, docname); - resolve(""); - } + return frappe.xcall("frappe.client.validate_link", { + doctype: options, + docname: value, + fields: columns_to_fetch, + }).then((response) => { + if (!response || !response.name) return ""; + if (!docname || !columns_to_fetch.length) return response.name; + + for (const [target_field, source_field] of Object.entries(fetch_map)) { + frappe.model.set_value( + df.parent, + docname, + target_field, + response[source_field], + df.fieldtype, + ); } + + return response.name; }); } - set_fetch_values(df, docname, fetch_values) { - let fl = this.frm.fetch_dict[df.fieldname].fields; - for (var i=0; i < fl.length; i++) { - frappe.model.set_value(df.parent, docname, fl[i], fetch_values[i], df.fieldtype); + get fetch_map() { + const fetch_map = {}; + if (!this.frm) return fetch_map; + + for (const key of ["*", this.df.parent]) { + if (this.frm.fetch_dict[key] && this.frm.fetch_dict[key][this.df.fieldname]) { + Object.assign(fetch_map, this.frm.fetch_dict[key][this.df.fieldname]); + } } + + return fetch_map; } reset_fetch_values(df, docname) { let fields = this.frm && this.frm.fetch_dict && this.frm.fetch_dict[df.fieldname] ? this.frm.fetch_dict[df.fieldname].fields : []; diff --git a/frappe/public/js/frappe/form/controls/markdown_editor.js b/frappe/public/js/frappe/form/controls/markdown_editor.js index e768dcee08..d9ba2df261 100644 --- a/frappe/public/js/frappe/form/controls/markdown_editor.js +++ b/frappe/public/js/frappe/form/controls/markdown_editor.js @@ -2,14 +2,16 @@ frappe.ui.form.ControlMarkdownEditor = class ControlMarkdownEditor extends frapp static editor_class = 'markdown' make_ace_editor() { super.make_ace_editor(); + if (this.markdown_container) return; - this.ace_editor_target.wrap(`
`); - this.markdown_container = this.$input_wrapper.find(`.${this.constructor.editor_class}-container`); + let editor_class = this.constructor.editor_class; + this.ace_editor_target.wrap(`
`); + this.markdown_container = this.$input_wrapper.find(`.${editor_class}-container`); this.editor.getSession().setUseWrapMode(true); this.showing_preview = false; - this.preview_toggle_btn = $(``) + this.preview_toggle_btn = $(``) .click(e => { if (!this.showing_preview) { this.update_preview(); @@ -25,7 +27,7 @@ frappe.ui.form.ControlMarkdownEditor = class ControlMarkdownEditor extends frapp }); this.markdown_container.prepend(this.preview_toggle_btn); - this.markdown_preview = $(`
`).hide(); + this.markdown_preview = $(`
`).hide(); this.markdown_container.append(this.markdown_preview); } diff --git a/frappe/public/js/frappe/form/controls/select.js b/frappe/public/js/frappe/form/controls/select.js index 042d86814d..7df2bbfbaa 100644 --- a/frappe/public/js/frappe/form/controls/select.js +++ b/frappe/public/js/frappe/form/controls/select.js @@ -113,6 +113,7 @@ frappe.ui.form.ControlSelect = class ControlSelect extends frappe.ui.form.Contro var is_value_null = is_null(v.value); var is_label_null = is_null(v.label); var is_disabled = Boolean(v.disabled); + var is_selected = Boolean(v.selected); if (is_value_null && is_label_null) { value = v; @@ -126,6 +127,7 @@ frappe.ui.form.ControlSelect = class ControlSelect extends frappe.ui.form.Contro $('
Id + Time + State + Info + Progress +