diff --git a/.flake8 b/.flake8
index 399b176e1d..56c9b9a369 100644
--- a/.flake8
+++ b/.flake8
@@ -29,4 +29,5 @@ ignore =
B950,
W191,
-max-line-length = 200
\ No newline at end of file
+max-line-length = 200
+exclude=.github/helper/semgrep_rules
diff --git a/.github/helper/semgrep_rules/frappe_correctness.py b/.github/helper/semgrep_rules/frappe_correctness.py
index 37889fbbb1..745e6463b8 100644
--- a/.github/helper/semgrep_rules/frappe_correctness.py
+++ b/.github/helper/semgrep_rules/frappe_correctness.py
@@ -4,25 +4,61 @@ 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'))
- # 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)
+
+# 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/translate.js b/.github/helper/semgrep_rules/translate.js
index 7b92fe2dff..9cdfb75d0b 100644
--- a/.github/helper/semgrep_rules/translate.js
+++ b/.github/helper/semgrep_rules/translate.js
@@ -35,3 +35,10 @@ __('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
index bd6cd9126c..9de6aa94f0 100644
--- a/.github/helper/semgrep_rules/translate.py
+++ b/.github/helper/semgrep_rules/translate.py
@@ -51,3 +51,11 @@ _(f"what" + f"this is also not cool")
_("")
# 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
index 7754b52efc..5f03fb9fd0 100644
--- a/.github/helper/semgrep_rules/translate.yml
+++ b/.github/helper/semgrep_rules/translate.yml
@@ -44,8 +44,8 @@ rules:
pattern-either:
- pattern: _(...) + _(...)
- pattern: _("..." + "...")
- - pattern-regex: '_\([^\)]*\\\s*' # lines broken by `\`
- - pattern-regex: '_\(\s*\n' # line breaks allowed by python for using ( )
+ - 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
@@ -54,8 +54,8 @@ rules:
- id: frappe-translation-js-splitting
pattern-either:
- - pattern-regex: '__\([^\)]*[\+\\]\s*'
- - pattern: __('...' + '...')
+ - pattern-regex: '__\([^\)]*[\\]\s+'
+ - pattern: __('...' + '...', ...)
- pattern: __('...') + __('...')
message: |
Do not split strings inside translate function. Do not concatenate using translate functions.
diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml
index 5092bf4705..389524e968 100644
--- a/.github/workflows/semgrep.yml
+++ b/.github/workflows/semgrep.yml
@@ -4,6 +4,8 @@ on:
pull_request:
branches:
- develop
+ - version-13-hotfix
+ - version-13-pre-release
jobs:
semgrep:
name: Frappe Linter
diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/server-mariadb-tests.yml
similarity index 51%
rename from .github/workflows/ci-tests.yml
rename to .github/workflows/server-mariadb-tests.yml
index d2fbef528b..075b76e8a1 100644
--- a/.github/workflows/ci-tests.yml
+++ b/.github/workflows/server-mariadb-tests.yml
@@ -1,10 +1,8 @@
-name: CI
+name: Server
on:
pull_request:
- types: [opened, synchronize, reopened, labeled, unlabeled]
workflow_dispatch:
- push:
jobs:
test:
@@ -13,23 +11,9 @@ jobs:
strategy:
fail-fast: false
matrix:
- include:
- - DB: "mariadb"
- TYPE: "server"
- JOB_NAME: "Python MariaDB"
- RUN_COMMAND: bench --site test_site run-tests --coverage
+ container: [1, 2]
- - DB: "postgres"
- TYPE: "server"
- JOB_NAME: "Python PostgreSQL"
- RUN_COMMAND: bench --site test_site run-tests --coverage
-
- - DB: "mariadb"
- TYPE: "ui"
- JOB_NAME: "UI MariaDB"
- RUN_COMMAND: bench --site test_site run-ui-tests frappe --headless
-
- name: ${{ matrix.JOB_NAME }}
+ name: Python Unit Tests (MariaDB)
services:
mysql:
@@ -40,18 +24,6 @@ jobs:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
- postgres:
- image: postgres:12.4
- env:
- POSTGRES_PASSWORD: travis
- options: >-
- --health-cmd pg_isready
- --health-interval 10s
- --health-timeout 5s
- --health-retries 5
- ports:
- - 5432:5432
-
steps:
- name: Clone
uses: actions/checkout@v2
@@ -63,7 +35,7 @@ jobs:
- uses: actions/setup-node@v2
with:
- node-version: '12'
+ node-version: '14'
check-latest: true
- name: Add to Hosts
@@ -104,68 +76,54 @@ jobs:
restore-keys: |
${{ runner.os }}-yarn-
- - name: Cache cypress binary
- if: matrix.TYPE == 'ui'
- uses: actions/cache@v2
- with:
- path: ~/.cache
- key: ${{ runner.os }}-cypress-
- restore-keys: |
- ${{ runner.os }}-cypress-
- ${{ runner.os }}-
-
- name: Install Dependencies
run: bash ${GITHUB_WORKSPACE}/.github/helper/install_dependencies.sh
env:
BEFORE: ${{ env.GITHUB_EVENT_PATH.before }}
AFTER: ${{ env.GITHUB_EVENT_PATH.after }}
- TYPE: ${{ matrix.TYPE }}
+ TYPE: server
- name: Install
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
env:
- DB: ${{ matrix.DB }}
- TYPE: ${{ matrix.TYPE }}
+ DB: mariadb
+ TYPE: server
- - name: Run Set-Up
- if: matrix.TYPE == 'ui'
- run: cd ~/frappe-bench/ && bench --site test_site execute frappe.utils.install.complete_setup_wizard
- env:
- DB: ${{ matrix.DB }}
- TYPE: ${{ matrix.TYPE }}
-
- - name: Setup tmate session
- if: contains(github.event.pull_request.labels.*.name, 'debug-gha')
- uses: mxschmitt/action-tmate@v3
- name: Run Tests
- run: cd ~/frappe-bench/ && ${{ matrix.RUN_COMMAND }}
+ run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --use-orchestrator --with-coverage
env:
- DB: ${{ matrix.DB }}
- TYPE: ${{ matrix.TYPE }}
+ CI_BUILD_ID: ${{ github.run_id }}
+ ORCHESTRATOR_URL: http://test-orchestrator.frappe.io
- - name: Coverage - Pull Request
- if: matrix.TYPE == 'server' && github.event_name == 'pull_request'
+ - name: Upload Coverage Data
run: |
cp ~/frappe-bench/sites/.coverage ${GITHUB_WORKSPACE}
cd ${GITHUB_WORKSPACE}
- pip install coveralls==2.2.0
- pip install coverage==4.5.4
- coveralls --service=github
+ 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_SERVICE_NAME: github
-
- - name: Coverage - Push
- if: matrix.TYPE == 'server' && github.event_name == 'push'
+ 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: |
- cp ~/frappe-bench/sites/.coverage ${GITHUB_WORKSPACE}
cd ${GITHUB_WORKSPACE}
- pip install coveralls==2.2.0
- pip install coverage==4.5.4
- coveralls --service=github-actions
+ pip3 install coverage==5.5
+ pip3 install coveralls==3.0.1
+ coveralls --finish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_TOKEN }}
- COVERALLS_SERVICE_NAME: github-actions
diff --git a/.github/workflows/server-postgres-tests.yml b/.github/workflows/server-postgres-tests.yml
new file mode 100644
index 0000000000..4325eebaad
--- /dev/null
+++ b/.github/workflows/server-postgres-tests.yml
@@ -0,0 +1,100 @@
+name: Server
+
+on:
+ pull_request:
+ workflow_dispatch:
+
+jobs:
+ test:
+ runs-on: ubuntu-18.04
+
+ strategy:
+ fail-fast: false
+ matrix:
+ container: [1, 2]
+
+ name: Python Unit Tests (Postgres)
+
+ services:
+ postgres:
+ image: postgres:12.4
+ env:
+ POSTGRES_PASSWORD: travis
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ ports:
+ - 5432:5432
+
+ steps:
+ - name: Clone
+ uses: actions/checkout@v2
+
+ - name: Setup Python
+ uses: actions/setup-python@v2
+ with:
+ python-version: 3.7
+
+ - uses: actions/setup-node@v2
+ with:
+ node-version: '14'
+ check-latest: true
+
+ - name: Add to Hosts
+ 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
+ uses: actions/cache@v2
+ with:
+ path: ~/.cache/pip
+ key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
+ restore-keys: |
+ ${{ runner.os }}-pip-
+ ${{ runner.os }}-
+
+ - name: Cache node modules
+ uses: actions/cache@v2
+ env:
+ cache-name: cache-node-modules
+ with:
+ path: ~/.npm
+ key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
+ restore-keys: |
+ ${{ runner.os }}-build-${{ env.cache-name }}-
+ ${{ runner.os }}-build-
+ ${{ runner.os }}-
+
+ - name: Get yarn cache directory path
+ id: yarn-cache-dir-path
+ run: echo "::set-output name=dir::$(yarn cache dir)"
+
+ - uses: actions/cache@v2
+ id: yarn-cache
+ with:
+ path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
+ key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-yarn-
+
+ - name: Install Dependencies
+ run: bash ${GITHUB_WORKSPACE}/.github/helper/install_dependencies.sh
+ env:
+ BEFORE: ${{ env.GITHUB_EVENT_PATH.before }}
+ AFTER: ${{ env.GITHUB_EVENT_PATH.after }}
+ TYPE: server
+
+ - name: Install
+ 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
+ env:
+ CI_BUILD_ID: ${{ github.run_id }}
+ ORCHESTRATOR_URL: http://test-orchestrator.frappe.io
diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml
new file mode 100644
index 0000000000..9eea128cd1
--- /dev/null
+++ b/.github/workflows/ui-tests.yml
@@ -0,0 +1,105 @@
+name: UI
+
+on:
+ pull_request:
+ workflow_dispatch:
+
+jobs:
+ test:
+ runs-on: ubuntu-18.04
+
+ strategy:
+ fail-fast: false
+ matrix:
+ containers: [1, 2]
+
+ name: UI Tests (Cypress)
+
+ services:
+ mysql:
+ image: mariadb:10.3
+ env:
+ MYSQL_ALLOW_EMPTY_PASSWORD: YES
+ ports:
+ - 3306:3306
+ options: --health-cmd="mysqladmin ping" --health-interval=5s --health-timeout=2s --health-retries=3
+
+ steps:
+ - name: Clone
+ uses: actions/checkout@v2
+
+ - name: Setup Python
+ uses: actions/setup-python@v2
+ with:
+ python-version: 3.7
+
+ - uses: actions/setup-node@v2
+ with:
+ node-version: '12'
+ check-latest: true
+
+ - name: Add to Hosts
+ 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
+ uses: actions/cache@v2
+ with:
+ path: ~/.cache/pip
+ key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
+ restore-keys: |
+ ${{ runner.os }}-pip-
+ ${{ runner.os }}-
+
+ - name: Cache node modules
+ uses: actions/cache@v2
+ env:
+ cache-name: cache-node-modules
+ with:
+ path: ~/.npm
+ key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
+ restore-keys: |
+ ${{ runner.os }}-build-${{ env.cache-name }}-
+ ${{ runner.os }}-build-
+ ${{ runner.os }}-
+
+ - name: Get yarn cache directory path
+ id: yarn-cache-dir-path
+ run: echo "::set-output name=dir::$(yarn cache dir)"
+
+ - uses: actions/cache@v2
+ id: yarn-cache
+ with:
+ path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
+ key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-yarn-
+
+ - name: Cache cypress binary
+ uses: actions/cache@v2
+ with:
+ path: ~/.cache
+ key: ${{ runner.os }}-cypress-
+ restore-keys: |
+ ${{ runner.os }}-cypress-
+ ${{ runner.os }}-
+
+ - name: Install Dependencies
+ run: bash ${GITHUB_WORKSPACE}/.github/helper/install_dependencies.sh
+ env:
+ BEFORE: ${{ env.GITHUB_EVENT_PATH.before }}
+ AFTER: ${{ env.GITHUB_EVENT_PATH.after }}
+ TYPE: ui
+
+ - name: Install
+ run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
+ env:
+ DB: mariadb
+ TYPE: ui
+
+ - name: Site Setup
+ 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
diff --git a/.mergify.yml b/.mergify.yml
index 82f710a5a8..c759c1e3ec 100644
--- a/.mergify.yml
+++ b/.mergify.yml
@@ -3,9 +3,12 @@ pull_request_rules:
conditions:
- status-success=Sider
- status-success=Semantic Pull Request
- - status-success=Python MariaDB
- - status-success=Python PostgreSQL
- - status-success=UI MariaDB
+ - status-success=Python Unit Tests (MariaDB) (1)
+ - status-success=Python Unit Tests (MariaDB) (2)
+ - status-success=Python Unit Tests (Postgres) (1)
+ - status-success=Python Unit Tests (Postgres) (2)
+ - status-success=UI Tests (Cypress) (1)
+ - status-success=UI Tests (Cypress) (2)
- status-success=security/snyk (frappe)
- label!=dont-merge
- label!=squash
@@ -16,9 +19,12 @@ pull_request_rules:
- name: Automatic squash on CI success and review
conditions:
- status-success=Sider
- - status-success=Python MariaDB
- - status-success=Python PostgreSQL
- - status-success=UI MariaDB
+ - status-success=Python Unit Tests (MariaDB) (1)
+ - status-success=Python Unit Tests (MariaDB) (2)
+ - status-success=Python Unit Tests (Postgres) (1)
+ - status-success=Python Unit Tests (Postgres) (2)
+ - status-success=UI Tests (Cypress) (1)
+ - status-success=UI Tests (Cypress) (2)
- status-success=security/snyk (frappe)
- label!=dont-merge
- label=squash
diff --git a/frappe/__init__.py b/frappe/__init__.py
index 2436692c81..02b8d71e40 100644
--- a/frappe/__init__.py
+++ b/frappe/__init__.py
@@ -11,7 +11,6 @@ be used to build database driven apps.
Read the documentation: https://frappeframework.com/docs
"""
-from six import iteritems, binary_type, text_type, string_types
from werkzeug.local import Local, release_local
import os, sys, importlib, inspect, json, warnings
import typing
@@ -91,14 +90,14 @@ def _(msg, lang=None, context=None):
def as_unicode(text, encoding='utf-8'):
'''Convert to unicode if required'''
- if isinstance(text, text_type):
+ if isinstance(text, str):
return text
elif text==None:
return ''
- elif isinstance(text, binary_type):
- return text_type(text, encoding)
+ elif isinstance(text, bytes):
+ return str(text, encoding)
else:
- return text_type(text)
+ return str(text)
def get_lang_dict(fortype, name=None):
"""Returns the translated language dict for the given type and name.
@@ -591,7 +590,7 @@ def is_whitelisted(method):
# strictly sanitize form_dict
# escapes html characters like <> except for predefined tags like a, b, ul etc.
for key, value in form_dict.items():
- if isinstance(value, string_types):
+ if isinstance(value, str):
form_dict[key] = sanitize_html(value)
def read_only():
@@ -715,7 +714,7 @@ def has_website_permission(doc=None, ptype='read', user=None, verbose=False, doc
user = session.user
if doc:
- if isinstance(doc, string_types):
+ if isinstance(doc, str):
doc = get_doc(doctype, doc)
doctype = doc.doctype
@@ -784,7 +783,7 @@ def set_value(doctype, docname, fieldname, value=None):
return frappe.client.set_value(doctype, docname, fieldname, value)
def get_cached_doc(*args, **kwargs):
- if args and len(args) > 1 and isinstance(args[1], text_type):
+ if args and len(args) > 1 and isinstance(args[1], str):
key = get_document_cache_key(args[0], args[1])
# local cache
doc = local.document_cache.get(key)
@@ -815,7 +814,7 @@ def clear_document_cache(doctype, name):
def get_cached_value(doctype, name, fieldname, as_dict=False):
doc = get_cached_doc(doctype, name)
- if isinstance(fieldname, string_types):
+ if isinstance(fieldname, str):
if as_dict:
throw('Cannot make dict for single fieldname')
return doc.get(fieldname)
@@ -1021,7 +1020,7 @@ def get_doc_hooks():
if not hasattr(local, 'doc_events_hooks'):
hooks = get_hooks('doc_events', {})
out = {}
- for key, value in iteritems(hooks):
+ for key, value in hooks.items():
if isinstance(key, tuple):
for doctype in key:
append_hook(out, doctype, value)
@@ -1138,7 +1137,7 @@ def get_file_json(path):
def read_file(path, raise_not_found=False):
"""Open a file and return its content as Unicode."""
- if isinstance(path, text_type):
+ if isinstance(path, str):
path = path.encode("utf-8")
if os.path.exists(path):
@@ -1161,7 +1160,7 @@ def get_attr(method_string):
def call(fn, *args, **kwargs):
"""Call a function and match arguments."""
- if isinstance(fn, string_types):
+ if isinstance(fn, str):
fn = get_attr(fn)
newargs = get_newargs(fn, kwargs)
@@ -1172,13 +1171,9 @@ def get_newargs(fn, kwargs):
if hasattr(fn, 'fnargs'):
fnargs = fn.fnargs
else:
- try:
- fnargs, varargs, varkw, defaults = inspect.getargspec(fn)
- except ValueError:
- fnargs = inspect.getfullargspec(fn).args
- varargs = inspect.getfullargspec(fn).varargs
- varkw = inspect.getfullargspec(fn).varkw
- defaults = inspect.getfullargspec(fn).defaults
+ fnargs = inspect.getfullargspec(fn).args
+ fnargs.extend(inspect.getfullargspec(fn).kwonlyargs)
+ varkw = inspect.getfullargspec(fn).varkw
newargs = {}
for a in kwargs:
@@ -1620,6 +1615,12 @@ def enqueue(*args, **kwargs):
import frappe.utils.background_jobs
return frappe.utils.background_jobs.enqueue(*args, **kwargs)
+def task(**task_kwargs):
+ def decorator_task(f):
+ f.enqueue = lambda **fun_kwargs: enqueue(f, **task_kwargs, **fun_kwargs)
+ return f
+ return decorator_task
+
def enqueue_doc(*args, **kwargs):
'''
Enqueue method to be executed using a background worker
diff --git a/frappe/app.py b/frappe/app.py
index a72f343532..64befdf531 100644
--- a/frappe/app.py
+++ b/frappe/app.py
@@ -99,17 +99,7 @@ def application(request):
frappe.monitor.stop(response)
frappe.recorder.dump()
- if hasattr(frappe.local, 'conf') and frappe.local.conf.enable_frappe_logger:
- frappe.logger("frappe.web", allow_site=frappe.local.site).info({
- "site": get_site_name(request.host),
- "remote_addr": getattr(request, "remote_addr", "NOTFOUND"),
- "base_url": getattr(request, "base_url", "NOTFOUND"),
- "full_path": getattr(request, "full_path", "NOTFOUND"),
- "method": getattr(request, "method", "NOTFOUND"),
- "scheme": getattr(request, "scheme", "NOTFOUND"),
- "http_status_code": getattr(response, "status_code", "NOTFOUND")
- })
-
+ log_request(request, response)
process_response(response)
frappe.destroy()
@@ -137,6 +127,19 @@ def init_request(request):
if request.method != "OPTIONS":
frappe.local.http_request = frappe.auth.HTTPRequest()
+def log_request(request, response):
+ if hasattr(frappe.local, 'conf') and frappe.local.conf.enable_frappe_logger:
+ frappe.logger("frappe.web", allow_site=frappe.local.site).info({
+ "site": get_site_name(request.host),
+ "remote_addr": getattr(request, "remote_addr", "NOTFOUND"),
+ "base_url": getattr(request, "base_url", "NOTFOUND"),
+ "full_path": getattr(request, "full_path", "NOTFOUND"),
+ "method": getattr(request, "method", "NOTFOUND"),
+ "scheme": getattr(request, "scheme", "NOTFOUND"),
+ "http_status_code": getattr(response, "status_code", "NOTFOUND")
+ })
+
+
def process_response(response):
if not response:
return
diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.js b/frappe/automation/doctype/auto_repeat/auto_repeat.js
index 7028ac486d..896a10dfe0 100644
--- a/frappe/automation/doctype/auto_repeat/auto_repeat.js
+++ b/frappe/automation/doctype/auto_repeat/auto_repeat.js
@@ -103,7 +103,7 @@ frappe.ui.form.on('Auto Repeat', {
frappe.auto_repeat.render_schedule = function(frm) {
if (!frm.is_dirty() && frm.doc.status !== 'Disabled') {
frm.call("get_auto_repeat_schedule").then(r => {
- frm.dashboard.wrapper.empty();
+ frm.dashboard.reset();
frm.dashboard.add_section(
frappe.render_template("auto_repeat_schedule", {
schedule_details: r.message || []
diff --git a/frappe/automation/doctype/auto_repeat/test_auto_repeat.py b/frappe/automation/doctype/auto_repeat/test_auto_repeat.py
index f41f31f3bb..6ceb4dba72 100644
--- a/frappe/automation/doctype/auto_repeat/test_auto_repeat.py
+++ b/frappe/automation/doctype/auto_repeat/test_auto_repeat.py
@@ -173,7 +173,7 @@ class TestAutoRepeat(unittest.TestCase):
fields=['docstatus'],
limit=1
)
- self.assertEquals(docnames[0].docstatus, 1)
+ self.assertEqual(docnames[0].docstatus, 1)
def make_auto_repeat(**args):
diff --git a/frappe/build.py b/frappe/build.py
index baedb633b6..321a9bf734 100644
--- a/frappe/build.py
+++ b/frappe/build.py
@@ -1,14 +1,11 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt
-from __future__ import print_function, unicode_literals
-
import os
import re
import json
import shutil
-import warnings
-import tempfile
+from tempfile import mkdtemp, mktemp
from distutils.spawn import find_executable
import frappe
@@ -16,8 +13,8 @@ from frappe.utils.minify import JavascriptMinify
import click
import psutil
-from six import iteritems, text_type
-from six.moves.urllib.parse import urlparse
+from urllib.parse import urlparse
+from simple_chalk import green
timestamps = {}
@@ -75,8 +72,8 @@ def get_assets_link(frappe_head):
from requests import head
tag = getoutput(
- "cd ../apps/frappe && git show-ref --tags -d | grep %s | sed -e 's,.*"
- " refs/tags/,,' -e 's/\^{}//'"
+ r"cd ../apps/frappe && git show-ref --tags -d | grep %s | sed -e 's,.*"
+ r" refs/tags/,,' -e 's/\^{}//'"
% frappe_head
)
@@ -97,9 +94,7 @@ def download_frappe_assets(verbose=True):
commit HEAD.
Returns True if correctly setup else returns False.
"""
- from simple_chalk import green
from subprocess import getoutput
- from tempfile import mkdtemp
assets_setup = False
frappe_head = getoutput("cd ../apps/frappe && git rev-parse HEAD")
@@ -166,7 +161,7 @@ def symlink(target, link_name, overwrite=False):
# Create link to target with temporary filename
while True:
- temp_link_name = tempfile.mktemp(dir=link_dir)
+ temp_link_name = mktemp(dir=link_dir)
# os.* functions mimic as closely as possible system functions
# The POSIX symlink() returns EEXIST if link_name already exists
@@ -193,7 +188,8 @@ def symlink(target, link_name, overwrite=False):
def setup():
- global app_paths
+ global app_paths, assets_path
+
pymodules = []
for app in frappe.get_all_apps(True):
try:
@@ -201,6 +197,7 @@ def setup():
except ImportError:
pass
app_paths = [os.path.dirname(pymodule.__file__) for pymodule in pymodules]
+ assets_path = os.path.join(frappe.local.sites_path, "assets")
def get_node_pacman():
@@ -210,10 +207,10 @@ def get_node_pacman():
raise ValueError("Yarn not found")
-def bundle(no_compress, app=None, make_copy=False, restore=False, verbose=False, skip_frappe=False):
+def bundle(no_compress, app=None, hard_link=False, verbose=False, skip_frappe=False):
"""concat / minify js files"""
setup()
- make_asset_dirs(make_copy=make_copy, restore=restore)
+ make_asset_dirs(hard_link=hard_link)
pacman = get_node_pacman()
mode = "build" if no_compress else "production"
@@ -266,75 +263,101 @@ def get_safe_max_old_space_size():
return safe_max_old_space_size
-def make_asset_dirs(make_copy=False, restore=False):
- # don't even think of making assets_path absolute - rm -rf ahead.
- assets_path = os.path.join(frappe.local.sites_path, "assets")
+def generate_assets_map():
+ symlinks = {}
- for dir_path in [os.path.join(assets_path, "js"), os.path.join(assets_path, "css")]:
- if not os.path.exists(dir_path):
- os.makedirs(dir_path)
+ for app_name in frappe.get_all_apps():
+ app_doc_path = None
- for app_name in frappe.get_all_apps(True):
pymodule = frappe.get_module(app_name)
app_base_path = os.path.abspath(os.path.dirname(pymodule.__file__))
-
- symlinks = []
app_public_path = os.path.join(app_base_path, "public")
- # app/public > assets/app
- symlinks.append([app_public_path, os.path.join(assets_path, app_name)])
- # app/node_modules > assets/app/node_modules
- if os.path.exists(os.path.abspath(app_public_path)):
- symlinks.append(
- [
- os.path.join(app_base_path, "..", "node_modules"),
- os.path.join(assets_path, app_name, "node_modules"),
- ]
- )
+ app_node_modules_path = os.path.join(app_base_path, "..", "node_modules")
+ app_docs_path = os.path.join(app_base_path, "docs")
+ app_www_docs_path = os.path.join(app_base_path, "www", "docs")
- app_doc_path = None
- if os.path.isdir(os.path.join(app_base_path, "docs")):
- app_doc_path = os.path.join(app_base_path, "docs")
+ app_assets = os.path.abspath(app_public_path)
+ app_node_modules = os.path.abspath(app_node_modules_path)
- elif os.path.isdir(os.path.join(app_base_path, "www", "docs")):
- app_doc_path = os.path.join(app_base_path, "www", "docs")
+ # {app}/public > assets/{app}
+ if os.path.isdir(app_assets):
+ symlinks[app_assets] = os.path.join(assets_path, app_name)
+
+ # {app}/node_modules > assets/{app}/node_modules
+ if os.path.isdir(app_node_modules):
+ symlinks[app_node_modules] = os.path.join(assets_path, app_name, "node_modules")
+ # {app}/docs > assets/{app}_docs
+ if os.path.isdir(app_docs_path):
+ app_doc_path = os.path.join(app_base_path, "docs")
+ elif os.path.isdir(app_www_docs_path):
+ app_doc_path = os.path.join(app_base_path, "www", "docs")
if app_doc_path:
- symlinks.append([app_doc_path, os.path.join(assets_path, app_name + "_docs")])
-
- for source, target in symlinks:
- source = os.path.abspath(source)
- if os.path.exists(source):
- if restore:
- if os.path.exists(target):
- if os.path.islink(target):
- os.unlink(target)
- else:
- shutil.rmtree(target)
- shutil.copytree(source, target)
- elif make_copy:
- if os.path.exists(target):
- warnings.warn("Target {target} already exists.".format(target=target))
- else:
- shutil.copytree(source, target)
- else:
- if os.path.exists(target):
- if os.path.islink(target):
- os.unlink(target)
- else:
- shutil.rmtree(target)
- try:
- symlink(source, target, overwrite=True)
- except OSError:
- print("Cannot link {} to {}".format(source, target))
- else:
- # warnings.warn('Source {source} does not exist.'.format(source = source))
- pass
+ app_docs = os.path.abspath(app_doc_path)
+ symlinks[app_docs] = os.path.join(assets_path, app_name + "_docs")
+ return symlinks
+
+
+def setup_assets_dirs():
+ for dir_path in (os.path.join(assets_path, x) for x in ("js", "css")):
+ os.makedirs(dir_path, exist_ok=True)
+
+
+def clear_broken_symlinks():
+ for path in os.listdir(assets_path):
+ path = os.path.join(assets_path, path)
+ if os.path.islink(path) and not os.path.exists(path):
+ os.remove(path)
-def build(no_compress=False, verbose=False):
- assets_path = os.path.join(frappe.local.sites_path, "assets")
- for target, sources in iteritems(get_build_maps()):
+
+def unstrip(message):
+ try:
+ max_str = os.get_terminal_size().columns
+ except Exception:
+ max_str = 80
+ _len = len(message)
+ _rem = max_str - _len
+ return f"{message}{' ' * _rem}"
+
+
+def make_asset_dirs(hard_link=False):
+ setup_assets_dirs()
+ clear_broken_symlinks()
+ symlinks = generate_assets_map()
+
+ for source, target in symlinks.items():
+ start_message = unstrip(f"{'Copying assets from' if hard_link else 'Linking'} {source} to {target}")
+ fail_message = unstrip(f"Cannot {'copy' if hard_link else 'link'} {source} to {target}")
+
+ try:
+ print(start_message, end="\r")
+ link_assets_dir(source, target, hard_link=hard_link)
+ except Exception:
+ print(fail_message, end="\r")
+
+ print(unstrip(f"{green('✔')} Application Assets Linked") + "\n")
+
+
+def link_assets_dir(source, target, hard_link=False):
+ if not os.path.exists(source):
+ return
+
+ if os.path.exists(target):
+ if os.path.islink(target):
+ os.unlink(target)
+ else:
+ shutil.rmtree(target)
+
+ if hard_link:
+ shutil.copytree(source, target, dirs_exist_ok=True)
+ else:
+ symlink(source, target, overwrite=True)
+
+
+def build(no_compress=False, verbose=False):
+ for target, sources in get_build_maps().items():
pack(os.path.join(assets_path, target), sources, no_compress, verbose)
@@ -348,7 +371,7 @@ def get_build_maps():
if os.path.exists(path):
with open(path) as f:
try:
- for target, sources in iteritems(json.loads(f.read())):
+ for target, sources in (json.loads(f.read() or "{}")).items():
# update app path
source_paths = []
for source in sources:
@@ -381,7 +404,7 @@ def pack(target, sources, no_compress, verbose):
timestamps[f] = os.path.getmtime(f)
try:
with open(f, "r") as sourcefile:
- data = text_type(sourcefile.read(), "utf-8", errors="ignore")
+ data = str(sourcefile.read(), "utf-8", errors="ignore")
extn = f.rsplit(".", 1)[1]
@@ -396,7 +419,7 @@ def pack(target, sources, no_compress, verbose):
jsm.minify(tmpin, tmpout)
minified = tmpout.getvalue()
if minified:
- outtxt += text_type(minified or "", "utf-8").strip("\n") + ";"
+ outtxt += str(minified or "", "utf-8").strip("\n") + ";"
if verbose:
print("{0}: {1}k".format(f, int(len(minified) / 1024)))
@@ -426,16 +449,16 @@ def html_to_js_template(path, content):
def scrub_html_template(content):
"""Returns HTML content with removed whitespace and comments"""
# remove whitespace to a single space
- content = re.sub("\s+", " ", content)
+ content = re.sub(r"\s+", " ", content)
# strip comments
- content = re.sub("()", "", content)
+ content = re.sub(r"()", "", content)
return content.replace("'", "\'")
def files_dirty():
- for target, sources in iteritems(get_build_maps()):
+ for target, sources in get_build_maps().items():
for f in sources:
if ":" in f:
f, suffix = f.split(":")
diff --git a/frappe/change_log/v13/v13_3_0.md b/frappe/change_log/v13/v13_3_0.md
new file mode 100644
index 0000000000..6ab181ef09
--- /dev/null
+++ b/frappe/change_log/v13/v13_3_0.md
@@ -0,0 +1,49 @@
+# Version 13.3.0 Release Notes
+
+### Features & Enhancements
+
+- Deletion Steps in Data Deletion Tool ([#13124](https://github.com/frappe/frappe/pull/13124))
+- Format Option for list-apps in bench CLI ([#13125](https://github.com/frappe/frappe/pull/13125))
+- Add password fieldtype option for Web Form ([#13093](https://github.com/frappe/frappe/pull/13093))
+- Add simple __repr__ for DocTypes ([#13151](https://github.com/frappe/frappe/pull/13151))
+- Switch theme with left/right keys ([#13077](https://github.com/frappe/frappe/pull/13077))
+- sourceURL for injected javascript ([#13022](https://github.com/frappe/frappe/pull/13022))
+
+### Fixes
+
+- Decode uri before importing file via weblink ([#13026](https://github.com/frappe/frappe/pull/13026))
+- Respond to /api requests as JSON by default ([#13053](https://github.com/frappe/frappe/pull/13053))
+- Disabled checkbox should be disabled ([#13021](https://github.com/frappe/frappe/pull/13021))
+- Moving Site folder across different FileSystems failed ([#13038](https://github.com/frappe/frappe/pull/13038))
+- Freeze screen till the background request is complete ([#13078](https://github.com/frappe/frappe/pull/13078))
+- Added conditional rendering for content field in split section w… ([#13075](https://github.com/frappe/frappe/pull/13075))
+- Show delete button on portal if user has permission to delete document ([#13149](https://github.com/frappe/frappe/pull/13149))
+- Dont disable dialog scroll on focusing a Link/Autocomplete field ([#13119](https://github.com/frappe/frappe/pull/13119))
+- Typo in RecorderDetail.vue ([#13086](https://github.com/frappe/frappe/pull/13086))
+- Error for bench drop-site. Added missing import. ([#13064](https://github.com/frappe/frappe/pull/13064))
+- Report column context ([#13090](https://github.com/frappe/frappe/pull/13090))
+- Different service name for push and pull request events ([#13094](https://github.com/frappe/frappe/pull/13094))
+- Moving Site folder across different FileSystems failed ([#13033](https://github.com/frappe/frappe/pull/13033))
+- Consistent checkboxes on all browsers ([#13042](https://github.com/frappe/frappe/pull/13042))
+- Changed shorcut widgets color picker to dropdown ([#13073](https://github.com/frappe/frappe/pull/13073))
+- Error while exporting reports with duration field ([#13118](https://github.com/frappe/frappe/pull/13118))
+- Add margin to download backup card ([#13079](https://github.com/frappe/frappe/pull/13079))
+- Move mention list generation logic to server-side ([#13074](https://github.com/frappe/frappe/pull/13074))
+- Make strings translatable ([#13046](https://github.com/frappe/frappe/pull/13046))
+- Don't evaluate dynamic properties to check if conflicts exist ([#13186](https://github.com/frappe/frappe/pull/13186))
+- Add __ function in vue global for translation in recorder ([#13089](https://github.com/frappe/frappe/pull/13089))
+- Make strings translatable ([#13076](https://github.com/frappe/frappe/pull/13076))
+- Show config in bench CLI ([#13128](https://github.com/frappe/frappe/pull/13128))
+- Add breadcrumbs for list view ([#13091](https://github.com/frappe/frappe/pull/13091))
+- Do not skip data in save while using shortcut ([#13182](https://github.com/frappe/frappe/pull/13182))
+- Use docfields from options if no docfields are returned from meta ([#13188](https://github.com/frappe/frappe/pull/13188))
+- Disable reloading files in `__pycache__` directory ([#13109](https://github.com/frappe/frappe/pull/13109))
+- RTL stylesheet route to load RTL style on demand. ([#13007](https://github.com/frappe/frappe/pull/13007))
+- Do not show messsage when exception is handled ([#13111](https://github.com/frappe/frappe/pull/13111))
+- Replace parseFloat by Number ([#13082](https://github.com/frappe/frappe/pull/13082))
+- Add margin to download backup card ([#13050](https://github.com/frappe/frappe/pull/13050))
+- Translate report column labels ([#13083](https://github.com/frappe/frappe/pull/13083))
+- Grid row color picker field not working ([#13040](https://github.com/frappe/frappe/pull/13040))
+- Improve oauthlib implementation ([#13045](https://github.com/frappe/frappe/pull/13045))
+- Replace filter_by like with full text filter ([#13126](https://github.com/frappe/frappe/pull/13126))
+- Focus jumps to first field ([#13067](https://github.com/frappe/frappe/pull/13067))
\ No newline at end of file
diff --git a/frappe/commands/__init__.py b/frappe/commands/__init__.py
index 61ee62d352..e521acc9ad 100644
--- a/frappe/commands/__init__.py
+++ b/frappe/commands/__init__.py
@@ -28,6 +28,10 @@ def pass_context(f):
except frappe.exceptions.SiteNotSpecifiedError as e:
click.secho(str(e), fg='yellow')
sys.exit(1)
+ except frappe.exceptions.IncorrectSitePath:
+ site = ctx.obj.get("sites", "")[0]
+ click.secho(f'Site {site} does not exist!', fg='yellow')
+ sys.exit(1)
if profile:
pr.disable()
diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py
index b917126696..1ee2a7ec00 100644
--- a/frappe/commands/utils.py
+++ b/frappe/commands/utils.py
@@ -16,13 +16,13 @@ from frappe.utils import get_bench_path, update_progress_bar, cint
@click.command('build')
@click.option('--app', help='Build assets for app')
-@click.option('--make-copy', is_flag=True, default=False, help='Copy the files instead of symlinking')
-@click.option('--restore', is_flag=True, default=False, help='Copy the files instead of symlinking with force')
+@click.option('--hard-link', is_flag=True, default=False, help='Copy the files instead of symlinking')
+@click.option('--make-copy', is_flag=True, default=False, help='[DEPRECATED] Copy the files instead of symlinking')
+@click.option('--restore', is_flag=True, default=False, help='[DEPRECATED] Copy the files instead of symlinking with force')
@click.option('--verbose', is_flag=True, default=False, help='Verbose')
@click.option('--force', is_flag=True, default=False, help='Force build assets instead of downloading available')
-def build(app=None, make_copy=False, restore=False, verbose=False, force=False):
+def build(app=None, hard_link=False, make_copy=False, restore=False, verbose=False, force=False):
"Minify + concatenate JS and CSS files, build translations"
- import frappe.build
frappe.init('')
# don't minify in developer_mode for faster builds
no_compress = frappe.local.conf.developer_mode or False
@@ -34,7 +34,20 @@ def build(app=None, make_copy=False, restore=False, verbose=False, force=False):
else:
skip_frappe = False
- frappe.build.bundle(no_compress, app=app, make_copy=make_copy, restore=restore, verbose=verbose, skip_frappe=skip_frappe)
+ if make_copy or restore:
+ hard_link = make_copy or restore
+ click.secho(
+ "bench build: --make-copy and --restore options are deprecated in favour of --hard-link",
+ fg="yellow",
+ )
+
+ frappe.build.bundle(
+ skip_frappe=skip_frappe,
+ no_compress=no_compress,
+ hard_link=hard_link,
+ verbose=verbose,
+ app=app,
+ )
@click.command('watch')
@@ -488,6 +501,8 @@ frappe.db.connect()
@pass_context
def console(context):
"Start ipython console for a site"
+ import warnings
+
site = get_site(context)
frappe.init(site=site)
frappe.connect()
@@ -508,6 +523,7 @@ def console(context):
if failed_to_import:
print("\nFailed to import:\n{}".format(", ".join(failed_to_import)))
+ warnings.simplefilter('ignore')
IPython.embed(display_banner="", header="", colors="neutral")
@@ -585,12 +601,29 @@ def run_tests(context, app=None, module=None, doctype=None, test=(), profile=Fal
if os.environ.get('CI'):
sys.exit(ret)
+@click.command('run-parallel-tests')
+@click.option('--app', help="For App", default='frappe')
+@click.option('--build-number', help="Build number", default=1)
+@click.option('--total-builds', help="Total number of builds", default=1)
+@click.option('--with-coverage', is_flag=True, help="Build coverage file")
+@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)
@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('--ci-build-id')
@pass_context
-def run_ui_tests(context, app, headless=False):
+def run_ui_tests(context, app, headless=False, parallel=True, 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), '..'))
@@ -622,6 +655,12 @@ def run_ui_tests(context, app, headless=False):
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)
+ if parallel:
+ formatted_command += ' --parallel'
+
+ if ci_build_id:
+ formatted_command += ' --ci-build-id {}'.format(ci_build_id)
+
click.secho("Running Cypress...", fg="yellow")
frappe.commands.popen(formatted_command, cwd=app_base_path, raise_err=True)
@@ -797,5 +836,6 @@ commands = [
watch,
bulk_rename,
add_to_email_queue,
- rebuild_global_search
+ rebuild_global_search,
+ run_parallel_tests
]
diff --git a/frappe/contacts/doctype/contact/test_contact.py b/frappe/contacts/doctype/contact/test_contact.py
index 4929873dc4..b131428696 100644
--- a/frappe/contacts/doctype/contact/test_contact.py
+++ b/frappe/contacts/doctype/contact/test_contact.py
@@ -5,7 +5,8 @@ from __future__ import unicode_literals
import frappe
import unittest
-from frappe.exceptions import ValidationError
+
+test_dependencies = ['Contact', 'Salutation']
class TestContact(unittest.TestCase):
@@ -52,4 +53,4 @@ def create_contact(name, salutation, emails=None, phones=None, save=True):
if save:
doc.insert()
- return doc
\ No newline at end of file
+ return doc
diff --git a/frappe/core/doctype/activity_log/test_activity_log.py b/frappe/core/doctype/activity_log/test_activity_log.py
index bd0ea08cc7..f33c7a1c85 100644
--- a/frappe/core/doctype/activity_log/test_activity_log.py
+++ b/frappe/core/doctype/activity_log/test_activity_log.py
@@ -65,12 +65,12 @@ class TestActivityLog(unittest.TestCase):
frappe.local.login_manager = LoginManager()
auth_log = self.get_auth_log()
- self.assertEquals(auth_log.status, 'Success')
+ self.assertEqual(auth_log.status, 'Success')
# test user logout log
frappe.local.login_manager.logout()
auth_log = self.get_auth_log(operation='Logout')
- self.assertEquals(auth_log.status, 'Success')
+ self.assertEqual(auth_log.status, 'Success')
# test invalid login
frappe.form_dict.update({ 'pwd': 'password' })
@@ -90,4 +90,5 @@ class TestActivityLog(unittest.TestCase):
def update_system_settings(args):
doc = frappe.get_doc('System Settings')
doc.update(args)
+ doc.flags.ignore_mandatory = 1
doc.save()
diff --git a/frappe/core/doctype/data_export/exporter.py b/frappe/core/doctype/data_export/exporter.py
index bec8cde7ea..5d600cc0db 100644
--- a/frappe/core/doctype/data_export/exporter.py
+++ b/frappe/core/doctype/data_export/exporter.py
@@ -282,7 +282,7 @@ class DataExporter:
try:
sflags = self.docs_to_export.get("flags", "I,U").upper()
flags = 0
- for a in re.split('\W+',sflags):
+ for a in re.split(r'\W+', sflags):
flags = flags | reflags.get(a,0)
c = re.compile(names, flags)
diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py
index 388d9389f2..d3f981add4 100644
--- a/frappe/core/doctype/data_import/importer.py
+++ b/frappe/core/doctype/data_import/importer.py
@@ -233,7 +233,7 @@ class Importer:
return updated_doc
else:
# throw if no changes
- frappe.throw("No changes to update")
+ frappe.throw(_("No changes to update"))
def get_eta(self, current, total, processing_time):
self.last_eta = getattr(self, "last_eta", 0)
@@ -319,7 +319,7 @@ class ImportFile:
self.warnings = []
self.file_doc = self.file_path = self.google_sheets_url = None
- if isinstance(file, frappe.string_types):
+ if isinstance(file, str):
if frappe.db.exists("File", {"file_url": file}):
self.file_doc = frappe.get_doc("File", {"file_url": file})
elif "docs.google.com/spreadsheets" in file:
@@ -626,7 +626,7 @@ class Row:
return
elif df.fieldtype in ["Date", "Datetime"]:
value = self.get_date(value, col)
- if isinstance(value, frappe.string_types):
+ if isinstance(value, str):
# value was not parsed as datetime object
self.warnings.append(
{
@@ -641,7 +641,7 @@ class Row:
return
elif df.fieldtype == "Duration":
import re
- is_valid_duration = re.match("^(?:(\d+d)?((^|\s)\d+h)?((^|\s)\d+m)?((^|\s)\d+s)?)$", value)
+ is_valid_duration = re.match(r"^(?:(\d+d)?((^|\s)\d+h)?((^|\s)\d+m)?((^|\s)\d+s)?)$", value)
if not is_valid_duration:
self.warnings.append(
{
@@ -929,10 +929,7 @@ class Column:
self.warnings.append(
{
"col": self.column_number,
- "message": _(
- "Date format could not be determined from the values in"
- " this column. Defaulting to yyyy-mm-dd."
- ),
+ "message": _("Date format could not be determined from the values in this column. Defaulting to yyyy-mm-dd."),
"type": "info",
}
)
diff --git a/frappe/core/doctype/docshare/test_docshare.py b/frappe/core/doctype/docshare/test_docshare.py
index d4ef1f92f8..9c424eb4d7 100644
--- a/frappe/core/doctype/docshare/test_docshare.py
+++ b/frappe/core/doctype/docshare/test_docshare.py
@@ -7,6 +7,8 @@ import frappe.share
import unittest
from frappe.automation.doctype.auto_repeat.test_auto_repeat import create_submittable_doctype
+test_dependencies = ['User']
+
class TestDocShare(unittest.TestCase):
def setUp(self):
self.user = "test@example.com"
@@ -112,4 +114,4 @@ class TestDocShare(unittest.TestCase):
self.assertTrue(frappe.has_permission(doctype, "read", doc=submittable_doc.name, user=self.user))
self.assertTrue(frappe.has_permission(doctype, "write", doc=submittable_doc.name, user=self.user))
- frappe.share.remove(doctype, submittable_doc.name, self.user)
\ No newline at end of file
+ frappe.share.remove(doctype, submittable_doc.name, self.user)
diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py
index 3588cc553a..84673f990a 100644
--- a/frappe/core/doctype/doctype/doctype.py
+++ b/frappe/core/doctype/doctype/doctype.py
@@ -83,12 +83,61 @@ class DocType(Document):
if not self.is_new():
self.before_update = frappe.get_doc('DocType', self.name)
self.setup_fields_to_fetch()
+ self.validate_field_name_conflicts()
check_email_append_to(self)
if self.default_print_format and not self.custom:
frappe.throw(_('Standard DocType cannot have default print format, use Customize Form'))
+ if frappe.conf.get('developer_mode'):
+ self.owner = 'Administrator'
+ self.modified_by = 'Administrator'
+
+ def validate_field_name_conflicts(self):
+ """Check if field names dont conflict with controller properties and methods"""
+ core_doctypes = [
+ "Custom DocPerm",
+ "DocPerm",
+ "Custom Field",
+ "Customize Form Field",
+ "DocField",
+ ]
+
+ if self.name in core_doctypes:
+ return
+
+ from frappe.model.base_document import get_controller
+
+ try:
+ controller = get_controller(self.name)
+ except ImportError:
+ controller = Document
+
+ available_objects = {x for x in dir(controller) if isinstance(x, str)}
+ property_set = {
+ x for x in available_objects if isinstance(getattr(controller, x, None), property)
+ }
+ method_set = {
+ x for x in available_objects if x not in property_set and callable(getattr(controller, x, None))
+ }
+
+ for docfield in self.get("fields") or []:
+ conflict_type = None
+ field = docfield.fieldname
+ field_label = docfield.label or docfield.fieldname
+
+ if docfield.fieldname in method_set:
+ conflict_type = "controller method"
+ if docfield.fieldname in property_set:
+ conflict_type = "class property"
+
+ if conflict_type:
+ frappe.throw(
+ _("Fieldname '{0}' conflicting with a {1} of the name {2} in {3}")
+ .format(field_label, conflict_type, field, self.name)
+ )
+
def after_insert(self):
# clear user cache so that on the next reload this doctype is included in boot
clear_user_cache(frappe.session.user)
@@ -622,12 +671,12 @@ class DocType(Document):
flags = {"flags": re.ASCII} if six.PY3 else {}
# a DocType name should not start or end with an empty space
- if re.search("^[ \t\n\r]+|[ \t\n\r]+$", name, **flags):
+ if re.search(r"^[ \t\n\r]+|[ \t\n\r]+$", name, **flags):
frappe.throw(_("DocType's name should not start or end with whitespace"), frappe.NameError)
# a DocType's name should not start with a number or underscore
# and should only contain letters, numbers and underscore
- if not re.match("^(?![\W])[^\d_\s][\w ]+$", name, **flags):
+ if not re.match(r"^(?![\W])[^\d_\s][\w ]+$", name, **flags):
frappe.throw(_("DocType's name should start with a letter and it can only consist of letters, numbers, spaces and underscores"), frappe.NameError)
validate_route_conflict(self.doctype, self.name)
@@ -915,7 +964,7 @@ def validate_fields(meta):
for field in depends_on_fields:
depends_on = docfield.get(field, None)
if depends_on and ("=" in depends_on) and \
- re.match("""[\w\.:_]+\s*={1}\s*[\w\.@'"]+""", depends_on):
+ re.match(r'[\w\.:_]+\s*={1}\s*[\w\.@\'"]+', depends_on):
frappe.throw(_("Invalid {0} condition").format(frappe.unscrub(field)), frappe.ValidationError)
def check_table_multiselect_option(docfield):
@@ -1174,11 +1223,19 @@ def make_module_and_roles(doc, perm_fieldname="permissions"):
else:
raise
-def check_if_fieldname_conflicts_with_methods(doctype, fieldname):
- doc = frappe.get_doc({"doctype": doctype})
- method_list = [method for method in dir(doc) if isinstance(method, str) and callable(getattr(doc, method))]
+def check_fieldname_conflicts(doctype, fieldname):
+ """Checks if fieldname conflicts with methods or properties"""
- if fieldname in method_list:
+ doc = frappe.get_doc({"doctype": doctype})
+ available_objects = [x for x in dir(doc) if isinstance(x, str)]
+ property_list = [
+ x for x in available_objects if isinstance(getattr(type(doc), x, None), property)
+ ]
+ method_list = [
+ x for x in available_objects if x not in property_list and callable(getattr(doc, x))
+ ]
+
+ if fieldname in method_list + property_list:
frappe.throw(_("Fieldname {0} conflicting with meta object").format(fieldname))
def clear_linked_doctype_cache():
diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py
index bfa9d0ec8a..9c492d2c36 100644
--- a/frappe/core/doctype/doctype/test_doctype.py
+++ b/frappe/core/doctype/doctype/test_doctype.py
@@ -92,7 +92,7 @@ class TestDocType(unittest.TestCase):
fields=["parent", "depends_on", "collapsible_depends_on", "mandatory_depends_on",\
"read_only_depends_on", "fieldname", "fieldtype"])
- pattern = """[\w\.:_]+\s*={1}\s*[\w\.@'"]+"""
+ pattern = r'[\w\.:_]+\s*={1}\s*[\w\.@\'"]+'
for field in docfields:
for depends_on in ["depends_on", "collapsible_depends_on", "mandatory_depends_on", "read_only_depends_on"]:
condition = field.get(depends_on)
@@ -517,4 +517,4 @@ def new_doctype(name, unique=0, depends_on='', fields=None):
for f in fields:
doc.append('fields', f)
- return doc
\ No newline at end of file
+ return doc
diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py
index 017106e6f5..c4c37e6d13 100755
--- a/frappe/core/doctype/file/file.py
+++ b/frappe/core/doctype/file/file.py
@@ -498,7 +498,7 @@ class File(Document):
self.file_size = self.check_max_file_size()
if (
- self.content_type and "image" in self.content_type
+ self.content_type and self.content_type == "image/jpeg"
and frappe.get_system_settings("strip_exif_metadata_from_uploaded_images")
):
self.content = strip_exif_data(self.content, self.content_type)
@@ -912,7 +912,7 @@ def extract_images_from_html(doc, content):
return ']*src\s*=\s*["\'](?=data:)(.*?)["\']', _save_file, content)
+ content = re.sub(r'
]*src\s*=\s*["\'](?=data:)(.*?)["\']', _save_file, content)
return content
diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py
index 2f8f437fc9..2596fe94d0 100644
--- a/frappe/core/doctype/file/test_file.py
+++ b/frappe/core/doctype/file/test_file.py
@@ -193,6 +193,7 @@ class TestSameContent(unittest.TestCase):
class TestFile(unittest.TestCase):
def setUp(self):
+ frappe.set_user('Administrator')
self.delete_test_data()
self.upload_file()
diff --git a/frappe/core/doctype/report/test_report.py b/frappe/core/doctype/report/test_report.py
index 9c76c839f3..d09799ca69 100644
--- a/frappe/core/doctype/report/test_report.py
+++ b/frappe/core/doctype/report/test_report.py
@@ -106,7 +106,7 @@ class TestReport(unittest.TestCase):
else:
report = frappe.get_doc('Report', 'Test Report')
- self.assertNotEquals(report.is_permitted(), True)
+ self.assertNotEqual(report.is_permitted(), True)
frappe.set_user('Administrator')
# test for the `_format` method if report data doesn't have sort_by parameter
diff --git a/frappe/core/doctype/role_profile/test_role_profile.py b/frappe/core/doctype/role_profile/test_role_profile.py
index 624b85c315..975453e8d1 100644
--- a/frappe/core/doctype/role_profile/test_role_profile.py
+++ b/frappe/core/doctype/role_profile/test_role_profile.py
@@ -5,6 +5,8 @@ from __future__ import unicode_literals
import frappe
import unittest
+test_dependencies = ['Role']
+
class TestRoleProfile(unittest.TestCase):
def test_make_new_role_profile(self):
new_role_profile = frappe.get_doc(dict(doctype='Role Profile', role_profile='Test 1')).insert()
@@ -21,4 +23,4 @@ class TestRoleProfile(unittest.TestCase):
# clear roles
new_role_profile.roles = []
new_role_profile.save()
- self.assertEqual(new_role_profile.roles, [])
\ No newline at end of file
+ self.assertEqual(new_role_profile.roles, [])
diff --git a/frappe/core/doctype/system_settings/system_settings.py b/frappe/core/doctype/system_settings/system_settings.py
index d102526a9e..05aaca81de 100644
--- a/frappe/core/doctype/system_settings/system_settings.py
+++ b/frappe/core/doctype/system_settings/system_settings.py
@@ -42,7 +42,7 @@ class SystemSettings(Document):
def on_update(self):
for df in self.meta.get("fields"):
- if df.fieldtype not in no_value_fields:
+ if df.fieldtype not in no_value_fields and self.has_value_changed(df.fieldname):
frappe.db.set_default(df.fieldname, self.get(df.fieldname))
if self.language:
diff --git a/frappe/core/doctype/user_permission/test_user_permission.py b/frappe/core/doctype/user_permission/test_user_permission.py
index 2e9b832acc..47651fee72 100644
--- a/frappe/core/doctype/user_permission/test_user_permission.py
+++ b/frappe/core/doctype/user_permission/test_user_permission.py
@@ -46,7 +46,7 @@ class TestUserPermission(unittest.TestCase):
frappe.set_user('test_user_perm1@example.com')
doc = frappe.new_doc("Blog Post")
- self.assertEquals(doc.blog_category, 'general')
+ self.assertEqual(doc.blog_category, 'general')
frappe.set_user('Administrator')
def test_apply_to_all(self):
@@ -54,7 +54,7 @@ class TestUserPermission(unittest.TestCase):
user = create_user('test_bulk_creation_update@example.com')
param = get_params(user, 'User', user.name)
is_created = add_user_permissions(param)
- self.assertEquals(is_created, 1)
+ self.assertEqual(is_created, 1)
def test_for_apply_to_all_on_update_from_apply_all(self):
user = create_user('test_bulk_creation_update@example.com')
@@ -63,11 +63,11 @@ class TestUserPermission(unittest.TestCase):
# Initially create User Permission document with apply_to_all checked
is_created = add_user_permissions(param)
- self.assertEquals(is_created, 1)
+ self.assertEqual(is_created, 1)
is_created = add_user_permissions(param)
# User Permission should not be changed
- self.assertEquals(is_created, 0)
+ self.assertEqual(is_created, 0)
def test_for_applicable_on_update_from_apply_to_all(self):
''' Update User Permission from all to some applicable Doctypes'''
@@ -77,7 +77,7 @@ class TestUserPermission(unittest.TestCase):
# Initially create User Permission document with apply_to_all checked
is_created = add_user_permissions(get_params(user, 'User', user.name))
- self.assertEquals(is_created, 1)
+ self.assertEqual(is_created, 1)
is_created = add_user_permissions(param)
frappe.db.commit()
@@ -92,7 +92,7 @@ class TestUserPermission(unittest.TestCase):
# Check that User Permissions for applicable is created
self.assertIsNotNone(is_created_applicable_first)
self.assertIsNotNone(is_created_applicable_second)
- self.assertEquals(is_created, 1)
+ self.assertEqual(is_created, 1)
def test_for_apply_to_all_on_update_from_applicable(self):
''' Update User Permission from some to all applicable Doctypes'''
@@ -102,7 +102,7 @@ class TestUserPermission(unittest.TestCase):
# create User permissions that with applicable
is_created = add_user_permissions(get_params(user, 'User', user.name, applicable = ["Chat Room", "Chat Message"]))
- self.assertEquals(is_created, 1)
+ 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))
@@ -115,7 +115,7 @@ class TestUserPermission(unittest.TestCase):
# Check that all User Permission with applicable is removed
self.assertIsNone(removed_applicable_first)
self.assertIsNone(removed_applicable_second)
- self.assertEquals(is_created, 1)
+ self.assertEqual(is_created, 1)
def test_user_perm_for_nested_doctype(self):
"""Test if descendants' visibility is controlled for a nested DocType."""
@@ -183,7 +183,7 @@ class TestUserPermission(unittest.TestCase):
# User perm is created on ToDo but for doctype Assignment Rule only
# it should not have impact on Doc A
- self.assertEquals(new_doc.doc, "ToDo")
+ self.assertEqual(new_doc.doc, "ToDo")
frappe.set_user('Administrator')
remove_applicable(["Assignment Rule"], "new_doc_test@example.com", "DocType", "ToDo")
@@ -228,7 +228,7 @@ class TestUserPermission(unittest.TestCase):
# User perm is created on ToDo but for doctype Assignment Rule only
# it should not have impact on Doc A
- self.assertEquals(new_doc.doc, "ToDo")
+ self.assertEqual(new_doc.doc, "ToDo")
frappe.set_user('Administrator')
clear_session_defaults()
diff --git a/frappe/core/doctype/user_permission/user_permission.py b/frappe/core/doctype/user_permission/user_permission.py
index fbc788f6bf..fec5019ca9 100644
--- a/frappe/core/doctype/user_permission/user_permission.py
+++ b/frappe/core/doctype/user_permission/user_permission.py
@@ -191,7 +191,7 @@ def clear_user_permissions(user, for_doctype):
def add_user_permissions(data):
''' Add and update the user permissions '''
frappe.only_for('System Manager')
- if isinstance(data, frappe.string_types):
+ if isinstance(data, str):
data = json.loads(data)
data = frappe._dict(data)
diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py
index 3126326636..39aff8b4a7 100644
--- a/frappe/custom/doctype/custom_field/custom_field.py
+++ b/frappe/custom/doctype/custom_field/custom_field.py
@@ -64,18 +64,19 @@ class CustomField(Document):
self.translatable = 0
if not self.flags.ignore_validate:
- from frappe.core.doctype.doctype.doctype import check_if_fieldname_conflicts_with_methods
- check_if_fieldname_conflicts_with_methods(self.dt, self.fieldname)
+ from frappe.core.doctype.doctype.doctype import check_fieldname_conflicts
+ check_fieldname_conflicts(self.dt, self.fieldname)
def on_update(self):
- frappe.clear_cache(doctype=self.dt)
+ if not frappe.flags.in_setup_wizard:
+ frappe.clear_cache(doctype=self.dt)
if not self.flags.ignore_validate:
# validate field
from frappe.core.doctype.doctype.doctype import validate_fields_for_doctype
validate_fields_for_doctype(self.dt)
# update the schema
- if not frappe.db.get_value('DocType', self.dt, 'issingle'):
+ if not frappe.db.get_value('DocType', self.dt, 'issingle') and not frappe.flags.in_setup_wizard:
frappe.db.updatedb(self.dt)
def on_trash(self):
@@ -144,6 +145,10 @@ def create_custom_fields(custom_fields, ignore_validate = False, update=True):
'''Add / update multiple custom fields
:param custom_fields: example `{'Sales Invoice': [dict(fieldname='test')]}`'''
+
+ if not ignore_validate and frappe.flags.in_setup_wizard:
+ ignore_validate = True
+
for doctype, fields in custom_fields.items():
if isinstance(fields, dict):
# only one field
@@ -163,6 +168,10 @@ def create_custom_fields(custom_fields, ignore_validate = False, update=True):
custom_field.update(df)
custom_field.save()
+ frappe.clear_cache(doctype=doctype)
+ frappe.db.updatedb(doctype)
+
+
@frappe.whitelist()
def add_custom_field(doctype, df):
diff --git a/frappe/custom/doctype/customize_form/test_customize_form.py b/frappe/custom/doctype/customize_form/test_customize_form.py
index f5e0371c1f..75555a8205 100644
--- a/frappe/custom/doctype/customize_form/test_customize_form.py
+++ b/frappe/custom/doctype/customize_form/test_customize_form.py
@@ -47,64 +47,64 @@ class TestCustomizeForm(unittest.TestCase):
self.assertEqual(len(d.get("fields")), 0)
d = self.get_customize_form("Event")
- self.assertEquals(d.doc_type, "Event")
- self.assertEquals(len(d.get("fields")), 36)
+ self.assertEqual(d.doc_type, "Event")
+ self.assertEqual(len(d.get("fields")), 36)
d = self.get_customize_form("Event")
- self.assertEquals(d.doc_type, "Event")
+ self.assertEqual(d.doc_type, "Event")
self.assertEqual(len(d.get("fields")),
len(frappe.get_doc("DocType", d.doc_type).fields) + 1)
- self.assertEquals(d.get("fields")[-1].fieldname, "test_custom_field")
- self.assertEquals(d.get("fields", {"fieldname": "event_type"})[0].in_list_view, 1)
+ self.assertEqual(d.get("fields")[-1].fieldname, "test_custom_field")
+ self.assertEqual(d.get("fields", {"fieldname": "event_type"})[0].in_list_view, 1)
return d
def test_save_customization_property(self):
d = self.get_customize_form("Event")
- self.assertEquals(frappe.db.get_value("Property Setter",
+ self.assertEqual(frappe.db.get_value("Property Setter",
{"doc_type": "Event", "property": "allow_copy"}, "value"), None)
d.allow_copy = 1
d.run_method("save_customization")
- self.assertEquals(frappe.db.get_value("Property Setter",
+ self.assertEqual(frappe.db.get_value("Property Setter",
{"doc_type": "Event", "property": "allow_copy"}, "value"), '1')
d.allow_copy = 0
d.run_method("save_customization")
- self.assertEquals(frappe.db.get_value("Property Setter",
+ self.assertEqual(frappe.db.get_value("Property Setter",
{"doc_type": "Event", "property": "allow_copy"}, "value"), None)
def test_save_customization_field_property(self):
d = self.get_customize_form("Event")
- self.assertEquals(frappe.db.get_value("Property Setter",
+ self.assertEqual(frappe.db.get_value("Property Setter",
{"doc_type": "Event", "property": "reqd", "field_name": "repeat_this_event"}, "value"), None)
repeat_this_event_field = d.get("fields", {"fieldname": "repeat_this_event"})[0]
repeat_this_event_field.reqd = 1
d.run_method("save_customization")
- self.assertEquals(frappe.db.get_value("Property Setter",
+ self.assertEqual(frappe.db.get_value("Property Setter",
{"doc_type": "Event", "property": "reqd", "field_name": "repeat_this_event"}, "value"), '1')
repeat_this_event_field = d.get("fields", {"fieldname": "repeat_this_event"})[0]
repeat_this_event_field.reqd = 0
d.run_method("save_customization")
- self.assertEquals(frappe.db.get_value("Property Setter",
+ self.assertEqual(frappe.db.get_value("Property Setter",
{"doc_type": "Event", "property": "reqd", "field_name": "repeat_this_event"}, "value"), None)
def test_save_customization_custom_field_property(self):
d = self.get_customize_form("Event")
- self.assertEquals(frappe.db.get_value("Custom Field", "Event-test_custom_field", "reqd"), 0)
+ self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "reqd"), 0)
custom_field = d.get("fields", {"fieldname": "test_custom_field"})[0]
custom_field.reqd = 1
d.run_method("save_customization")
- self.assertEquals(frappe.db.get_value("Custom Field", "Event-test_custom_field", "reqd"), 1)
+ self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "reqd"), 1)
custom_field = d.get("fields", {"is_custom_field": True})[0]
custom_field.reqd = 0
d.run_method("save_customization")
- self.assertEquals(frappe.db.get_value("Custom Field", "Event-test_custom_field", "reqd"), 0)
+ self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "reqd"), 0)
def test_save_customization_new_field(self):
d = self.get_customize_form("Event")
@@ -115,14 +115,14 @@ class TestCustomizeForm(unittest.TestCase):
"is_custom_field": 1
})
d.run_method("save_customization")
- self.assertEquals(frappe.db.get_value("Custom Field",
+ self.assertEqual(frappe.db.get_value("Custom Field",
"Event-test_add_custom_field_via_customize_form", "fieldtype"), "Data")
- self.assertEquals(frappe.db.get_value("Custom Field",
+ self.assertEqual(frappe.db.get_value("Custom Field",
"Event-test_add_custom_field_via_customize_form", 'insert_after'), last_fieldname)
frappe.delete_doc("Custom Field", "Event-test_add_custom_field_via_customize_form")
- self.assertEquals(frappe.db.get_value("Custom Field",
+ self.assertEqual(frappe.db.get_value("Custom Field",
"Event-test_add_custom_field_via_customize_form"), None)
@@ -142,7 +142,7 @@ class TestCustomizeForm(unittest.TestCase):
d.doc_type = "Event"
d.run_method('reset_to_defaults')
- self.assertEquals(d.get("fields", {"fieldname": "repeat_this_event"})[0].in_list_view, 0)
+ self.assertEqual(d.get("fields", {"fieldname": "repeat_this_event"})[0].in_list_view, 0)
frappe.local.test_objects["Property Setter"] = []
make_test_records_for_doctype("Property Setter")
@@ -156,7 +156,7 @@ class TestCustomizeForm(unittest.TestCase):
d = self.get_customize_form("Event")
# don't allow for standard fields
- self.assertEquals(d.get("fields", {"fieldname": "subject"})[0].allow_on_submit or 0, 0)
+ self.assertEqual(d.get("fields", {"fieldname": "subject"})[0].allow_on_submit or 0, 0)
# allow for custom field
self.assertEqual(d.get("fields", {"fieldname": "test_custom_field"})[0].allow_on_submit, 1)
diff --git a/frappe/database/database.py b/frappe/database/database.py
index 58e5c8a46e..c9c1ec3909 100644
--- a/frappe/database/database.py
+++ b/frappe/database/database.py
@@ -858,7 +858,7 @@ class Database(object):
if not datetime:
return '0001-01-01 00:00:00.000000'
- if isinstance(datetime, frappe.string_types):
+ if isinstance(datetime, str):
if ':' not in datetime:
datetime = datetime + ' 00:00:00.000000'
else:
diff --git a/frappe/database/mariadb/database.py b/frappe/database/mariadb/database.py
index 7d1d92408c..879c8394d7 100644
--- a/frappe/database/mariadb/database.py
+++ b/frappe/database/mariadb/database.py
@@ -1,5 +1,3 @@
-import warnings
-
import pymysql
from pymysql.constants import ER, FIELD_TYPE
from pymysql.converters import conversions, escape_string
@@ -55,7 +53,6 @@ class MariaDBDatabase(Database):
}
def get_connection(self):
- warnings.filterwarnings('ignore', category=pymysql.Warning)
usessl = 0
if frappe.conf.db_ssl_ca and frappe.conf.db_ssl_cert and frappe.conf.db_ssl_key:
usessl = 1
diff --git a/frappe/database/postgres/database.py b/frappe/database/postgres/database.py
index 4faea78551..6ac2767a71 100644
--- a/frappe/database/postgres/database.py
+++ b/frappe/database/postgres/database.py
@@ -1,5 +1,3 @@
-from __future__ import unicode_literals
-
import re
import frappe
import psycopg2
@@ -13,9 +11,9 @@ from frappe.database.postgres.schema import PostgresTable
# cast decimals as floats
DEC2FLOAT = psycopg2.extensions.new_type(
- psycopg2.extensions.DECIMAL.values,
- 'DEC2FLOAT',
- lambda value, curs: float(value) if value is not None else None)
+ psycopg2.extensions.DECIMAL.values,
+ 'DEC2FLOAT',
+ lambda value, curs: float(value) if value is not None else None)
psycopg2.extensions.register_type(DEC2FLOAT)
@@ -65,7 +63,6 @@ class PostgresDatabase(Database):
}
def get_connection(self):
- # warnings.filterwarnings('ignore', category=psycopg2.Warning)
conn = psycopg2.connect("host='{}' dbname='{}' user='{}' password='{}' port={}".format(
self.host, self.user, self.user, self.password, self.port
))
@@ -114,7 +111,7 @@ class PostgresDatabase(Database):
if not date:
return '0001-01-01'
- if not isinstance(date, frappe.string_types):
+ if not isinstance(date, str):
date = date.strftime('%Y-%m-%d')
return date
diff --git a/frappe/desk/doctype/notification_log/notification_log.py b/frappe/desk/doctype/notification_log/notification_log.py
index 20551559fd..25af92f532 100644
--- a/frappe/desk/doctype/notification_log/notification_log.py
+++ b/frappe/desk/doctype/notification_log/notification_log.py
@@ -46,7 +46,7 @@ def enqueue_create_notification(users, doc):
doc = frappe._dict(doc)
- if isinstance(users, frappe.string_types):
+ if isinstance(users, str):
users = [user.strip() for user in users.split(',') if user.strip()]
users = list(set(users))
diff --git a/frappe/desk/doctype/todo/test_todo.py b/frappe/desk/doctype/todo/test_todo.py
index b767fd4aef..de5b6724a6 100644
--- a/frappe/desk/doctype/todo/test_todo.py
+++ b/frappe/desk/doctype/todo/test_todo.py
@@ -9,8 +9,7 @@ from frappe.model.db_query import DatabaseQuery
from frappe.permissions import add_permission, reset_perms
from frappe.core.doctype.doctype.doctype import clear_permissions_cache
-# test_records = frappe.get_test_records('ToDo')
-test_user_records = frappe.get_test_records('User')
+test_dependencies = ['User']
class TestToDo(unittest.TestCase):
def test_delete(self):
@@ -77,7 +76,7 @@ class TestToDo(unittest.TestCase):
frappe.set_user('test4@example.com')
#owner and assigned_by is test4
todo3 = create_new_todo('Test3', 'test4@example.com')
-
+
# user without any role to read or write todo document
self.assertFalse(todo1.has_permission("read"))
self.assertFalse(todo1.has_permission("write"))
diff --git a/frappe/desk/doctype/workspace_link/workspace_link.json b/frappe/desk/doctype/workspace_link/workspace_link.json
index 010fb3f316..53dadad83d 100644
--- a/frappe/desk/doctype/workspace_link/workspace_link.json
+++ b/frappe/desk/doctype/workspace_link/workspace_link.json
@@ -8,13 +8,13 @@
"type",
"label",
"icon",
+ "only_for",
"hidden",
"link_details_section",
"link_type",
"link_to",
"column_break_7",
"dependencies",
- "only_for",
"onboard",
"is_query_report"
],
@@ -84,7 +84,7 @@
{
"fieldname": "only_for",
"fieldtype": "Link",
- "label": "Only for ",
+ "label": "Only for",
"options": "Country"
},
{
@@ -104,7 +104,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2021-01-12 13:13:12.379443",
+ "modified": "2021-05-13 13:10:18.128512",
"modified_by": "Administrator",
"module": "Desk",
"name": "Workspace Link",
diff --git a/frappe/desk/page/setup_wizard/setup_wizard.py b/frappe/desk/page/setup_wizard/setup_wizard.py
index c38cf47626..1ac5279508 100755
--- a/frappe/desk/page/setup_wizard/setup_wizard.py
+++ b/frappe/desk/page/setup_wizard/setup_wizard.py
@@ -124,6 +124,7 @@ def handle_setup_exception(args):
frappe.db.rollback()
if args:
traceback = frappe.get_traceback()
+ print(traceback)
for hook in frappe.get_hooks("setup_wizard_exception"):
frappe.get_attr(hook)(traceback, args)
diff --git a/frappe/email/doctype/document_follow/test_document_follow.py b/frappe/email/doctype/document_follow/test_document_follow.py
index 1ac2d19305..38aa870232 100644
--- a/frappe/email/doctype/document_follow/test_document_follow.py
+++ b/frappe/email/doctype/document_follow/test_document_follow.py
@@ -17,14 +17,14 @@ class TestDocumentFollow(unittest.TestCase):
document_follow.unfollow_document("Event", event_doc.name, user.name)
doc = document_follow.follow_document("Event", event_doc.name, user.name)
- self.assertEquals(doc.user, user.name)
+ self.assertEqual(doc.user, user.name)
document_follow.send_hourly_updates()
email_queue_entry_name = frappe.get_all("Email Queue", limit=1)[0].name
email_queue_entry_doc = frappe.get_doc("Email Queue", email_queue_entry_name)
- self.assertEquals((email_queue_entry_doc.recipients[0].recipient), user.name)
+ self.assertEqual((email_queue_entry_doc.recipients[0].recipient), user.name)
self.assertIn(event_doc.doctype, email_queue_entry_doc.message)
self.assertIn(event_doc.name, email_queue_entry_doc.message)
diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py
index 3aa7c10ea5..36b662bb39 100755
--- a/frappe/email/doctype/email_account/email_account.py
+++ b/frappe/email/doctype/email_account/email_account.py
@@ -35,9 +35,6 @@ OUTGOING_EMAIL_ACCOUNT_MISSING = _("Please setup default Email Account from Setu
class SentEmailInInbox(Exception):
pass
-class InvalidEmailCredentials(frappe.ValidationError):
- pass
-
def cache_email_account(cache_name):
def decorator_cache_email_account(func):
@functools.wraps(func)
@@ -100,9 +97,8 @@ class EmailAccount(Document):
self.get_incoming_server()
self.no_failed = 0
-
if self.enable_outgoing:
- self.check_smtp()
+ self.validate_smtp_conn()
else:
if self.enable_incoming or (self.enable_outgoing and not self.no_smtp_authentication):
frappe.throw(_("Password is required or select Awaiting Password"))
@@ -118,6 +114,13 @@ class EmailAccount(Document):
if self.append_to not in valid_doctypes:
frappe.throw(_("Append To can be one of {0}").format(comma_or(valid_doctypes)))
+ def validate_smtp_conn(self):
+ if not self.smtp_server:
+ frappe.throw(_("SMTP Server is required"))
+
+ server = self.get_smtp_server()
+ return server.session
+
def before_save(self):
messages = []
as_list = 1
@@ -179,24 +182,6 @@ class EmailAccount(Document):
except Exception:
pass
- def check_smtp(self):
- """Checks SMTP settings."""
- if self.enable_outgoing:
- if not self.smtp_server:
- frappe.throw(_("{0} is required").format("SMTP Server"))
-
- server = SMTPServer(
- login = getattr(self, "login_id", None) or self.email_id,
- server=self.smtp_server,
- port=cint(self.smtp_port),
- use_tls=cint(self.use_tls),
- use_ssl=cint(self.use_ssl_for_outgoing)
- )
- if self.password and not self.no_smtp_authentication:
- server.password = self.get_password()
-
- server.sess
-
def get_incoming_server(self, in_receive=False, email_sync_rule="UNSEEN"):
"""Returns logged in POP3/IMAP connection object."""
if frappe.cache().get_value("workers:no-internet") == True:
@@ -259,7 +244,7 @@ class EmailAccount(Document):
return None
elif not in_receive and any(map(lambda t: t in message, auth_error_codes)):
- self.throw_invalid_credentials_exception()
+ SMTPServer.throw_invalid_credentials_exception()
else:
frappe.throw(cstr(e))
@@ -279,20 +264,18 @@ class EmailAccount(Document):
@property
def _password(self):
- raise_exception = not self.no_smtp_authentication
+ raise_exception = not (self.no_smtp_authentication or frappe.flags.in_test)
return self.get_password(raise_exception=raise_exception)
@property
def default_sender(self):
return email.utils.formataddr((self.name, self.get("email_id")))
- @classmethod
- def throw_invalid_credentials_exception(cls):
- frappe.throw(
- _("Incorrect email or password. Please check your login credentials."),
- exc=InvalidEmailCredentials,
- title=_("Invalid Credentials")
- )
+ def is_exists_in_db(self):
+ """Some of the Email Accounts we create from configs and those doesn't exists in DB.
+ This is is to check the specific email account exists in DB or not.
+ """
+ return self.find_one_by_filters(name=self.name)
@classmethod
def from_record(cls, record):
@@ -402,6 +385,20 @@ class EmailAccount(Document):
account_details[doc_field_name] = (value and value[0]) or default
return account_details
+ def sendmail_config(self):
+ return {
+ 'server': self.smtp_server,
+ 'port': cint(self.smtp_port),
+ 'login': getattr(self, "login_id", None) or self.email_id,
+ 'password': self._password,
+ 'use_ssl': cint(self.use_ssl_for_outgoing),
+ 'use_tls': cint(self.use_tls)
+ }
+
+ def get_smtp_server(self):
+ config = self.sendmail_config()
+ return SMTPServer(**config)
+
def handle_incoming_connect_error(self, description):
if test_internet():
if self.get_failed_attempts_count() > 2:
diff --git a/frappe/email/doctype/email_queue/email_queue.json b/frappe/email/doctype/email_queue/email_queue.json
index 4529ea8211..f251786c90 100644
--- a/frappe/email/doctype/email_queue/email_queue.json
+++ b/frappe/email/doctype/email_queue/email_queue.json
@@ -24,7 +24,8 @@
"unsubscribe_method",
"expose_recipients",
"attachments",
- "retry"
+ "retry",
+ "email_account"
],
"fields": [
{
@@ -139,13 +140,19 @@
"fieldtype": "Int",
"label": "Retry",
"read_only": 1
+ },
+ {
+ "fieldname": "email_account",
+ "fieldtype": "Link",
+ "label": "Email Account",
+ "options": "Email Account"
}
],
"icon": "fa fa-envelope",
"idx": 1,
"in_create": 1,
"links": [],
- "modified": "2020-07-17 15:58:15.369419",
+ "modified": "2021-04-29 06:33:25.191729",
"modified_by": "Administrator",
"module": "Email",
"name": "Email Queue",
diff --git a/frappe/email/doctype/email_queue/email_queue.py b/frappe/email/doctype/email_queue/email_queue.py
index 267fbdfe9c..076dfc5417 100644
--- a/frappe/email/doctype/email_queue/email_queue.py
+++ b/frappe/email/doctype/email_queue/email_queue.py
@@ -2,15 +2,26 @@
# Copyright (c) 2015, Frappe Technologies and contributors
# For license information, please see license.txt
-from __future__ import unicode_literals
+import traceback
+import json
+
+from rq.timeouts import JobTimeoutException
+import smtplib
+import quopri
+from email.parser import Parser
+
import frappe
-from frappe import _
+from frappe import _, safe_encode, task
from frappe.model.document import Document
-from frappe.email.queue import send_one
-from frappe.utils import now_datetime
-
+from frappe.email.queue import get_unsubcribed_url
+from frappe.email.email_body import add_attachment
+from frappe.utils import cint
+from email.policy import SMTPUTF8
+MAX_RETRY_COUNT = 3
class EmailQueue(Document):
+ DOCTYPE = 'Email Queue'
+
def set_recipients(self, recipients):
self.set("recipients", [])
for r in recipients:
@@ -30,6 +41,241 @@ class EmailQueue(Document):
duplicate.set_recipients(recipients)
return duplicate
+ @classmethod
+ def find(cls, name):
+ return frappe.get_doc(cls.DOCTYPE, name)
+
+ def update_db(self, commit=False, **kwargs):
+ frappe.db.set_value(self.DOCTYPE, self.name, kwargs)
+ if commit:
+ frappe.db.commit()
+
+ def update_status(self, status, commit=False, **kwargs):
+ self.update_db(status = status, commit = commit, **kwargs)
+ if self.communication:
+ communication_doc = frappe.get_doc('Communication', self.communication)
+ communication_doc.set_delivery_status(commit=commit)
+
+ @property
+ def cc(self):
+ return (self.show_as_cc and self.show_as_cc.split(",")) or []
+
+ @property
+ def to(self):
+ return [r.recipient for r in self.recipients if r.recipient not in self.cc]
+
+ @property
+ def attachments_list(self):
+ return json.loads(self.attachments) if self.attachments else []
+
+ def get_email_account(self):
+ from frappe.email.doctype.email_account.email_account import EmailAccount
+
+ if self.email_account:
+ return frappe.get_doc('Email Account', self.email_account)
+
+ return EmailAccount.find_outgoing(
+ match_by_email = self.sender, match_by_doctype = self.reference_doctype)
+
+ def is_to_be_sent(self):
+ return self.status in ['Not Sent','Partially Sent']
+
+ def can_send_now(self):
+ hold_queue = (cint(frappe.defaults.get_defaults().get("hold_queue"))==1)
+ if frappe.are_emails_muted() or not self.is_to_be_sent() or hold_queue:
+ return False
+
+ return True
+
+ def send(self, is_background_task=False):
+ """ Send emails to recipients.
+ """
+ if not self.can_send_now():
+ frappe.db.rollback()
+ return
+
+ with SendMailContext(self, is_background_task) as ctx:
+ message = None
+ for recipient in self.recipients:
+ if not recipient.is_mail_to_be_sent():
+ continue
+
+ message = ctx.build_message(recipient.recipient)
+ if not frappe.flags.in_test:
+ ctx.smtp_session.sendmail(recipient.recipient, self.sender, message)
+ ctx.add_to_sent_list(recipient)
+
+ if frappe.flags.in_test:
+ frappe.flags.sent_mail = message
+ return
+
+ if ctx.email_account_doc.append_emails_to_sent_folder and ctx.sent_to:
+ ctx.email_account_doc.append_email_to_sent_folder(message)
+
+
+@task(queue = 'short')
+def send_mail(email_queue_name, is_background_task=False):
+ """This is equalent to EmqilQueue.send.
+
+ This provides a way to make sending mail as a background job.
+ """
+ record = EmailQueue.find(email_queue_name)
+ record.send(is_background_task=is_background_task)
+
+class SendMailContext:
+ def __init__(self, queue_doc: Document, is_background_task: bool = False):
+ self.queue_doc = queue_doc
+ self.is_background_task = is_background_task
+ self.email_account_doc = queue_doc.get_email_account()
+ self.smtp_server = self.email_account_doc.get_smtp_server()
+ self.sent_to = [rec.recipient for rec in self.queue_doc.recipients if rec.is_main_sent()]
+
+ def __enter__(self):
+ self.queue_doc.update_status(status='Sending', commit=True)
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ exceptions = [
+ smtplib.SMTPServerDisconnected,
+ smtplib.SMTPAuthenticationError,
+ smtplib.SMTPRecipientsRefused,
+ smtplib.SMTPConnectError,
+ smtplib.SMTPHeloError,
+ JobTimeoutException
+ ]
+
+ self.smtp_server.quit()
+ self.log_exception(exc_type, exc_val, exc_tb)
+
+ if exc_type in exceptions:
+ email_status = (self.sent_to and 'Partially Sent') or 'Not Sent'
+ self.queue_doc.update_status(status = email_status, commit = True)
+ elif exc_type:
+ if self.queue_doc.retry < MAX_RETRY_COUNT:
+ update_fields = {'status': 'Not Sent', 'retry': self.queue_doc.retry + 1}
+ else:
+ update_fields = {'status': (self.sent_to and 'Partially Errored') or 'Error'}
+ self.queue_doc.update_status(**update_fields, commit = True)
+ else:
+ email_status = self.is_mail_sent_to_all() and 'Sent'
+ email_status = email_status or (self.sent_to and 'Partially Sent') or 'Not Sent'
+ self.queue_doc.update_status(status = email_status, commit = True)
+
+ def log_exception(self, exc_type, exc_val, exc_tb):
+ if exc_type:
+ traceback_string = "".join(traceback.format_tb(exc_tb))
+ traceback_string += f"\n Queue Name: {self.queue_doc.name}"
+
+ if self.is_background_task:
+ frappe.log_error(title = 'frappe.email.queue.flush', message = traceback_string)
+ else:
+ frappe.log_error(message = traceback_string)
+
+ @property
+ def smtp_session(self):
+ if frappe.flags.in_test:
+ return
+ return self.smtp_server.session
+
+ def add_to_sent_list(self, recipient):
+ # Update recipient status
+ recipient.update_db(status='Sent', commit=True)
+ self.sent_to.append(recipient.recipient)
+
+ def is_mail_sent_to_all(self):
+ return sorted(self.sent_to) == sorted([rec.recipient for rec in self.queue_doc.recipients])
+
+ def get_message_object(self, message):
+ return Parser(policy=SMTPUTF8).parsestr(message)
+
+ def message_placeholder(self, placeholder_key):
+ map = {
+ 'tracker': '',
+ 'unsubscribe_url': '',
+ 'cc': '',
+ 'recipient': '',
+ }
+ return map.get(placeholder_key)
+
+ def build_message(self, recipient_email):
+ """Build message specific to the recipient.
+ """
+ message = self.queue_doc.message
+ if not message:
+ return ""
+
+ message = message.replace(self.message_placeholder('tracker'), self.get_tracker_str())
+ message = message.replace(self.message_placeholder('unsubscribe_url'),
+ self.get_unsubscribe_str(recipient_email))
+ message = message.replace(self.message_placeholder('cc'), self.get_receivers_str())
+ message = message.replace(self.message_placeholder('recipient'),
+ self.get_receipient_str(recipient_email))
+ message = self.include_attachments(message)
+ return message
+
+ def get_tracker_str(self):
+ tracker_url_html = \
+ '
'
+
+ message = ''
+ if frappe.conf.use_ssl and self.queue_doc.track_email_status:
+ message = quopri.encodestring(
+ tracker_url_html.format(frappe.local.site, self.queue_doc.communication).encode()
+ ).decode()
+ return message
+
+ def get_unsubscribe_str(self, recipient_email):
+ unsubscribe_url = ''
+ if self.queue_doc.add_unsubscribe_link and self.queue_doc.reference_doctype:
+ doctype, doc_name = self.queue_doc.reference_doctype, self.queue_doc.reference_name
+ unsubscribe_url = get_unsubcribed_url(doctype, doc_name, recipient_email,
+ self.queue_doc.unsubscribe_method, self.queue_doc.unsubscribe_param)
+
+ return quopri.encodestring(unsubscribe_url.encode()).decode()
+
+ def get_receivers_str(self):
+ message = ''
+ if self.queue_doc.expose_recipients == "footer":
+ to_str = ', '.join(self.queue_doc.to)
+ cc_str = ', '.join(self.queue_doc.cc)
+ message = f"This email was sent to {to_str}"
+ message = message + f" and copied to {cc_str}" if cc_str else message
+ return message
+
+ def get_receipient_str(self, recipient_email):
+ message = ''
+ if self.queue_doc.expose_recipients != "header":
+ message = recipient_email
+ return message
+
+ def include_attachments(self, message):
+ message_obj = self.get_message_object(message)
+ attachments = self.queue_doc.attachments_list
+
+ for attachment in attachments:
+ if attachment.get('fcontent'):
+ continue
+
+ fid = attachment.get("fid")
+ if fid:
+ _file = frappe.get_doc("File", fid)
+ fcontent = _file.get_content()
+ attachment.update({
+ 'fname': _file.file_name,
+ 'fcontent': fcontent,
+ 'parent': message_obj
+ })
+ attachment.pop("fid", None)
+ add_attachment(**attachment)
+
+ elif attachment.get("print_format_attachment") == 1:
+ attachment.pop("print_format_attachment", None)
+ print_format_file = frappe.attach_print(**attachment)
+ print_format_file.update({"parent": message_obj})
+ add_attachment(**print_format_file)
+
+ return safe_encode(message_obj.as_string())
+
@frappe.whitelist()
def retry_sending(name):
doc = frappe.get_doc("Email Queue", name)
@@ -42,7 +288,9 @@ def retry_sending(name):
@frappe.whitelist()
def send_now(name):
- send_one(name, now=True)
+ record = EmailQueue.find(name)
+ if record:
+ record.send()
def on_doctype_update():
"""Add index in `tabCommunication` for `(reference_doctype, reference_name)`"""
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 42956a1180..3f07ec58f3 100644
--- a/frappe/email/doctype/email_queue_recipient/email_queue_recipient.py
+++ b/frappe/email/doctype/email_queue_recipient/email_queue_recipient.py
@@ -7,4 +7,16 @@ import frappe
from frappe.model.document import Document
class EmailQueueRecipient(Document):
- pass
+ DOCTYPE = 'Email Queue Recipient'
+
+ def is_mail_to_be_sent(self):
+ return self.status == 'Not Sent'
+
+ def is_main_sent(self):
+ return self.status == 'Sent'
+
+ def update_db(self, commit=False, **kwargs):
+ frappe.db.set_value(self.DOCTYPE, self.name, kwargs)
+ if commit:
+ frappe.db.commit()
+
diff --git a/frappe/email/doctype/newsletter/newsletter..json b/frappe/email/doctype/newsletter/newsletter..json
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/frappe/email/doctype/notification/notification.json b/frappe/email/doctype/notification/notification.json
index c1c877efd4..8b6900a3c9 100644
--- a/frappe/email/doctype/notification/notification.json
+++ b/frappe/email/doctype/notification/notification.json
@@ -102,7 +102,8 @@
"default": "0",
"fieldname": "is_standard",
"fieldtype": "Check",
- "label": "Is Standard"
+ "label": "Is Standard",
+ "no_copy": 1
},
{
"depends_on": "is_standard",
@@ -281,7 +282,7 @@
"icon": "fa fa-envelope",
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2020-11-24 14:25:43.245677",
+ "modified": "2021-05-04 11:17:11.882314",
"modified_by": "Administrator",
"module": "Email",
"name": "Notification",
diff --git a/frappe/email/doctype/notification/test_notification.py b/frappe/email/doctype/notification/test_notification.py
index 87c4b2527a..31d5d9d1cc 100644
--- a/frappe/email/doctype/notification/test_notification.py
+++ b/frappe/email/doctype/notification/test_notification.py
@@ -7,9 +7,7 @@ import frappe, frappe.utils, frappe.utils.scheduler
from frappe.desk.form import assign_to
import unittest
-test_records = frappe.get_test_records('Notification')
-
-test_dependencies = ["User"]
+test_dependencies = ["User", "Notification"]
class TestNotification(unittest.TestCase):
def setUp(self):
diff --git a/frappe/email/queue.py b/frappe/email/queue.py
index cd984e9bf9..52c91baf1c 100755
--- a/frappe/email/queue.py
+++ b/frappe/email/queue.py
@@ -173,19 +173,19 @@ def add(recipients, sender, subject, **kwargs):
if not email_queue:
email_queue = get_email_queue([r], sender, subject, **kwargs)
if kwargs.get('now'):
- send_one(email_queue.name, now=True)
+ email_queue.send()
else:
duplicate = email_queue.get_duplicate([r])
duplicate.insert(ignore_permissions=True)
if kwargs.get('now'):
- send_one(duplicate.name, now=True)
+ duplicate.send()
frappe.db.commit()
else:
email_queue = get_email_queue(recipients, sender, subject, **kwargs)
if kwargs.get('now'):
- send_one(email_queue.name, now=True)
+ email_queue.send()
def get_email_queue(recipients, sender, subject, **kwargs):
'''Make Email Queue object'''
@@ -237,6 +237,9 @@ def get_email_queue(recipients, sender, subject, **kwargs):
', '.join(mail.recipients), traceback.format_exc()), 'Email Not Sent')
recipients = list(set(recipients + kwargs.get('cc', []) + kwargs.get('bcc', [])))
+ email_account = kwargs.get('email_account')
+ email_account_name = email_account and email_account.is_exists_in_db() and email_account.name
+
e.set_recipients(recipients)
e.reference_doctype = kwargs.get('reference_doctype')
e.reference_name = kwargs.get('reference_name')
@@ -248,8 +251,8 @@ def get_email_queue(recipients, sender, subject, **kwargs):
e.send_after = kwargs.get('send_after')
e.show_as_cc = ",".join(kwargs.get('cc', []))
e.show_as_bcc = ",".join(kwargs.get('bcc', []))
+ e.email_account = email_account_name or None
e.insert(ignore_permissions=True)
-
return e
def get_emails_sent_this_month():
@@ -331,44 +334,25 @@ def return_unsubscribed_page(email, doctype, name):
indicator_color='green')
def flush(from_test=False):
- """flush email queue, every time: called from scheduler"""
- # additional check
-
- auto_commit = not from_test
+ """flush email queue, every time: called from scheduler
+ """
+ from frappe.email.doctype.email_queue.email_queue import send_mail
+ # To avoid running jobs inside unit tests
if frappe.are_emails_muted():
msgprint(_("Emails are muted"))
from_test = True
- smtpserver_dict = frappe._dict()
-
- for email in get_queue():
-
- if cint(frappe.defaults.get_defaults().get("hold_queue"))==1:
- break
-
- if email.name:
- smtpserver = smtpserver_dict.get(email.sender)
- if not smtpserver:
- smtpserver = SMTPServer()
- smtpserver_dict[email.sender] = smtpserver
+ if cint(frappe.defaults.get_defaults().get("hold_queue"))==1:
+ return
- if from_test:
- send_one(email.name, smtpserver, auto_commit)
- else:
- send_one_args = {
- 'email': email.name,
- 'smtpserver': smtpserver,
- 'auto_commit': auto_commit,
- }
- enqueue(
- method = 'frappe.email.queue.send_one',
- queue = 'short',
- **send_one_args
- )
+ for row in get_queue():
+ try:
+ func = send_mail if from_test else send_mail.enqueue
+ is_background_task = not from_test
+ func(email_queue_name = row.name, is_background_task = is_background_task)
+ except Exception:
+ frappe.log_error()
- # NOTE: removing commit here because we pass auto_commit
- # finally:
- # frappe.db.commit()
def get_queue():
return frappe.db.sql('''select
name, sender
@@ -381,213 +365,6 @@ def get_queue():
by priority desc, creation asc
limit 500''', { 'now': now_datetime() }, as_dict=True)
-
-def send_one(email, smtpserver=None, auto_commit=True, now=False):
- '''Send Email Queue with given smtpserver'''
-
- email = frappe.db.sql('''select
- name, status, communication, message, sender, reference_doctype,
- reference_name, unsubscribe_param, unsubscribe_method, expose_recipients,
- show_as_cc, add_unsubscribe_link, attachments, retry
- from
- `tabEmail Queue`
- where
- name=%s
- for update''', email, as_dict=True)
-
- if len(email):
- email = email[0]
- else:
- return
-
- recipients_list = frappe.db.sql('''select name, recipient, status from
- `tabEmail Queue Recipient` where parent=%s''', email.name, as_dict=1)
-
- if frappe.are_emails_muted():
- frappe.msgprint(_("Emails are muted"))
- return
-
- if cint(frappe.defaults.get_defaults().get("hold_queue"))==1 :
- return
-
- if email.status not in ('Not Sent','Partially Sent') :
- # rollback to release lock and return
- frappe.db.rollback()
- return
-
- frappe.db.sql("""update `tabEmail Queue` set status='Sending', modified=%s where name=%s""",
- (now_datetime(), email.name), auto_commit=auto_commit)
-
- if email.communication:
- frappe.get_doc('Communication', email.communication).set_delivery_status(commit=auto_commit)
-
- email_sent_to_any_recipient = None
-
- try:
- message = None
-
- if not frappe.flags.in_test:
- if not smtpserver:
- smtpserver = SMTPServer()
-
- # to avoid always using default email account for outgoing
- if getattr(frappe.local, "outgoing_email_account", None):
- frappe.local.outgoing_email_account = {}
-
- smtpserver.setup_email_account(email.reference_doctype, sender=email.sender)
-
- for recipient in recipients_list:
- if recipient.status != "Not Sent":
- continue
-
- message = prepare_message(email, recipient.recipient, recipients_list)
- if not frappe.flags.in_test:
- smtpserver.sess.sendmail(email.sender, recipient.recipient, message)
-
- recipient.status = "Sent"
- frappe.db.sql("""update `tabEmail Queue Recipient` set status='Sent', modified=%s where name=%s""",
- (now_datetime(), recipient.name), auto_commit=auto_commit)
-
- email_sent_to_any_recipient = any("Sent" == s.status for s in recipients_list)
-
- #if all are sent set status
- if email_sent_to_any_recipient:
- frappe.db.sql("""update `tabEmail Queue` set status='Sent', modified=%s where name=%s""",
- (now_datetime(), email.name), auto_commit=auto_commit)
- else:
- frappe.db.sql("""update `tabEmail Queue` set status='Error', error=%s
- where name=%s""", ("No recipients to send to", email.name), auto_commit=auto_commit)
- if frappe.flags.in_test:
- frappe.flags.sent_mail = message
- return
- if email.communication:
- frappe.get_doc('Communication', email.communication).set_delivery_status(commit=auto_commit)
-
- if smtpserver.append_emails_to_sent_folder and email_sent_to_any_recipient:
- smtpserver.email_account.append_email_to_sent_folder(message)
-
- except (smtplib.SMTPServerDisconnected,
- smtplib.SMTPConnectError,
- smtplib.SMTPHeloError,
- smtplib.SMTPAuthenticationError,
- smtplib.SMTPRecipientsRefused,
- JobTimeoutException):
-
- # bad connection/timeout, retry later
-
- if email_sent_to_any_recipient:
- frappe.db.sql("""update `tabEmail Queue` set status='Partially Sent', modified=%s where name=%s""",
- (now_datetime(), email.name), auto_commit=auto_commit)
- else:
- frappe.db.sql("""update `tabEmail Queue` set status='Not Sent', modified=%s where name=%s""",
- (now_datetime(), email.name), auto_commit=auto_commit)
-
- if email.communication:
- frappe.get_doc('Communication', email.communication).set_delivery_status(commit=auto_commit)
-
- # no need to attempt further
- return
-
- except Exception as e:
- frappe.db.rollback()
-
- if email.retry < 3:
- frappe.db.sql("""update `tabEmail Queue` set status='Not Sent', modified=%s, retry=retry+1 where name=%s""",
- (now_datetime(), email.name), auto_commit=auto_commit)
- else:
- if email_sent_to_any_recipient:
- frappe.db.sql("""update `tabEmail Queue` set status='Partially Errored', error=%s where name=%s""",
- (text_type(e), email.name), auto_commit=auto_commit)
- else:
- frappe.db.sql("""update `tabEmail Queue` set status='Error', error=%s
- where name=%s""", (text_type(e), email.name), auto_commit=auto_commit)
-
- if email.communication:
- frappe.get_doc('Communication', email.communication).set_delivery_status(commit=auto_commit)
-
- if now:
- print(frappe.get_traceback())
- raise e
-
- else:
- # log to Error Log
- frappe.log_error('frappe.email.queue.flush')
-
-def prepare_message(email, recipient, recipients_list):
- message = email.message
- if not message:
- return ""
-
- # Parse "Email Account" from "Email Sender"
- email_account = EmailAccount.find_outgoing(match_by_email=email.sender)
- if frappe.conf.use_ssl and email_account.track_email_status:
- # Using SSL => Publically available domain => Email Read Reciept Possible
- message = message.replace("", quopri.encodestring('
'.format(frappe.local.site, email.communication).encode()).decode())
- else:
- # No SSL => No Email Read Reciept
- message = message.replace("", quopri.encodestring("".encode()).decode())
-
- if email.add_unsubscribe_link and email.reference_doctype: # is missing the check for unsubscribe message but will not add as there will be no unsubscribe url
- unsubscribe_url = get_unsubcribed_url(email.reference_doctype, email.reference_name, recipient,
- email.unsubscribe_method, email.unsubscribe_params)
- message = message.replace("", quopri.encodestring(unsubscribe_url.encode()).decode())
-
- if email.expose_recipients == "header":
- pass
- else:
- if email.expose_recipients == "footer":
- if isinstance(email.show_as_cc, string_types):
- email.show_as_cc = email.show_as_cc.split(",")
- email_sent_to = [r.recipient for r in recipients_list]
- email_sent_cc = ", ".join([e for e in email_sent_to if e in email.show_as_cc])
- email_sent_to = ", ".join([e for e in email_sent_to if e not in email.show_as_cc])
-
- if email_sent_cc:
- email_sent_message = _("This email was sent to {0} and copied to {1}").format(email_sent_to,email_sent_cc)
- else:
- email_sent_message = _("This email was sent to {0}").format(email_sent_to)
- message = message.replace("", quopri.encodestring(email_sent_message.encode()).decode())
-
- message = message.replace("", recipient)
-
- message = (message and message.encode('utf8')) or ''
- message = safe_decode(message)
-
- if PY3:
- from email.policy import SMTPUTF8
- message = Parser(policy=SMTPUTF8).parsestr(message)
- else:
- message = Parser().parsestr(message)
-
- if email.attachments:
- # On-demand attachments
-
- attachments = json.loads(email.attachments)
-
- for attachment in attachments:
- if attachment.get('fcontent'):
- continue
-
- fid = attachment.get("fid")
- if fid:
- _file = frappe.get_doc("File", fid)
- fcontent = _file.get_content()
- attachment.update({
- 'fname': _file.file_name,
- 'fcontent': fcontent,
- 'parent': message
- })
- attachment.pop("fid", None)
- add_attachment(**attachment)
-
- elif attachment.get("print_format_attachment") == 1:
- attachment.pop("print_format_attachment", None)
- print_format_file = frappe.attach_print(**attachment)
- print_format_file.update({"parent": message})
- add_attachment(**print_format_file)
-
- return safe_encode(message.as_string())
-
def clear_outbox(days=None):
"""Remove low priority older than 31 days in Outbox or configured in Log Settings.
Note: Used separate query to avoid deadlock
diff --git a/frappe/email/receive.py b/frappe/email/receive.py
index 949da4a343..6d60007cdb 100644
--- a/frappe/email/receive.py
+++ b/frappe/email/receive.py
@@ -284,7 +284,7 @@ class EmailServer:
flags = []
for flag in imaplib.ParseFlags(flag_string) or []:
- pattern = re.compile("\w+")
+ pattern = re.compile(r"\w+")
match = re.search(pattern, frappe.as_unicode(flag))
flags.append(match.group(0))
@@ -555,7 +555,7 @@ class Email:
def get_thread_id(self):
"""Extract thread ID from `[]`"""
- l = re.findall('(?<=\[)[\w/-]+', self.subject)
+ l = re.findall(r'(?<=\[)[\w/-]+', self.subject)
return l and l[0] or None
diff --git a/frappe/email/smtp.py b/frappe/email/smtp.py
index ca69e621cc..3acb76af23 100644
--- a/frappe/email/smtp.py
+++ b/frappe/email/smtp.py
@@ -9,11 +9,24 @@ import _socket, sys
from frappe import _
from frappe.utils import cint, cstr, parse_addr
+CONNECTION_FAILED = _('Could not connect to outgoing email server')
+AUTH_ERROR_TITLE = _("Invalid Credentials")
+AUTH_ERROR = _("Incorrect email or password. Please check your login credentials.")
+SOCKET_ERROR_TITLE = _("Incorrect Configuration")
+SOCKET_ERROR = _("Invalid Outgoing Mail Server or Port")
+SEND_MAIL_FAILED = _("Unable to send emails at this time")
+EMAIL_ACCOUNT_MISSING = _('Email Account not setup. Please create a new Email Account from Setup > Email > Email Account')
+
+class InvalidEmailCredentials(frappe.ValidationError):
+ pass
+
def send(email, append_to=None, retry=1):
"""Deprecated: Send the message or add it to Outbox Email"""
def _send(retry):
+ from frappe.email.doctype.email_account.email_account import EmailAccount
try:
- smtpserver = SMTPServer(append_to=append_to)
+ email_account = EmailAccount.find_outgoing(match_by_doctype=append_to)
+ smtpserver = email_account.get_smtp_server()
# validate is called in as_string
email_body = email.as_string()
@@ -34,102 +47,80 @@ def send(email, append_to=None, retry=1):
_send(retry)
-
class SMTPServer:
- def __init__(self, login=None, password=None, server=None, port=None, use_tls=None, use_ssl=None, append_to=None):
- # get defaults from mail settings
-
- self._sess = None
- self.email_account = None
- self.server = None
- self.append_emails_to_sent_folder = None
-
- if server:
- self.server = server
- self.port = port
- self.use_tls = cint(use_tls)
- self.use_ssl = cint(use_ssl)
- self.login = login
- self.password = password
-
- else:
- self.setup_email_account(append_to)
-
- def setup_email_account(self, append_to=None, sender=None):
- from frappe.email.doctype.email_account.email_account import EmailAccount
- self.email_account = EmailAccount.find_outgoing(match_by_doctype=append_to, match_by_email=sender)
- if self.email_account:
- self.server = self.email_account.smtp_server
- self.login = (getattr(self.email_account, "login_id", None) or self.email_account.email_id)
- if self.email_account.no_smtp_authentication or frappe.local.flags.in_test:
- self.password = None
- else:
- self.password = self.email_account._password
- self.port = self.email_account.smtp_port
- self.use_tls = self.email_account.use_tls
- self.sender = self.email_account.email_id
- self.use_ssl = self.email_account.use_ssl_for_outgoing
- self.append_emails_to_sent_folder = self.email_account.append_emails_to_sent_folder
- self.always_use_account_email_id_as_sender = cint(self.email_account.get("always_use_account_email_id_as_sender"))
- self.always_use_account_name_as_sender_name = cint(self.email_account.get("always_use_account_name_as_sender_name"))
+ def __init__(self, server, login=None, password=None, port=None, use_tls=None, use_ssl=None):
+ self.login = login
+ self.password = password
+ self._server = server
+ self._port = port
+ self.use_tls = use_tls
+ self.use_ssl = use_ssl
+ self._session = None
+
+ if not self.server:
+ frappe.msgprint(EMAIL_ACCOUNT_MISSING, raise_exception=frappe.OutgoingEmailError)
@property
- def sess(self):
- """get session"""
- if self._sess:
- return self._sess
+ def port(self):
+ port = self._port or (self.use_ssl and 465) or (self.use_tls and 587)
+ return cint(port)
- # check if email server specified
- if not getattr(self, 'server'):
- err_msg = _('Email Account not setup. Please create a new Email Account from Setup > Email > Email Account')
- frappe.msgprint(err_msg)
- raise frappe.OutgoingEmailError(err_msg)
-
- try:
- if self.use_ssl:
- if not self.port:
- self.port = 465
+ @property
+ def server(self):
+ return cstr(self._server or "")
- self._sess = smtplib.SMTP_SSL((self.server or ""), cint(self.port))
- else:
- if self.use_tls and not self.port:
- self.port = 587
+ def secure_session(self, conn):
+ """Secure the connection incase of TLS.
+ """
+ if self.use_tls:
+ conn.ehlo()
+ conn.starttls()
+ conn.ehlo()
- self._sess = smtplib.SMTP(cstr(self.server or ""),
- cint(self.port) or None)
+ @property
+ def session(self):
+ if self.is_session_active():
+ return self._session
- if not self._sess:
- err_msg = _('Could not connect to outgoing email server')
- frappe.msgprint(err_msg)
- raise frappe.OutgoingEmailError(err_msg)
+ SMTP = smtplib.SMTP_SSL if self.use_ssl else smtplib.SMTP
- if self.use_tls:
- self._sess.ehlo()
- self._sess.starttls()
- self._sess.ehlo()
+ try:
+ self._session = SMTP(self.server, self.port)
+ if not self._session:
+ frappe.msgprint(CONNECTION_FAILED, raise_exception=frappe.OutgoingEmailError)
+ self.secure_session(self._session)
if self.login and self.password:
- ret = self._sess.login(str(self.login or ""), str(self.password or ""))
+ res = self._session.login(str(self.login or ""), str(self.password or ""))
# check if logged correctly
- if ret[0]!=235:
- frappe.msgprint(ret[1])
- raise frappe.OutgoingEmailError(ret[1])
+ if res[0]!=235:
+ frappe.msgprint(res[1], raise_exception=frappe.OutgoingEmailError)
- return self._sess
+ return self._session
except smtplib.SMTPAuthenticationError as e:
- from frappe.email.doctype.email_account.email_account import EmailAccount
- EmailAccount.throw_invalid_credentials_exception()
+ self.throw_invalid_credentials_exception()
except _socket.error as e:
# Invalid mail server -- due to refusing connection
- frappe.throw(
- _("Invalid Outgoing Mail Server or Port"),
- exc=frappe.ValidationError,
- title=_("Incorrect Configuration")
- )
+ frappe.throw(SOCKET_ERROR, title=SOCKET_ERROR_TITLE)
except smtplib.SMTPException:
- frappe.msgprint(_('Unable to send emails at this time'))
+ frappe.msgprint(SEND_MAIL_FAILED)
raise
+
+ def is_session_active(self):
+ if self._session:
+ try:
+ return self._session.noop()[0] == 250
+ except Exception:
+ return False
+
+ def quit(self):
+ if self.is_session_active():
+ self._session.quit()
+
+ @classmethod
+ def throw_invalid_credentials_exception(cls):
+ frappe.throw(AUTH_ERROR, title=AUTH_ERROR_TITLE, exc=InvalidEmailCredentials)
diff --git a/frappe/email/test_email_body.py b/frappe/email/test_email_body.py
index 3fcabb9495..33668cddba 100644
--- a/frappe/email/test_email_body.py
+++ b/frappe/email/test_email_body.py
@@ -7,10 +7,10 @@ from frappe import safe_decode
from frappe.email.receive import Email
from frappe.email.email_body import (replace_filename_with_cid,
get_email, inline_style_in_html, get_header)
-from frappe.email.queue import prepare_message, get_email_queue
+from frappe.email.queue import get_email_queue
+from frappe.email.doctype.email_queue.email_queue import SendMailContext
from six import PY3
-
class TestEmailBody(unittest.TestCase):
def setUp(self):
email_html = '''
@@ -57,7 +57,8 @@ This is the text version of this email
content='