diff --git a/.github/helper/semgrep_rules/frappe_correctness.py b/.github/helper/semgrep_rules/frappe_correctness.py
new file mode 100644
index 0000000000..37889fbbb1
--- /dev/null
+++ b/.github/helper/semgrep_rules/frappe_correctness.py
@@ -0,0 +1,28 @@
+import frappe
+from frappe import _, flt
+
+from frappe.model.document import Document
+
+
+def on_submit(self):
+ if self.value_of_goods == 0:
+ frappe.throw(_('Value of goods cannot be 0'))
+ # ruleid: frappe-modifying-after-submit
+ self.status = 'Submitted'
+
+def on_submit(self): # noqa
+ if flt(self.per_billed) < 100:
+ self.update_billing_status()
+ else:
+ # todook: frappe-modifying-after-submit
+ self.status = "Completed"
+ self.db_set("status", "Completed")
+
+class TestDoc(Document):
+ pass
+
+ def validate(self):
+ #ruleid: frappe-modifying-child-tables-while-iterating
+ for item in self.child_table:
+ if item.value < 0:
+ self.remove(item)
diff --git a/.github/helper/semgrep_rules/frappe_correctness.yml b/.github/helper/semgrep_rules/frappe_correctness.yml
new file mode 100644
index 0000000000..faab3344a6
--- /dev/null
+++ b/.github/helper/semgrep_rules/frappe_correctness.yml
@@ -0,0 +1,135 @@
+# This file specifies rules for correctness according to how frappe doctype data model works.
+
+rules:
+- id: frappe-modifying-but-not-comitting
+ patterns:
+ - pattern: |
+ def $METHOD(self, ...):
+ ...
+ self.$ATTR = ...
+ - pattern-not: |
+ def $METHOD(self, ...):
+ ...
+ self.$ATTR = ...
+ ...
+ self.db_set(..., self.$ATTR, ...)
+ - pattern-not: |
+ def $METHOD(self, ...):
+ ...
+ self.$ATTR = $SOME_VAR
+ ...
+ self.db_set(..., $SOME_VAR, ...)
+ - pattern-not: |
+ def $METHOD(self, ...):
+ ...
+ self.$ATTR = $SOME_VAR
+ ...
+ self.save()
+ - metavariable-regex:
+ metavariable: '$ATTR'
+ # this is negative look-ahead, add more attrs to ignore like (ignore|ignore_this_too|ignore_me)
+ regex: '^(?!ignore_linked_doctypes|status_updater)(.*)$'
+ - metavariable-regex:
+ metavariable: "$METHOD"
+ regex: "(on_submit|on_cancel)"
+ message: |
+ DocType modified in self.$METHOD. Please check if modification of self.$ATTR is commited to database.
+ languages: [python]
+ severity: ERROR
+
+- id: frappe-modifying-but-not-comitting-other-method
+ patterns:
+ - pattern: |
+ class $DOCTYPE(...):
+ def $METHOD(self, ...):
+ ...
+ self.$ANOTHER_METHOD()
+ ...
+
+ def $ANOTHER_METHOD(self, ...):
+ ...
+ self.$ATTR = ...
+ - pattern-not: |
+ class $DOCTYPE(...):
+ def $METHOD(self, ...):
+ ...
+ self.$ANOTHER_METHOD()
+ ...
+
+ def $ANOTHER_METHOD(self, ...):
+ ...
+ self.$ATTR = ...
+ ...
+ self.db_set(..., self.$ATTR, ...)
+ - pattern-not: |
+ class $DOCTYPE(...):
+ def $METHOD(self, ...):
+ ...
+ self.$ANOTHER_METHOD()
+ ...
+
+ def $ANOTHER_METHOD(self, ...):
+ ...
+ self.$ATTR = $SOME_VAR
+ ...
+ self.db_set(..., $SOME_VAR, ...)
+ - pattern-not: |
+ class $DOCTYPE(...):
+ def $METHOD(self, ...):
+ ...
+ self.$ANOTHER_METHOD()
+ ...
+ self.save()
+ def $ANOTHER_METHOD(self, ...):
+ ...
+ self.$ATTR = ...
+ - metavariable-regex:
+ metavariable: "$METHOD"
+ regex: "(on_submit|on_cancel)"
+ message: |
+ self.$ANOTHER_METHOD is called from self.$METHOD, check if changes to self.$ATTR are commited to database.
+ languages: [python]
+ severity: ERROR
+
+- id: frappe-print-function-in-doctypes
+ pattern: print(...)
+ message: |
+ Did you mean to leave this print statement in? Consider using msgprint or logger instead of print statement.
+ languages: [python]
+ severity: WARNING
+ paths:
+ exclude:
+ - test_*.py
+ include:
+ - "*/**/doctype/*"
+
+- id: frappe-modifying-child-tables-while-iterating
+ pattern-either:
+ - pattern: |
+ for $ROW in self.$TABLE:
+ ...
+ self.remove(...)
+ - pattern: |
+ for $ROW in self.$TABLE:
+ ...
+ self.append(...)
+ message: |
+ Child table being modified while iterating on it.
+ languages: [python]
+ severity: ERROR
+ paths:
+ include:
+ - "*/**/doctype/*"
+
+- id: frappe-same-key-assigned-twice
+ pattern-either:
+ - pattern: |
+ {..., $X: $A, ..., $X: $B, ...}
+ - pattern: |
+ dict(..., ($X, $A), ..., ($X, $B), ...)
+ - pattern: |
+ _dict(..., ($X, $A), ..., ($X, $B), ...)
+ message: |
+ key `$X` is uselessly assigned twice. This could be a potential bug.
+ languages: [python]
+ severity: ERROR
diff --git a/.github/helper/semgrep_rules/security.yml b/.github/helper/semgrep_rules/security.yml
index 1937fc0e52..b2cc4b16fc 100644
--- a/.github/helper/semgrep_rules/security.yml
+++ b/.github/helper/semgrep_rules/security.yml
@@ -12,3 +12,18 @@ rules:
exclude:
- frappe/__init__.py
- frappe/commands/utils.py
+
+- id: frappe-sqli-format-strings
+ patterns:
+ - pattern-inside: |
+ @frappe.whitelist()
+ def $FUNC(...):
+ ...
+ - pattern-either:
+ - pattern: frappe.db.sql("..." % ...)
+ - pattern: frappe.db.sql(f"...", ...)
+ - pattern: frappe.db.sql("...".format(...), ...)
+ message: |
+ Detected use of raw string formatting for SQL queries. This can lead to sql injection vulnerabilities. Refer security guidelines - https://github.com/frappe/erpnext/wiki/Code-Security-Guidelines
+ languages: [python]
+ severity: WARNING
diff --git a/.github/helper/semgrep_rules/translate.yml b/.github/helper/semgrep_rules/translate.yml
index 3737da5a7e..df55089b9f 100644
--- a/.github/helper/semgrep_rules/translate.yml
+++ b/.github/helper/semgrep_rules/translate.yml
@@ -44,7 +44,8 @@ rules:
pattern-either:
- pattern: _(...) + ... + _(...)
- pattern: _("..." + "...")
- - pattern-regex: '_\([^\)]*\\\s*'
+ - pattern-regex: '_\([^\)]*\\\s*' # lines broken by `\`
+ - pattern-regex: '_\(\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
diff --git a/.github/helper/semgrep_rules/ux.py b/.github/helper/semgrep_rules/ux.py
new file mode 100644
index 0000000000..4a74457435
--- /dev/null
+++ b/.github/helper/semgrep_rules/ux.py
@@ -0,0 +1,31 @@
+import frappe
+from frappe import msgprint, throw, _
+
+
+# ruleid: frappe-missing-translate-function
+throw("Error Occured")
+
+# ruleid: frappe-missing-translate-function
+frappe.throw("Error Occured")
+
+# ruleid: frappe-missing-translate-function
+frappe.msgprint("Useful message")
+
+# ruleid: frappe-missing-translate-function
+msgprint("Useful message")
+
+
+# ok: frappe-missing-translate-function
+translatedmessage = _("Hello")
+
+# ok: frappe-missing-translate-function
+throw(translatedmessage)
+
+# ok: frappe-missing-translate-function
+msgprint(translatedmessage)
+
+# ok: frappe-missing-translate-function
+msgprint(_("Helpful message"))
+
+# ok: frappe-missing-translate-function
+frappe.throw(_("Error occured"))
diff --git a/.github/helper/semgrep_rules/ux.yml b/.github/helper/semgrep_rules/ux.yml
new file mode 100644
index 0000000000..ed06a6a80c
--- /dev/null
+++ b/.github/helper/semgrep_rules/ux.yml
@@ -0,0 +1,15 @@
+rules:
+- id: frappe-missing-translate-function
+ pattern-either:
+ - patterns:
+ - pattern: frappe.msgprint("...", ...)
+ - pattern-not: frappe.msgprint(_("..."), ...)
+ - pattern-not: frappe.msgprint(__("..."), ...)
+ - patterns:
+ - pattern: frappe.throw("...", ...)
+ - pattern-not: 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, javascript, json]
+ severity: ERROR
diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
index bfe2002f69..e21a1f7ac6 100644
--- a/.github/workflows/ci-tests.yml
+++ b/.github/workflows/ci-tests.yml
@@ -151,7 +151,8 @@ jobs:
cd ${GITHUB_WORKSPACE}
pip install coveralls==2.2.0
pip install coverage==4.5.4
- coveralls
+ coveralls --service=github
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_TOKEN }}
+ COVERALLS_SERVICE_NAME: github
diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml
index 1d5694f521..5092bf4705 100644
--- a/.github/workflows/semgrep.yml
+++ b/.github/workflows/semgrep.yml
@@ -14,9 +14,19 @@ jobs:
uses: actions/setup-python@v2
with:
python-version: 3.8
- - name: Run semgrep
+
+ - name: Setup semgrep
run: |
python -m pip install -q semgrep
git fetch origin $GITHUB_BASE_REF:$GITHUB_BASE_REF -q
+
+ - name: Semgrep errors
+ run: |
files=$(git diff --name-only --diff-filter=d $GITHUB_BASE_REF)
- [[ -d .github/helper/semgrep_rules ]] && semgrep --config=.github/helper/semgrep_rules --quiet --error $files
+ [[ -d .github/helper/semgrep_rules ]] && semgrep --severity ERROR --config=.github/helper/semgrep_rules --quiet --error $files
+ semgrep --config="r/python.lang.correctness" --quiet --error $files
+
+ - name: Semgrep warnings
+ run: |
+ files=$(git diff --name-only --diff-filter=d $GITHUB_BASE_REF)
+ [[ -d .github/helper/semgrep_rules ]] && semgrep --severity WARNING --severity INFO --config=.github/helper/semgrep_rules --quiet $files
diff --git a/README.md b/README.md
index b00d291b96..e00bea7857 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,8 @@ Full-stack web application framework that uses Python and MariaDB on the server
### Installation
-[Install via Frappe Bench](https://github.com/frappe/bench)
+* [Install via Docker](https://github.com/frappe/frappe_docker)
+* [Install via Frappe Bench](https://github.com/frappe/bench)
## Contributing
diff --git a/cypress/integration/form.js b/cypress/integration/form.js
index 5302ed0964..20ed7a61cd 100644
--- a/cypress/integration/form.js
+++ b/cypress/integration/form.js
@@ -8,7 +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').blur();
+ cy.fill_field('description', 'this is a test todo', 'Text Editor');
cy.wait(300);
cy.get('.page-title').should('contain', 'Not Saved');
cy.intercept({
diff --git a/cypress/integration/relative_time_filters.js b/cypress/integration/relative_time_filters.js
index 80e6387d99..cbb0524c24 100644
--- a/cypress/integration/relative_time_filters.js
+++ b/cypress/integration/relative_time_filters.js
@@ -1,7 +1,4 @@
context('Relative Timeframe', () => {
- beforeEach(() => {
- cy.login();
- });
before(() => {
cy.login();
cy.visit('/app/website');
diff --git a/cypress/integration/table_multiselect.js b/cypress/integration/table_multiselect.js
index faa72d63a5..25cab78ba2 100644
--- a/cypress/integration/table_multiselect.js
+++ b/cypress/integration/table_multiselect.js
@@ -1,5 +1,5 @@
context('Table MultiSelect', () => {
- beforeEach(() => {
+ before(() => {
cy.login();
});
diff --git a/frappe/__init__.py b/frappe/__init__.py
index 8e03c43373..3e9c0bfedf 100644
--- a/frappe/__init__.py
+++ b/frappe/__init__.py
@@ -15,6 +15,7 @@ from __future__ import unicode_literals, print_function
from six import iteritems, binary_type, text_type, string_types, PY2
from werkzeug.local import Local, release_local
import os, sys, importlib, inspect, json
+import typing
from past.builtins import cmp
import click
@@ -134,6 +135,14 @@ message_log = local("message_log")
lang = local("lang")
+# This if block is never executed when running the code. It is only used for
+# telling static code analyzer where to find dynamically defined attributes.
+if typing.TYPE_CHECKING:
+ from frappe.database.mariadb.database import MariaDBDatabase
+ from frappe.database.postgres.database import PostgresDatabase
+ db: typing.Union[MariaDBDatabase, PostgresDatabase]
+# end: static analysis hack
+
def init(site, sites_path=None, new_site=False):
"""Initialize frappe for the current site. Reset thread locals `frappe.local`"""
if getattr(local, "initialised", None):
diff --git a/frappe/api.py b/frappe/api.py
index 6a09b795b0..9039ae0e5f 100644
--- a/frappe/api.py
+++ b/frappe/api.py
@@ -1,12 +1,10 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt
-from __future__ import unicode_literals
import base64
import binascii
import json
-
-from six.moves.urllib.parse import urlencode, urlparse
+from urllib.parse import urlencode, urlparse
import frappe
import frappe.client
@@ -14,6 +12,7 @@ import frappe.handler
from frappe import _
from frappe.utils.response import build_response
+
def handle():
"""
Handler for `/api` methods
@@ -38,9 +37,6 @@ def handle():
`/api/resource/{doctype}/{name}?run_method={method}` will run a whitelisted controller method
"""
-
- validate_auth()
-
parts = frappe.request.path[1:].split("/",3)
call = doctype = name = None
@@ -116,7 +112,7 @@ def handle():
frappe.local.form_dict['fields'] = json.loads(frappe.local.form_dict['fields'])
frappe.local.form_dict.setdefault('limit_page_length', 20)
frappe.local.response.update({
- "data": frappe.call(
+ "data": frappe.call(
frappe.client.get_list,
doctype,
**frappe.local.form_dict
@@ -140,6 +136,7 @@ def handle():
return build_response("json")
+
def get_request_form_data():
if frappe.local.form_dict.data is None:
data = frappe.safe_decode(frappe.local.request.get_data())
@@ -148,25 +145,18 @@ def get_request_form_data():
return frappe.parse_json(data)
+
def validate_auth():
- if frappe.get_request_header("Authorization") is None:
- return
-
- VALID_AUTH_PREFIX_TYPES = ['basic', 'bearer', 'token']
- VALID_AUTH_PREFIX_STRING = ", ".join(VALID_AUTH_PREFIX_TYPES).title()
-
+ """
+ Authenticate and sets user for the request.
+ """
authorization_header = frappe.get_request_header("Authorization", str()).split(" ")
- authorization_type = authorization_header[0].lower()
- if len(authorization_header) == 1:
- frappe.throw(_('Invalid Authorization headers, add a token with a prefix from one of the following: {0}.').format(VALID_AUTH_PREFIX_STRING), frappe.InvalidAuthorizationHeader)
-
- if authorization_type == "bearer":
+ if len(authorization_header) == 2:
validate_oauth(authorization_header)
- elif authorization_type in VALID_AUTH_PREFIX_TYPES:
validate_auth_via_api_keys(authorization_header)
- else:
- frappe.throw(_('Invalid Authorization Type {0}, must be one of {1}.').format(authorization_type, VALID_AUTH_PREFIX_STRING), frappe.InvalidAuthorizationPrefix)
+
+ validate_auth_via_hooks()
def validate_oauth(authorization_header):
@@ -177,8 +167,8 @@ def validate_oauth(authorization_header):
authorization_header (list of str): The 'Authorization' header containing the prefix and token
"""
- from frappe.oauth import get_url_delimiter
from frappe.integrations.oauth2 import get_oauth_server
+ from frappe.oauth import get_url_delimiter
form_dict = frappe.local.form_dict
token = authorization_header[1]
@@ -187,19 +177,20 @@ def validate_oauth(authorization_header):
access_token = {"access_token": token}
uri = parsed_url.scheme + "://" + parsed_url.netloc + parsed_url.path + "?" + urlencode(access_token)
http_method = req.method
- body = req.get_data()
headers = req.headers
+ body = req.get_data()
+ if req.content_type and "multipart/form-data" in req.content_type:
+ body = None
try:
required_scopes = frappe.db.get_value("OAuth Bearer Token", token, "scopes").split(get_url_delimiter())
+ valid, oauthlib_request = get_oauth_server().verify_request(uri, http_method, body, headers, required_scopes)
+ if valid:
+ frappe.set_user(frappe.db.get_value("OAuth Bearer Token", token, "user"))
+ frappe.local.form_dict = form_dict
except AttributeError:
- frappe.throw(_("Invalid Bearer token, please provide a valid access token with prefix 'Bearer'."), frappe.InvalidAuthorizationToken)
+ pass
- valid, oauthlib_request = get_oauth_server().verify_request(uri, http_method, body, headers, required_scopes)
-
- if valid:
- frappe.set_user(frappe.db.get_value("OAuth Bearer Token", token, "user"))
- frappe.local.form_dict = form_dict
def validate_auth_via_api_keys(authorization_header):
@@ -222,8 +213,7 @@ def validate_auth_via_api_keys(authorization_header):
except binascii.Error:
frappe.throw(_("Failed to decode token, please provide a valid base64-encoded token."), frappe.InvalidAuthorizationToken)
except (AttributeError, TypeError, ValueError):
- frappe.throw(_("Invalid token, please provide a valid token with prefix 'Basic' or 'Token'."), frappe.InvalidAuthorizationToken)
-
+ pass
def validate_api_key_secret(api_key, api_secret, frappe_authorization_source=None):
@@ -248,3 +238,8 @@ def validate_api_key_secret(api_key, api_secret, frappe_authorization_source=Non
if frappe.local.login_manager.user in ('', 'Guest'):
frappe.set_user(user)
frappe.local.form_dict = form_dict
+
+
+def validate_auth_via_hooks():
+ for auth_hook in frappe.get_hooks('auth_hooks', []):
+ frappe.get_attr(auth_hook)()
diff --git a/frappe/app.py b/frappe/app.py
index 607479ad52..c9e993a853 100644
--- a/frappe/app.py
+++ b/frappe/app.py
@@ -56,6 +56,7 @@ def application(request):
frappe.recorder.record()
frappe.monitor.start()
frappe.rate_limiter.apply()
+ frappe.api.validate_auth()
if request.method == "OPTIONS":
response = Response()
diff --git a/frappe/boot.py b/frappe/boot.py
index 0dfcb8d1b4..65a07b15e5 100644
--- a/frappe/boot.py
+++ b/frappe/boot.py
@@ -42,6 +42,8 @@ def get_bootinfo():
bootinfo.user_info = get_user_info()
bootinfo.sid = frappe.session['sid']
+ bootinfo.user_groups = frappe.get_all('User Group', pluck="name")
+
bootinfo.modules = {}
bootinfo.module_list = []
load_desktop_data(bootinfo)
diff --git a/frappe/cache_manager.py b/frappe/cache_manager.py
index bad879d2fa..4e0fe0cf44 100644
--- a/frappe/cache_manager.py
+++ b/frappe/cache_manager.py
@@ -18,7 +18,7 @@ global_cache_keys = ("app_hooks", "installed_apps", 'all_apps',
'scheduler_events', 'time_zone', 'webhooks', 'active_domains',
'active_modules', 'assignment_rule', 'server_script_map', 'wkhtmltopdf_version',
'domain_restricted_doctypes', 'domain_restricted_pages', 'information_schema:counts',
- 'sitemap_routes', 'db_tables') + doctype_map_keys
+ 'sitemap_routes', 'db_tables', 'server_script_autocompletion_items') + doctype_map_keys
user_cache_keys = ("bootinfo", "user_recent", "roles", "user_doc", "lang",
"defaults", "user_permissions", "home_page", "linked_with",
diff --git a/frappe/commands/__init__.py b/frappe/commands/__init__.py
index b9ae02e112..61ee62d352 100644
--- a/frappe/commands/__init__.py
+++ b/frappe/commands/__init__.py
@@ -62,11 +62,24 @@ def popen(command, *args, **kwargs):
if env:
env = dict(environ, **env)
+ def set_low_prio():
+ import psutil
+ if psutil.LINUX:
+ psutil.Process().nice(19)
+ psutil.Process().ionice(psutil.IOPRIO_CLASS_IDLE)
+ elif psutil.WINDOWS:
+ psutil.Process().nice(psutil.IDLE_PRIORITY_CLASS)
+ psutil.Process().ionice(psutil.IOPRIO_VERYLOW)
+ else:
+ psutil.Process().nice(19)
+ # ionice not supported
+
proc = subprocess.Popen(command,
stdout=None if output else subprocess.PIPE,
stderr=None if output else subprocess.PIPE,
shell=shell,
cwd=cwd,
+ preexec_fn=set_low_prio,
env=env
)
diff --git a/frappe/commands/scheduler.py b/frappe/commands/scheduler.py
index bd9c9d2cb0..e9638800cd 100755
--- a/frappe/commands/scheduler.py
+++ b/frappe/commands/scheduler.py
@@ -18,22 +18,33 @@ def _is_scheduler_enabled():
return enable_scheduler
-@click.command('trigger-scheduler-event')
-@click.argument('event')
+
+@click.command("trigger-scheduler-event", help="Trigger a scheduler event")
+@click.argument("event")
@pass_context
def trigger_scheduler_event(context, event):
- "Trigger a scheduler event"
import frappe.utils.scheduler
+
+ exit_code = 0
+
for site in context.sites:
try:
frappe.init(site=site)
frappe.connect()
- frappe.utils.scheduler.trigger(site, event, now=True)
+ try:
+ frappe.get_doc("Scheduled Job Type", {"method": event}).execute()
+ except frappe.DoesNotExistError:
+ click.secho(f"Event {event} does not exist!", fg="red")
+ exit_code = 1
finally:
frappe.destroy()
+
if not context.sites:
raise SiteNotSpecifiedError
+ sys.exit(exit_code)
+
+
@click.command('enable-scheduler')
@pass_context
def enable_scheduler(context):
diff --git a/frappe/commands/site.py b/frappe/commands/site.py
index 0fadf2a294..0102d3ac40 100755
--- a/frappe/commands/site.py
+++ b/frappe/commands/site.py
@@ -676,10 +676,8 @@ def start_ngrok(context):
frappe.init(site=site)
port = frappe.conf.http_port or frappe.conf.webserver_port
- public_url = ngrok.connect(port=port, options={
- 'host_header': site
- })
- print(f'Public URL: {public_url}')
+ tunnel = ngrok.connect(addr=str(port), host_header=site)
+ print(f'Public URL: {tunnel.public_url}')
print('Inspect logs at http://localhost:4040')
ngrok_process = ngrok.get_ngrok_process()
diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py
index 5ff66171fc..a203c8c6d9 100644
--- a/frappe/commands/utils.py
+++ b/frappe/commands/utils.py
@@ -11,7 +11,7 @@ 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
+from frappe.utils import get_bench_path, update_progress_bar, cint
@click.command('build')
@@ -567,11 +567,14 @@ def run_ui_tests(context, app, headless=False):
node_bin = subprocess.getoutput("npm bin")
cypress_path = "{0}/cypress".format(node_bin)
- plugin_path = "{0}/cypress-file-upload".format(node_bin)
+ plugin_path = "{0}/../cypress-file-upload".format(node_bin)
# check if cypress in path...if not, install it.
- if not (os.path.exists(cypress_path) or os.path.exists(plugin_path)) \
- or not subprocess.getoutput("npm view cypress version").startswith("6."):
+ if not (
+ os.path.exists(cypress_path)
+ and os.path.exists(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")
diff --git a/frappe/core/doctype/communication/communication_list.js b/frappe/core/doctype/communication/communication_list.js
index 454897b865..315b74a39c 100644
--- a/frappe/core/doctype/communication/communication_list.js
+++ b/frappe/core/doctype/communication/communication_list.js
@@ -20,6 +20,6 @@ frappe.listview_settings['Communication'] = {
},
primary_action: function() {
- new frappe.views.CommunicationComposer({ doc: {} });
+ new frappe.views.CommunicationComposer();
}
};
diff --git a/frappe/core/doctype/data_import/data_import.json b/frappe/core/doctype/data_import/data_import.json
index 8b1b6c4e07..fe6fb90481 100644
--- a/frappe/core/doctype/data_import/data_import.json
+++ b/frappe/core/doctype/data_import/data_import.json
@@ -53,7 +53,8 @@
"fieldname": "import_file",
"fieldtype": "Attach",
"in_list_view": 1,
- "label": "Import File"
+ "label": "Import File",
+ "read_only_depends_on": "eval: ['Success', 'Partial Success'].includes(doc.status)"
},
{
"fieldname": "import_preview",
@@ -156,10 +157,11 @@
"description": "Must be a publicly accessible Google Sheets URL",
"fieldname": "google_sheets_url",
"fieldtype": "Data",
- "label": "Import from Google Sheets"
+ "label": "Import from Google Sheets",
+ "read_only_depends_on": "eval: ['Success', 'Partial Success'].includes(doc.status)"
},
{
- "depends_on": "eval:doc.google_sheets_url && !doc.__unsaved",
+ "depends_on": "eval:doc.google_sheets_url && !doc.__unsaved && ['Success', 'Partial Success'].includes(doc.status)",
"fieldname": "refresh_google_sheet",
"fieldtype": "Button",
"label": "Refresh Google Sheet"
@@ -167,7 +169,7 @@
],
"hide_toolbar": 1,
"links": [],
- "modified": "2020-06-24 14:33:03.173876",
+ "modified": "2021-04-11 01:50:42.074623",
"modified_by": "Administrator",
"module": "Core",
"name": "Data Import",
diff --git a/frappe/core/doctype/doctype/doctype.json b/frappe/core/doctype/doctype/doctype.json
index 276ce7bee7..ceaa1240f2 100644
--- a/frappe/core/doctype/doctype/doctype.json
+++ b/frappe/core/doctype/doctype/doctype.json
@@ -56,6 +56,8 @@
"show_preview_popup",
"show_name_in_global_search",
"email_settings_sb",
+ "default_email_template",
+ "column_break_51",
"email_append_to",
"sender_field",
"subject_field",
@@ -535,6 +537,16 @@
"fieldname": "is_virtual",
"fieldtype": "Check",
"label": "Is Virtual"
+ },
+ {
+ "fieldname": "default_email_template",
+ "fieldtype": "Link",
+ "label": "Default Email Template",
+ "options": "Email Template"
+ },
+ {
+ "fieldname": "column_break_51",
+ "fieldtype": "Column Break"
}
],
"icon": "fa fa-bolt",
@@ -616,7 +628,7 @@
"link_fieldname": "reference_doctype"
}
],
- "modified": "2021-02-17 20:18:06.212232",
+ "modified": "2021-03-22 12:26:41.031135",
"modified_by": "Administrator",
"module": "Core",
"name": "DocType",
@@ -650,4 +662,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
-}
\ No newline at end of file
+}
diff --git a/frappe/core/doctype/prepared_report/prepared_report.py b/frappe/core/doctype/prepared_report/prepared_report.py
index e947cee8ed..c27853f460 100644
--- a/frappe/core/doctype/prepared_report/prepared_report.py
+++ b/frappe/core/doctype/prepared_report/prepared_report.py
@@ -37,7 +37,10 @@ def run_background(prepared_report):
custom_report_doc = report
reference_report = custom_report_doc.reference_report
report = frappe.get_doc("Report", reference_report)
- report.custom_columns = custom_report_doc.json
+ if custom_report_doc.json:
+ data = json.loads(custom_report_doc.json)
+ if data:
+ report.custom_columns = data["columns"]
result = generate_report_result(
report=report,
diff --git a/frappe/core/doctype/report/report.py b/frappe/core/doctype/report/report.py
index af2c4e5dc2..8a0f9a99f5 100644
--- a/frappe/core/doctype/report/report.py
+++ b/frappe/core/doctype/report/report.py
@@ -325,9 +325,8 @@ def get_group_by_field(args, doctype):
if args['aggregate_function'] == 'count':
group_by_field = 'count(*) as _aggregate_column'
else:
- group_by_field = '{0}(`tab{1}`.{2}) as _aggregate_column'.format(
+ group_by_field = '{0}({1}) as _aggregate_column'.format(
args.aggregate_function,
- doctype,
args.aggregate_on
)
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 92493a593a..59089d12ad 100644
--- a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py
+++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py
@@ -2,14 +2,15 @@
# Copyright (c) 2019, Frappe Technologies and contributors
# For license information, please see license.txt
-from __future__ import unicode_literals
+import json
+from datetime import datetime
from typing import Dict, List
-import frappe, json
-from frappe.model.document import Document
-from frappe.utils import now_datetime, get_datetime
-from datetime import datetime
from croniter import croniter
+
+import frappe
+from frappe.model.document import Document
+from frappe.utils import get_datetime, now_datetime
from frappe.utils.background_jobs import enqueue, get_jobs
diff --git a/frappe/core/doctype/server_script/server_script.js b/frappe/core/doctype/server_script/server_script.js
index 95a63780f8..dda39115bf 100644
--- a/frappe/core/doctype/server_script/server_script.js
+++ b/frappe/core/doctype/server_script/server_script.js
@@ -9,6 +9,12 @@ frappe.ui.form.on('Server Script', {
if (frm.doc.script_type != 'Scheduler Event') {
frm.dashboard.hide();
}
+
+ frm.call('get_autocompletion_items')
+ .then(r => r.message)
+ .then(items => {
+ frm.set_df_property('script', 'autocompletions', items);
+ });
},
setup_help(frm) {
diff --git a/frappe/core/doctype/server_script/server_script.py b/frappe/core/doctype/server_script/server_script.py
index 8838d9e954..f80a067cf1 100644
--- a/frappe/core/doctype/server_script/server_script.py
+++ b/frappe/core/doctype/server_script/server_script.py
@@ -5,11 +5,12 @@
from __future__ import unicode_literals
import ast
+from types import FunctionType, MethodType, ModuleType
from typing import Dict, List
import frappe
from frappe.model.document import Document
-from frappe.utils.safe_exec import safe_exec
+from frappe.utils.safe_exec import get_safe_globals, safe_exec, NamespaceDict
from frappe import _
@@ -122,6 +123,51 @@ class ServerScript(Document):
if locals["conditions"]:
return locals["conditions"]
+ @frappe.whitelist()
+ def get_autocompletion_items(self):
+ """Generates a list of a autocompletion strings from the context dict
+ that is used while executing a Server Script.
+
+ Returns:
+ list: Returns list of autocompletion items.
+ For e.g., ["frappe.utils.cint", "frappe.db.get_all", ...]
+ """
+ def get_keys(obj):
+ out = []
+ for key in obj:
+ if key.startswith('_'):
+ continue
+ value = obj[key]
+ if isinstance(value, (NamespaceDict, dict)) and value:
+ if key == 'form_dict':
+ out.append(['form_dict', 7])
+ continue
+ for subkey, score in get_keys(value):
+ fullkey = f'{key}.{subkey}'
+ out.append([fullkey, score])
+ else:
+ if isinstance(value, type) and issubclass(value, Exception):
+ score = 0
+ elif isinstance(value, ModuleType):
+ score = 10
+ elif isinstance(value, (FunctionType, MethodType)):
+ score = 9
+ elif isinstance(value, type):
+ score = 8
+ elif isinstance(value, dict):
+ score = 7
+ else:
+ score = 6
+ out.append([key, score])
+ return out
+
+ items = frappe.cache().get_value('server_script_autocompletion_items')
+ if not items:
+ items = get_keys(get_safe_globals())
+ items = [{'value': d[0], 'score': d[1]} for d in items]
+ frappe.cache().set_value('server_script_autocompletion_items', items)
+ return items
+
@frappe.whitelist()
def setup_scheduler_events(script_name, frequency):
diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py
index 5b16c72775..5bea767934 100644
--- a/frappe/core/doctype/user/test_user.py
+++ b/frappe/core/doctype/user/test_user.py
@@ -229,6 +229,28 @@ class TestUser(unittest.TestCase):
self.assertEqual(extract_mentions(comment)[0], "test_user@example.com")
self.assertEqual(extract_mentions(comment)[1], "test.again@example1.com")
+ doc = frappe.get_doc({
+ 'doctype': 'User Group',
+ 'name': 'Team',
+ 'user_group_members': [{
+ 'user': 'test@example.com'
+ }, {
+ 'user': 'test1@example.com'
+ }]
+ })
+ doc.insert(ignore_if_duplicate=True)
+
+ comment = '''
+
+ Testing comment for
+
+ @Team
+
+ please check
+
+ '''
+ self.assertListEqual(extract_mentions(comment), ['test@example.com', 'test1@example.com'])
+
def test_rate_limiting_for_reset_password(self):
# Allow only one reset request for a day
frappe.db.set_value("System Settings", "System Settings", "password_reset_limit", 1)
diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py
index 04d087e82a..0462de8643 100644
--- a/frappe/core/doctype/user/user.py
+++ b/frappe/core/doctype/user/user.py
@@ -1018,8 +1018,16 @@ def extract_mentions(txt):
soup = BeautifulSoup(txt, 'html.parser')
emails = []
for mention in soup.find_all(class_='mention'):
+ if mention.get('data-is-group') == 'true':
+ try:
+ user_group = frappe.get_cached_doc('User Group', mention['data-id'])
+ emails += [d.user for d in user_group.user_group_members]
+ except frappe.DoesNotExistError:
+ pass
+ continue
email = mention['data-id']
emails.append(email)
+
return emails
def handle_password_test_fail(result):
diff --git a/frappe/core/doctype/user_group/__init__.py b/frappe/core/doctype/user_group/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/frappe/core/doctype/user_group/test_user_group.py b/frappe/core/doctype/user_group/test_user_group.py
new file mode 100644
index 0000000000..c7e28f3d31
--- /dev/null
+++ b/frappe/core/doctype/user_group/test_user_group.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2021, Frappe Technologies and Contributors
+# See license.txt
+from __future__ import unicode_literals
+
+# import frappe
+import unittest
+
+class TestUserGroup(unittest.TestCase):
+ pass
diff --git a/frappe/core/doctype/user_group/user_group.js b/frappe/core/doctype/user_group/user_group.js
new file mode 100644
index 0000000000..2aa9b68658
--- /dev/null
+++ b/frappe/core/doctype/user_group/user_group.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2021, Frappe Technologies and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('User Group', {
+ // refresh: function(frm) {
+
+ // }
+});
diff --git a/frappe/core/doctype/user_group/user_group.json b/frappe/core/doctype/user_group/user_group.json
new file mode 100644
index 0000000000..e807372061
--- /dev/null
+++ b/frappe/core/doctype/user_group/user_group.json
@@ -0,0 +1,48 @@
+{
+ "actions": [],
+ "autoname": "Prompt",
+ "creation": "2021-04-12 15:17:24.751710",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "user_group_members"
+ ],
+ "fields": [
+ {
+ "fieldname": "user_group_members",
+ "fieldtype": "Table MultiSelect",
+ "label": "User Group Members",
+ "options": "User Group Member",
+ "reqd": 1
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2021-04-15 16:12:31.455401",
+ "modified_by": "Administrator",
+ "module": "Core",
+ "name": "User Group",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "read": 1,
+ "role": "All"
+ }
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/frappe/core/doctype/user_group/user_group.py b/frappe/core/doctype/user_group/user_group.py
new file mode 100644
index 0000000000..64bffa06d0
--- /dev/null
+++ b/frappe/core/doctype/user_group/user_group.py
@@ -0,0 +1,15 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2021, Frappe Technologies and contributors
+# For license information, please see license.txt
+
+from __future__ import unicode_literals
+# import frappe
+from frappe.model.document import Document
+import frappe
+
+class UserGroup(Document):
+ def after_insert(self):
+ frappe.publish_realtime('user_group_added', self.name)
+
+ def on_trash(self):
+ frappe.publish_realtime('user_group_deleted', self.name)
diff --git a/frappe/core/doctype/user_group_member/__init__.py b/frappe/core/doctype/user_group_member/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
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
new file mode 100644
index 0000000000..38aade4608
--- /dev/null
+++ b/frappe/core/doctype/user_group_member/test_user_group_member.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2021, Frappe Technologies and Contributors
+# See license.txt
+from __future__ import unicode_literals
+
+# import frappe
+import unittest
+
+class TestUserGroupMember(unittest.TestCase):
+ pass
diff --git a/frappe/core/doctype/user_group_member/user_group_member.js b/frappe/core/doctype/user_group_member/user_group_member.js
new file mode 100644
index 0000000000..0b2dbe0d46
--- /dev/null
+++ b/frappe/core/doctype/user_group_member/user_group_member.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2021, Frappe Technologies and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('User Group Member', {
+ // refresh: function(frm) {
+
+ // }
+});
diff --git a/frappe/core/doctype/user_group_member/user_group_member.json b/frappe/core/doctype/user_group_member/user_group_member.json
new file mode 100644
index 0000000000..d2ff149366
--- /dev/null
+++ b/frappe/core/doctype/user_group_member/user_group_member.json
@@ -0,0 +1,32 @@
+{
+ "actions": [],
+ "creation": "2021-04-12 15:16:29.279107",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "user"
+ ],
+ "fields": [
+ {
+ "fieldname": "user",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "User",
+ "options": "User",
+ "reqd": 1
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2021-04-12 15:17:18.773046",
+ "modified_by": "Administrator",
+ "module": "Core",
+ "name": "User Group Member",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/frappe/core/doctype/user_group_member/user_group_member.py b/frappe/core/doctype/user_group_member/user_group_member.py
new file mode 100644
index 0000000000..4d0656913d
--- /dev/null
+++ b/frappe/core/doctype/user_group_member/user_group_member.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2021, Frappe Technologies and contributors
+# For license information, please see license.txt
+
+from __future__ import unicode_literals
+# import frappe
+from frappe.model.document import Document
+
+class UserGroupMember(Document):
+ pass
diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py
index ee6e3b9c61..3126326636 100644
--- a/frappe/custom/doctype/custom_field/custom_field.py
+++ b/frappe/custom/doctype/custom_field/custom_field.py
@@ -40,6 +40,8 @@ class CustomField(Document):
frappe.throw(_("A field with the name '{}' already exists in doctype {}.").format(self.fieldname, self.dt))
def validate(self):
+ from frappe.custom.doctype.customize_form.customize_form import CustomizeForm
+
meta = frappe.get_meta(self.dt, cached=False)
fieldnames = [df.fieldname for df in meta.get("fields")]
@@ -49,7 +51,11 @@ class CustomField(Document):
if self.insert_after and self.insert_after in fieldnames:
self.idx = fieldnames.index(self.insert_after) + 1
- self._old_fieldtype = self.db_get('fieldtype')
+ old_fieldtype = self.db_get('fieldtype')
+ is_fieldtype_changed = (not self.is_new()) and (old_fieldtype != self.fieldtype)
+
+ if is_fieldtype_changed and not CustomizeForm.allow_fieldtype_change(old_fieldtype, self.fieldtype):
+ frappe.throw(_("Fieldtype cannot be changed from {0} to {1}").format(old_fieldtype, self.fieldtype))
if not self.fieldname:
frappe.throw(_("Fieldname not set for Custom Field"))
diff --git a/frappe/custom/doctype/customize_form/customize_form.json b/frappe/custom/doctype/customize_form/customize_form.json
index 77f62b3ec3..8d6a6a8ca7 100644
--- a/frappe/custom/doctype/customize_form/customize_form.json
+++ b/frappe/custom/doctype/customize_form/customize_form.json
@@ -33,6 +33,8 @@
"show_preview_popup",
"image_view",
"email_settings_section",
+ "default_email_template",
+ "column_break_26",
"email_append_to",
"sender_field",
"subject_field",
@@ -264,6 +266,16 @@
"label": "Actions",
"options": "DocType Action"
},
+ {
+ "fieldname": "default_email_template",
+ "fieldtype": "Link",
+ "label": "Default Email Template",
+ "options": "Email Template"
+ },
+ {
+ "fieldname": "column_break_26",
+ "fieldtype": "Column Break"
+ },
{
"collapsible": 1,
"fieldname": "naming_section",
@@ -275,6 +287,16 @@
"fieldname": "autoname",
"fieldtype": "Data",
"label": "Auto Name"
+ },
+ {
+ "fieldname": "default_email_template",
+ "fieldtype": "Link",
+ "label": "Default Email Template",
+ "options": "Email Template"
+ },
+ {
+ "fieldname": "column_break_26",
+ "fieldtype": "Column Break"
}
],
"hide_toolbar": 1,
@@ -283,7 +305,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2021-02-16 15:22:11.108256",
+ "modified": "2021-03-22 12:27:15.462727",
"modified_by": "Administrator",
"module": "Custom",
"name": "Customize Form",
@@ -304,4 +326,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"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 c79c965aae..be0dded99c 100644
--- a/frappe/custom/doctype/customize_form/customize_form.py
+++ b/frappe/custom/doctype/customize_form/customize_form.py
@@ -401,22 +401,18 @@ class CustomizeForm(Document):
return property_value
def validate_fieldtype_change(self, df, old_value, new_value):
- allowed = False
- self.check_length_for_fieldtypes = []
- for allowed_changes in ALLOWED_FIELDTYPE_CHANGE:
- if (old_value in allowed_changes and new_value in allowed_changes):
- allowed = True
- old_value_length = cint(frappe.db.type_map.get(old_value)[1])
- new_value_length = cint(frappe.db.type_map.get(new_value)[1])
+ allowed = self.allow_fieldtype_change(old_value, new_value)
+ if allowed:
+ old_value_length = cint(frappe.db.type_map.get(old_value)[1])
+ new_value_length = cint(frappe.db.type_map.get(new_value)[1])
- # Ignore fieldtype check validation if new field type has unspecified maxlength
- # Changes like DATA to TEXT, where new_value_lenth equals 0 will not be validated
- if new_value_length and (old_value_length > new_value_length):
- self.check_length_for_fieldtypes.append({'df': df, 'old_value': old_value})
- self.validate_fieldtype_length()
- else:
- self.flags.update_db = True
- break
+ # Ignore fieldtype check validation if new field type has unspecified maxlength
+ # Changes like DATA to TEXT, where new_value_lenth equals 0 will not be validated
+ if new_value_length and (old_value_length > new_value_length):
+ self.check_length_for_fieldtypes.append({'df': df, 'old_value': old_value})
+ self.validate_fieldtype_length()
+ else:
+ self.flags.update_db = True
if not allowed:
frappe.throw(_("Fieldtype cannot be changed from {0} to {1} in row {2}").format(old_value, new_value, df.idx))
@@ -458,6 +454,14 @@ class CustomizeForm(Document):
reset_customization(self.doc_type)
self.fetch_to_customize()
+ @classmethod
+ def allow_fieldtype_change(self, old_type: str, new_type: str) -> bool:
+ """ allow type change, if both old_type and new_type are in same field group.
+ field groups are defined in ALLOWED_FIELDTYPE_CHANGE variables.
+ """
+ in_field_group = lambda group: (old_type in group) and (new_type in group)
+ return any(map(in_field_group, ALLOWED_FIELDTYPE_CHANGE))
+
def reset_customization(doctype):
setters = frappe.get_all("Property Setter", filters={
'doc_type': doctype,
@@ -487,6 +491,7 @@ doctype_properties = {
'allow_auto_repeat': 'Check',
'allow_import': 'Check',
'show_preview_popup': 'Check',
+ 'default_email_template': 'Data',
'email_append_to': 'Check',
'subject_field': 'Data',
'sender_field': 'Data',
diff --git a/frappe/database/mariadb/database.py b/frappe/database/mariadb/database.py
index f9997d1526..7d1d92408c 100644
--- a/frappe/database/mariadb/database.py
+++ b/frappe/database/mariadb/database.py
@@ -1,17 +1,13 @@
-from __future__ import unicode_literals
-
-import frappe
import warnings
import pymysql
-from pymysql.times import TimeDelta
-from pymysql.constants import ER, FIELD_TYPE
-from pymysql.converters import conversions
+from pymysql.constants import ER, FIELD_TYPE
+from pymysql.converters import conversions, escape_string
-from frappe.utils import get_datetime, cstr, UnicodeWithAttrs
+import frappe
from frappe.database.database import Database
-from six import PY2, binary_type, text_type, string_types
from frappe.database.mariadb.schema import MariaDBTable
+from frappe.utils import UnicodeWithAttrs, cstr, get_datetime
class MariaDBDatabase(Database):
@@ -72,22 +68,20 @@ class MariaDBDatabase(Database):
conversions.update({
FIELD_TYPE.NEWDECIMAL: float,
FIELD_TYPE.DATETIME: get_datetime,
- UnicodeWithAttrs: conversions[text_type]
+ UnicodeWithAttrs: conversions[str]
})
- if PY2:
- conversions.update({
- TimeDelta: conversions[binary_type]
- })
-
- if usessl:
- conn = pymysql.connect(self.host, self.user or '', self.password or '',
- port=self.port, charset='utf8mb4', use_unicode = True, ssl=ssl_params,
- conv = conversions, local_infile = frappe.conf.local_infile)
- else:
- conn = pymysql.connect(self.host, self.user or '', self.password or '',
- port=self.port, charset='utf8mb4', use_unicode = True, conv = conversions,
- local_infile = frappe.conf.local_infile)
+ conn = pymysql.connect(
+ user=self.user or '',
+ password=self.password or '',
+ host=self.host,
+ port=self.port,
+ charset='utf8mb4',
+ use_unicode=True,
+ ssl=ssl_params if usessl else None,
+ conv=conversions,
+ local_infile=frappe.conf.local_infile
+ )
# MYSQL_OPTION_MULTI_STATEMENTS_OFF = 1
# # self._conn.set_server_option(MYSQL_OPTION_MULTI_STATEMENTS_OFF)
@@ -111,7 +105,7 @@ class MariaDBDatabase(Database):
def escape(s, percent=True):
"""Excape quotes and percent in given string."""
# pymysql expects unicode argument to escape_string with Python 3
- s = frappe.as_unicode(pymysql.escape_string(frappe.as_unicode(s)), "utf-8").replace("`", "\\`")
+ s = frappe.as_unicode(escape_string(frappe.as_unicode(s)), "utf-8").replace("`", "\\`")
# NOTE separating % escape, because % escape should only be done when using LIKE operator
# or when you use python format string to generate query that already has a %s
@@ -260,7 +254,7 @@ class MariaDBDatabase(Database):
ADD INDEX `%s`(%s)""" % (table_name, index_name, ", ".join(fields)))
def add_unique(self, doctype, fields, constraint_name=None):
- if isinstance(fields, string_types):
+ if isinstance(fields, str):
fields = [fields]
if not constraint_name:
constraint_name = "unique_" + "_".join(fields)
diff --git a/frappe/desk/doctype/notification_settings/notification_settings.js b/frappe/desk/doctype/notification_settings/notification_settings.js
index 88dc145be2..cc2fd95204 100644
--- a/frappe/desk/doctype/notification_settings/notification_settings.js
+++ b/frappe/desk/doctype/notification_settings/notification_settings.js
@@ -19,7 +19,7 @@ frappe.ui.form.on('Notification Settings', {
refresh: (frm) => {
if (frappe.user.has_role('System Manager')) {
- frm.add_custom_button('Go to Notification Settings List', () => {
+ frm.add_custom_button(__('Go to Notification Settings List'), () => {
frappe.set_route('List', 'Notification Settings');
});
}
diff --git a/frappe/desk/query_report.py b/frappe/desk/query_report.py
index 22d47d1120..9589507ca6 100644
--- a/frappe/desk/query_report.py
+++ b/frappe/desk/query_report.py
@@ -36,7 +36,10 @@ def get_report_doc(report_name):
reference_report = custom_report_doc.reference_report
doc = frappe.get_doc("Report", reference_report)
doc.custom_report = report_name
- doc.custom_columns = custom_report_doc.json
+ if custom_report_doc.json:
+ data = json.loads(custom_report_doc.json)
+ if data:
+ doc.custom_columns = data["columns"]
doc.is_custom_report = True
if not doc.is_permitted():
@@ -83,7 +86,7 @@ def generate_report_result(report, filters=None, user=None, custom_columns=None)
if report.custom_columns:
# saved columns (with custom columns / with different column order)
- columns = json.loads(report.custom_columns)
+ columns = report.custom_columns
# unsaved custom_columns
if custom_columns:
@@ -524,9 +527,12 @@ def save_report(reference_report, report_name, columns):
"report_type": "Custom Report",
},
)
+
if docname:
report = frappe.get_doc("Report", docname)
- report.update({"json": columns})
+ existing_jd = json.loads(report.json)
+ existing_jd["columns"] = json.loads(columns)
+ report.update({"json": json.dumps(existing_jd, separators=(',', ':'))})
report.save()
frappe.msgprint(_("Report updated successfully"))
@@ -536,7 +542,7 @@ def save_report(reference_report, report_name, columns):
{
"doctype": "Report",
"report_name": report_name,
- "json": columns,
+ "json": f'{{"columns":{columns}}}',
"ref_doctype": report_doc.ref_doctype,
"is_standard": "No",
"report_type": "Custom Report",
diff --git a/frappe/desk/treeview.py b/frappe/desk/treeview.py
index 29c9b0c30d..6f0d7d3d5f 100644
--- a/frappe/desk/treeview.py
+++ b/frappe/desk/treeview.py
@@ -66,7 +66,7 @@ def add_node():
doc.save()
def make_tree_args(**kwarg):
- del kwarg['cmd']
+ kwarg.pop('cmd', None)
doctype = kwarg['doctype']
parent_field = 'parent_' + doctype.lower().replace(' ', '_')
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 d82caa7bd4..6f1cd8eebd 100644
--- a/frappe/email/doctype/auto_email_report/auto_email_report.py
+++ b/frappe/email/doctype/auto_email_report/auto_email_report.py
@@ -252,7 +252,7 @@ def make_links(columns, data):
elif col.fieldtype == "Dynamic Link":
if col.options and row.get(col.fieldname) and row.get(col.options):
row[col.fieldname] = get_link_to_form(row[col.options], row[col.fieldname])
- elif col.fieldtype == "Currency":
+ elif col.fieldtype == "Currency" and row.get(col.fieldname):
row[col.fieldname] = frappe.format_value(row[col.fieldname], col)
return columns, data
diff --git a/frappe/email/doctype/newsletter/newsletter.py b/frappe/email/doctype/newsletter/newsletter.py
index c792347c09..6412338e96 100755
--- a/frappe/email/doctype/newsletter/newsletter.py
+++ b/frappe/email/doctype/newsletter/newsletter.py
@@ -24,6 +24,7 @@ class Newsletter(WebsiteGenerator):
if self.send_from:
validate_email_address(self.send_from, True)
+ @frappe.whitelist()
def test_send(self, doctype="Lead"):
self.recipients = frappe.utils.split_emails(self.test_email_id)
self.queue_all(test_email=True)
diff --git a/frappe/email/receive.py b/frappe/email/receive.py
index cf6c13ee76..949da4a343 100644
--- a/frappe/email/receive.py
+++ b/frappe/email/receive.py
@@ -1,18 +1,27 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt
-from __future__ import unicode_literals
-import six
-from six import iteritems, text_type
-from six.moves import range
-import time, _socket, poplib, imaplib, email, email.utils, datetime, chardet, re
-from email_reply_parser import EmailReplyParser
+import datetime
+import email
+import email.utils
+import imaplib
+import poplib
+import re
+import time
from email.header import decode_header
+
+import _socket
+import chardet
+import six
+from email_reply_parser import EmailReplyParser
+
import frappe
from frappe import _, safe_decode, safe_encode
-from frappe.utils import (extract_email_id, convert_utc_to_user_timezone, now,
- cint, cstr, strip, markdown, parse_addr)
-from frappe.core.doctype.file.file import get_random_filename, MaxFileSizeReachedError
+from frappe.core.doctype.file.file import (MaxFileSizeReachedError,
+ get_random_filename)
+from frappe.utils import (cint, convert_utc_to_user_timezone, cstr,
+ extract_email_id, markdown, now, parse_addr, strip)
+
class EmailSizeExceededError(frappe.ValidationError): pass
class EmailTimeoutError(frappe.ValidationError): pass
@@ -337,7 +346,7 @@ class EmailServer:
return
self.imap.select("Inbox")
- for uid, operation in iteritems(uid_list):
+ for uid, operation in uid_list.items():
if not uid: continue
op = "+FLAGS" if operation == "Read" else "-FLAGS"
@@ -473,7 +482,7 @@ class Email:
self.html_content += markdown(text_content)
def get_charset(self, part):
- """Detect chartset."""
+ """Detect charset."""
charset = part.get_content_charset()
if not charset:
charset = chardet.detect(safe_encode(cstr(part)))['encoding']
@@ -484,7 +493,7 @@ class Email:
charset = self.get_charset(part)
try:
- return text_type(part.get_payload(decode=True), str(charset), "ignore")
+ return str(part.get_payload(decode=True), str(charset), "ignore")
except LookupError:
return part.get_payload()
diff --git a/frappe/email/test_smtp.py b/frappe/email/test_smtp.py
index 869d708430..0b11c559a2 100644
--- a/frappe/email/test_smtp.py
+++ b/frappe/email/test_smtp.py
@@ -2,7 +2,9 @@
# License: The MIT License
import unittest
+import frappe
from frappe.email.smtp import SMTPServer
+from frappe.email.smtp import get_outgoing_email_account
class TestSMTP(unittest.TestCase):
def test_smtp_ssl_session(self):
@@ -13,6 +15,57 @@ class TestSMTP(unittest.TestCase):
for port in [None, 0, 587, "587"]:
make_server(port, 0, 1)
+ def test_get_email_account(self):
+ existing_email_accounts = frappe.get_all("Email Account", fields = ["name", "enable_outgoing", "default_outgoing", "append_to"])
+ unset_details = {
+ "enable_outgoing": 0,
+ "default_outgoing": 0,
+ "append_to": None
+ }
+ for email_account in existing_email_accounts:
+ frappe.db.set_value('Email Account', email_account['name'], unset_details)
+
+ # remove mail_server config so that test@example.com is not created
+ mail_server = frappe.conf.get('mail_server')
+ del frappe.conf['mail_server']
+
+ frappe.local.outgoing_email_account = {}
+
+ frappe.local.outgoing_email_account = {}
+ # lowest preference given to email account with default incoming enabled
+ create_email_account(email_id="default_outgoing_enabled@gmail.com", password="***", enable_outgoing = 1, default_outgoing=1)
+ self.assertEqual(get_outgoing_email_account().email_id, "default_outgoing_enabled@gmail.com")
+
+ frappe.local.outgoing_email_account = {}
+ # highest preference given to email account with append_to matching
+ create_email_account(email_id="append_to@gmail.com", password="***", enable_outgoing = 1, default_outgoing=1, append_to="Blog Post")
+ self.assertEqual(get_outgoing_email_account(append_to="Blog Post").email_id, "append_to@gmail.com")
+
+ # add back the mail_server
+ frappe.conf['mail_server'] = mail_server
+ for email_account in existing_email_accounts:
+ set_details = {
+ "enable_outgoing": email_account['enable_outgoing'],
+ "default_outgoing": email_account['default_outgoing'],
+ "append_to": email_account['append_to']
+ }
+ frappe.db.set_value('Email Account', email_account['name'], set_details)
+
+def create_email_account(email_id, password, enable_outgoing, default_outgoing=0, append_to=None):
+ email_dict = {
+ "email_id": email_id,
+ "passsword": password,
+ "enable_outgoing":enable_outgoing ,
+ "default_outgoing":default_outgoing ,
+ "enable_incoming": 1,
+ "append_to":append_to,
+ "is_dummy_password": 1,
+ "smtp_server": "localhost"
+ }
+
+ email_account = frappe.new_doc('Email Account')
+ email_account.update(email_dict)
+ email_account.save()
def make_server(port, ssl, tls):
server = SMTPServer(
@@ -22,4 +75,4 @@ def make_server(port, ssl, tls):
use_tls = tls
)
- server.sess
\ No newline at end of file
+ server.sess
diff --git a/frappe/handler.py b/frappe/handler.py
index 82c1ea65c6..a38feb90fa 100755
--- a/frappe/handler.py
+++ b/frappe/handler.py
@@ -9,7 +9,6 @@ import frappe
import frappe.utils
import frappe.sessions
from frappe.utils import cint
-from frappe.api import validate_auth
from frappe import _, is_whitelisted
from frappe.utils.response import build_response
from frappe.utils.csvutils import build_csv_response
@@ -24,7 +23,7 @@ ALLOWED_MIMETYPES = ('image/png', 'image/jpeg', 'application/pdf', 'application/
def handle():
"""handle request"""
- validate_auth()
+
cmd = frappe.local.form_dict.cmd
data = None
diff --git a/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py b/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py
index 09da1ecc42..53f0935c80 100644
--- a/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py
+++ b/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py
@@ -2,22 +2,23 @@
# Copyright (c) 2015, Frappe Technologies and contributors
# For license information, please see license.txt
-from __future__ import unicode_literals
-import dropbox
import json
-import frappe
import os
-from frappe import _
-from frappe.model.document import Document
-from frappe.integrations.offsite_backup_utils import get_latest_backup_file, send_email, validate_file_size, get_chunk_site
-from frappe.integrations.utils import make_post_request
-from frappe.utils import (cint, get_request_site_address,
- get_files_path, get_backups_path, get_url, encode)
-from frappe.utils.backups import new_backup
-from frappe.utils.background_jobs import enqueue
-from six.moves.urllib.parse import urlparse, parse_qs
+from urllib.parse import parse_qs, urlparse
+
+import dropbox
from rq.timeouts import JobTimeoutException
-from six import text_type
+
+import frappe
+from frappe import _
+from frappe.integrations.offsite_backup_utils import (get_chunk_site,
+ get_latest_backup_file, send_email, validate_file_size)
+from frappe.integrations.utils import make_post_request
+from frappe.model.document import Document
+from frappe.utils import (cint, encode, get_backups_path, get_files_path,
+ get_request_site_address, get_url)
+from frappe.utils.background_jobs import enqueue
+from frappe.utils.backups import new_backup
ignore_list = [".DS_Store"]
@@ -91,7 +92,10 @@ def backup_to_dropbox(upload_db_backup=True):
dropbox_settings['access_token'] = access_token['oauth2_token']
set_dropbox_access_token(access_token['oauth2_token'])
- dropbox_client = dropbox.Dropbox(dropbox_settings['access_token'], timeout=None)
+ dropbox_client = dropbox.Dropbox(
+ oauth2_access_token=dropbox_settings['access_token'],
+ timeout=None
+ )
if upload_db_backup:
if frappe.flags.create_new_backup:
@@ -127,7 +131,7 @@ def upload_from_folder(path, is_private, dropbox_folder, dropbox_client, did_not
else:
response = frappe._dict({"entries": []})
- path = text_type(path)
+ path = str(path)
for f in frappe.get_all("File", filters={"is_folder": 0, "is_private": is_private,
"uploaded_to_dropbox": 0}, fields=['file_url', 'name', 'file_name']):
@@ -286,11 +290,11 @@ def get_redirect_url():
def get_dropbox_authorize_url():
app_details = get_dropbox_settings(redirect_uri=True)
dropbox_oauth_flow = dropbox.DropboxOAuth2Flow(
- app_details["app_key"],
- app_details["app_secret"],
- app_details["redirect_uri"],
- {},
- "dropbox-auth-csrf-token"
+ consumer_key=app_details["app_key"],
+ redirect_uri=app_details["redirect_uri"],
+ session={},
+ csrf_token_session_key="dropbox-auth-csrf-token",
+ consumer_secret=app_details["app_secret"]
)
auth_url = dropbox_oauth_flow.start()
@@ -307,13 +311,13 @@ def dropbox_auth_finish(return_access_token=False):
close = '' + _('Please close this window') + '
'
dropbox_oauth_flow = dropbox.DropboxOAuth2Flow(
- app_details["app_key"],
- app_details["app_secret"],
- app_details["redirect_uri"],
- {
+ consumer_key=app_details["app_key"],
+ redirect_uri=app_details["redirect_uri"],
+ session={
'dropbox-auth-csrf-token': callback.state
},
- "dropbox-auth-csrf-token"
+ csrf_token_session_key="dropbox-auth-csrf-token",
+ consumer_secret=app_details["app_secret"]
)
if callback.state or callback.code:
diff --git a/frappe/integrations/doctype/google_calendar/google_calendar.py b/frappe/integrations/doctype/google_calendar/google_calendar.py
index fbedd75029..f93be35aa7 100644
--- a/frappe/integrations/doctype/google_calendar/google_calendar.py
+++ b/frappe/integrations/doctype/google_calendar/google_calendar.py
@@ -2,22 +2,23 @@
# Copyright (c) 2019, Frappe Technologies and contributors
# For license information, please see license.txt
-from __future__ import unicode_literals
-import frappe
-import requests
-import googleapiclient.discovery
-import google.oauth2.credentials
-from frappe import _
-from frappe.model.document import Document
-from frappe.utils import get_request_site_address
-from googleapiclient.errors import HttpError
-from frappe.utils.password import set_encrypted_password
-from frappe.utils import add_days, get_datetime, get_weekdays, now_datetime, add_to_date, get_time_zone
-from dateutil import parser
from datetime import datetime, timedelta
-from six.moves.urllib.parse import quote
+from urllib.parse import quote
+
+import google.oauth2.credentials
+import requests
+from dateutil import parser
+from googleapiclient.discovery import build
+from googleapiclient.errors import HttpError
+
+import frappe
+from frappe import _
from frappe.integrations.doctype.google_settings.google_settings import get_auth_url
+from frappe.model.document import Document
+from frappe.utils import (add_days, add_to_date, get_datetime,
+ get_request_site_address, get_time_zone, get_weekdays, now_datetime)
+from frappe.utils.password import set_encrypted_password
SCOPES = "https://www.googleapis.com/auth/calendar"
@@ -171,7 +172,12 @@ def get_google_calendar_object(g_calendar):
}
credentials = google.oauth2.credentials.Credentials(**credentials_dict)
- google_calendar = googleapiclient.discovery.build("calendar", "v3", credentials=credentials)
+ google_calendar = build(
+ serviceName="calendar",
+ version="v3",
+ credentials=credentials,
+ static_discovery=False
+ )
check_google_calendar(account, google_calendar)
diff --git a/frappe/integrations/doctype/google_contacts/google_contacts.py b/frappe/integrations/doctype/google_contacts/google_contacts.py
index 4c8c3b67f6..1705f98e91 100644
--- a/frappe/integrations/doctype/google_contacts/google_contacts.py
+++ b/frappe/integrations/doctype/google_contacts/google_contacts.py
@@ -2,17 +2,17 @@
# Copyright (c) 2019, Frappe Technologies and contributors
# For license information, please see license.txt
-from __future__ import unicode_literals
-import frappe
-import requests
-import googleapiclient.discovery
-import google.oauth2.credentials
-from frappe.model.document import Document
-from frappe import _
+import google.oauth2.credentials
+import requests
+from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
-from frappe.utils import get_request_site_address
+
+import frappe
+from frappe import _
from frappe.integrations.doctype.google_settings.google_settings import get_auth_url
+from frappe.model.document import Document
+from frappe.utils import get_request_site_address
SCOPES = "https://www.googleapis.com/auth/contacts"
@@ -118,7 +118,12 @@ def get_google_contacts_object(g_contact):
}
credentials = google.oauth2.credentials.Credentials(**credentials_dict)
- google_contacts = googleapiclient.discovery.build("people", "v1", credentials=credentials)
+ google_contacts = build(
+ serviceName="people",
+ version="v1",
+ credentials=credentials,
+ static_discovery=False
+ )
return google_contacts, account
diff --git a/frappe/integrations/doctype/google_drive/google_drive.py b/frappe/integrations/doctype/google_drive/google_drive.py
index 859c769018..93b6fa3f8d 100644
--- a/frappe/integrations/doctype/google_drive/google_drive.py
+++ b/frappe/integrations/doctype/google_drive/google_drive.py
@@ -2,27 +2,29 @@
# Copyright (c) 2019, Frappe Technologies and contributors
# For license information, please see license.txt
-from __future__ import unicode_literals
-import frappe
-import requests
-import googleapiclient.discovery
-import google.oauth2.credentials
import os
+from urllib.parse import quote
-from frappe import _
-from googleapiclient.errors import HttpError
-from frappe.model.document import Document
-from frappe.utils import get_request_site_address
-from frappe.utils.background_jobs import enqueue
-from six.moves.urllib.parse import quote
+import google.oauth2.credentials
+import requests
from apiclient.http import MediaFileUpload
-from frappe.utils import get_backups_path, get_bench_path
-from frappe.utils.backups import new_backup
+from googleapiclient.discovery import build
+from googleapiclient.errors import HttpError
+
+import frappe
+from frappe import _
from frappe.integrations.doctype.google_settings.google_settings import get_auth_url
-from frappe.integrations.offsite_backup_utils import get_latest_backup_file, send_email, validate_file_size
+from frappe.integrations.offsite_backup_utils import (get_latest_backup_file,
+ send_email, validate_file_size)
+from frappe.model.document import Document
+from frappe.utils import (get_backups_path, get_bench_path,
+ get_request_site_address)
+from frappe.utils.background_jobs import enqueue
+from frappe.utils.backups import new_backup
SCOPES = "https://www.googleapis.com/auth/drive"
+
class GoogleDrive(Document):
def validate(self):
@@ -126,7 +128,12 @@ def get_google_drive_object():
}
credentials = google.oauth2.credentials.Credentials(**credentials_dict)
- google_drive = googleapiclient.discovery.build("drive", "v3", credentials=credentials)
+ google_drive = build(
+ serviceName="drive",
+ version="v3",
+ credentials=credentials,
+ static_discovery=False
+ )
return google_drive, account
diff --git a/frappe/integrations/doctype/token_cache/test_token_cache.py b/frappe/integrations/doctype/token_cache/test_token_cache.py
index 73c9f38fce..7aa069647d 100644
--- a/frappe/integrations/doctype/token_cache/test_token_cache.py
+++ b/frappe/integrations/doctype/token_cache/test_token_cache.py
@@ -13,7 +13,7 @@ class TestTokenCache(unittest.TestCase):
def setUp(self):
self.token_cache = frappe.get_last_doc('Token Cache')
self.token_cache.update({'connected_app': frappe.get_last_doc('Connected App').name})
- self.token_cache.save()
+ self.token_cache.save(ignore_permissions=True)
def test_get_auth_header(self):
self.token_cache.get_auth_header()
diff --git a/frappe/integrations/doctype/webhook/__init__.py b/frappe/integrations/doctype/webhook/__init__.py
index 8b08db5f68..19233bd175 100644
--- a/frappe/integrations/doctype/webhook/__init__.py
+++ b/frappe/integrations/doctype/webhook/__init__.py
@@ -21,7 +21,9 @@ def run_webhooks(doc, method):
if webhooks is None:
# query webhooks
webhooks_list = frappe.get_all('Webhook',
- fields=["name", "`condition`", "webhook_docevent", "webhook_doctype"])
+ fields=["name", "`condition`", "webhook_docevent", "webhook_doctype"],
+ filters={"enabled": True}
+ )
# make webhooks map for cache
webhooks = {}
diff --git a/frappe/integrations/doctype/webhook/test_webhook.py b/frappe/integrations/doctype/webhook/test_webhook.py
index fa7f9534e1..acf2f609e7 100644
--- a/frappe/integrations/doctype/webhook/test_webhook.py
+++ b/frappe/integrations/doctype/webhook/test_webhook.py
@@ -10,6 +10,44 @@ from frappe.integrations.doctype.webhook.webhook import get_webhook_headers, get
class TestWebhook(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ # delete any existing webhooks
+ frappe.db.sql("DELETE FROM tabWebhook")
+ # create test webhooks
+ cls.create_sample_webhooks()
+
+ @classmethod
+ def create_sample_webhooks(cls):
+ samples_webhooks_data = [
+ {
+ "webhook_doctype": "User",
+ "webhook_docevent": "after_insert",
+ "request_url": "https://httpbin.org/post",
+ "condition": "doc.email",
+ "enabled": True
+ },
+ {
+ "webhook_doctype": "User",
+ "webhook_docevent": "after_insert",
+ "request_url": "https://httpbin.org/post",
+ "condition": "doc.first_name",
+ "enabled": False
+ }
+ ]
+
+ cls.sample_webhooks = []
+ for wh_fields in samples_webhooks_data:
+ wh = frappe.new_doc("Webhook")
+ wh.update(wh_fields)
+ wh.insert()
+ cls.sample_webhooks.append(wh)
+
+ @classmethod
+ def tearDownClass(cls):
+ # delete any existing webhooks
+ frappe.db.sql("DELETE FROM tabWebhook")
+
def setUp(self):
# retrieve or create a User webhook for `after_insert`
webhook_fields = {
@@ -30,10 +68,37 @@ class TestWebhook(unittest.TestCase):
self.user.email = frappe.mock("email")
self.user.save()
+ # Create another test user specific to this test
+ self.test_user = frappe.new_doc("User")
+ self.test_user.email = "user1@integration.webhooks.test.com"
+ self.test_user.first_name = "user1"
+
def tearDown(self) -> None:
self.user.delete()
+ self.test_user.delete()
super().tearDown()
+ def test_webhook_trigger_with_enabled_webhooks(self):
+ """Test webhook trigger for enabled webhooks"""
+
+ frappe.cache().delete_value('webhooks')
+ frappe.flags.webhooks = None
+
+ # Insert the user to db
+ self.test_user.insert()
+
+ self.assertTrue("User" in frappe.flags.webhooks)
+ # only 1 hook (enabled) must be queued
+ self.assertEqual(
+ len(frappe.flags.webhooks.get("User")),
+ 1
+ )
+ self.assertTrue(self.test_user.email in frappe.flags.webhooks_executed)
+ self.assertEqual(
+ frappe.flags.webhooks_executed.get(self.test_user.email)[0],
+ self.sample_webhooks[0].name
+ )
+
def test_validate_doc_events(self):
"Test creating a submit-related webhook for a non-submittable DocType"
diff --git a/frappe/integrations/doctype/webhook/webhook.json b/frappe/integrations/doctype/webhook/webhook.json
index 9f979099c9..85895c052c 100644
--- a/frappe/integrations/doctype/webhook/webhook.json
+++ b/frappe/integrations/doctype/webhook/webhook.json
@@ -11,6 +11,7 @@
"webhook_doctype",
"cb_doc_events",
"webhook_docevent",
+ "enabled",
"sb_condition",
"condition",
"cb_condition",
@@ -147,10 +148,16 @@
"fieldname": "webhook_secret",
"fieldtype": "Password",
"label": "Webhook Secret"
+ },
+ {
+ "default": "1",
+ "fieldname": "enabled",
+ "fieldtype": "Check",
+ "label": "Enabled"
}
],
"links": [],
- "modified": "2020-01-13 01:53:04.459968",
+ "modified": "2021-04-14 05:35:28.532049",
"modified_by": "Administrator",
"module": "Integrations",
"name": "Webhook",
diff --git a/frappe/integrations/oauth2.py b/frappe/integrations/oauth2.py
index c444964a16..3ebaaffcff 100644
--- a/frappe/integrations/oauth2.py
+++ b/frappe/integrations/oauth2.py
@@ -133,7 +133,7 @@ def get_token(*args, **kwargs):
}
id_token_encoded = jwt.encode(id_token, client_secret, algorithm='HS256', headers=id_token_header)
- out.update({"id_token": str(id_token_encoded)})
+ out.update({"id_token": frappe.safe_decode(id_token_encoded)})
frappe.local.response = out
diff --git a/frappe/patches.txt b/frappe/patches.txt
index 5251b3da30..516ddb6094 100644
--- a/frappe/patches.txt
+++ b/frappe/patches.txt
@@ -334,3 +334,4 @@ frappe.patches.v13_0.delete_package_publish_tool
frappe.patches.v13_0.rename_list_view_setting_to_list_view_settings
frappe.patches.v13_0.remove_twilio_settings
frappe.patches.v12_0.rename_uploaded_files_with_proper_name
+frappe.patches.v13_0.queryreport_columns
diff --git a/frappe/patches/v13_0/queryreport_columns.py b/frappe/patches/v13_0/queryreport_columns.py
new file mode 100644
index 0000000000..6c2a1b1219
--- /dev/null
+++ b/frappe/patches/v13_0/queryreport_columns.py
@@ -0,0 +1,22 @@
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# MIT License. See license.txt
+
+from __future__ import unicode_literals
+import frappe
+import json
+
+def execute():
+ """Convert Query Report json to support other content"""
+ records = frappe.get_all('Report',
+ filters={
+ "json": ["!=", ""]
+ },
+ fields=["name", "json"]
+ )
+ for record in records:
+ jstr = record["json"]
+ data = json.loads(jstr)
+ if isinstance(data, list):
+ # double escape braces
+ jstr = f'{{"columns":{jstr}}}'
+ frappe.db.update('Report', record["name"], "json", jstr)
diff --git a/frappe/public/js/frappe/desk.js b/frappe/public/js/frappe/desk.js
index 250d308b7e..216ec967a4 100644
--- a/frappe/public/js/frappe/desk.js
+++ b/frappe/public/js/frappe/desk.js
@@ -51,6 +51,7 @@ frappe.Application = Class.extend({
this.set_fullwidth_if_enabled();
this.add_browser_class();
this.setup_energy_point_listeners();
+ this.setup_copy_doc_listener();
frappe.ui.keys.setup();
@@ -113,7 +114,7 @@ frappe.Application = Class.extend({
dialog.get_close_btn().toggle(false);
});
- this.setup_social_listeners();
+ this.setup_user_group_listeners();
// listen to build errors
this.setup_build_error_listener();
@@ -592,11 +593,12 @@ frappe.Application = Class.extend({
}
},
- setup_social_listeners() {
- frappe.realtime.on('mention', (message) => {
- if (frappe.get_route()[0] !== 'social') {
- frappe.show_alert(message);
- }
+ setup_user_group_listeners() {
+ frappe.realtime.on('user_group_added', (user_group) => {
+ frappe.boot.user_groups && frappe.boot.user_groups.push(user_group);
+ });
+ frappe.realtime.on('user_group_deleted', (user_group) => {
+ frappe.boot.user_groups = (frappe.boot.user_groups || []).filter(el => el !== user_group);
});
},
@@ -605,6 +607,39 @@ frappe.Application = Class.extend({
frappe.show_alert(message);
});
},
+
+ setup_copy_doc_listener() {
+ $('body').on('paste', (e) => {
+ try {
+ let clipboard_data = e.clipboardData || window.clipboardData || e.originalEvent.clipboardData;
+ let pasted_data = clipboard_data.getData('Text');
+ let doc = JSON.parse(pasted_data);
+ if (doc.doctype) {
+ e.preventDefault();
+ let sleep = (time) => {
+ return new Promise((resolve) => setTimeout(resolve, time));
+ };
+
+ frappe.dom.freeze(__('Creating {0}', [doc.doctype]) + '...');
+ // to avoid abrupt UX
+ // wait for activity feedback
+ sleep(500).then(() => {
+ let res = frappe.model.with_doctype(doc.doctype, () => {
+ let newdoc = frappe.model.copy_doc(doc);
+ newdoc.__newname = doc.name;
+ newdoc.idx = null;
+ newdoc.__run_link_triggers = false;
+ frappe.set_route('Form', newdoc.doctype, newdoc.name);
+ frappe.dom.unfreeze();
+ });
+ res && res.fail(frappe.dom.unfreeze);
+ });
+ }
+ } catch (e) {
+ //
+ }
+ });
+ }
});
frappe.get_module = function(m, default_module) {
diff --git a/frappe/public/js/frappe/form/controls/button.js b/frappe/public/js/frappe/form/controls/button.js
index b44c9d9dcd..d09e9c3a95 100644
--- a/frappe/public/js/frappe/form/controls/button.js
+++ b/frappe/public/js/frappe/form/controls/button.js
@@ -6,7 +6,10 @@ frappe.ui.form.ControlButton = frappe.ui.form.ControlData.extend({
make_input: function() {
var me = this;
const btn_type = this.df.primary ? 'btn-primary': 'btn-default';
- this.$input = $(`