diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000000..24f122a8d4
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,14 @@
+# Root editor config file
+root = true
+
+# Common settings
+[*]
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+charset = utf-8
+
+# python, js indentation settings
+[{*.py,*.js}]
+indent_style = tab
+indent_size = 4
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000000..26bb7ab280
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Community Forum
+ url: https://discuss.erpnext.com/
+ about: For general QnA, discussions and community help.
diff --git a/.github/helper/documentation.py b/.github/helper/documentation.py
index 3fc14ba61b..08d1d1aa9c 100644
--- a/.github/helper/documentation.py
+++ b/.github/helper/documentation.py
@@ -21,8 +21,8 @@ def docs_link_exists(body):
if word.startswith('http') and uri_validator(word):
parsed_url = urlparse(word)
if parsed_url.netloc == "github.com":
- _, org, repo, _type, ref = parsed_url.path.split('/')
- if org == "frappe" and repo in docs_repos:
+ parts = parsed_url.path.split('/')
+ if len(parts) == 5 and parts[1] == "frappe" and parts[2] in docs_repos:
return True
diff --git a/frappe/__init__.py b/frappe/__init__.py
index fac0927428..7d15fc716e 100644
--- a/frappe/__init__.py
+++ b/frappe/__init__.py
@@ -945,7 +945,11 @@ def get_installed_apps(sort=False, frappe_last=False):
connect()
if not local.all_apps:
- local.all_apps = get_all_apps(True)
+ local.all_apps = cache().get_value('all_apps', get_all_apps)
+
+ #cache bench apps
+ if not cache().get_value('all_apps'):
+ cache().set_value('all_apps', local.all_apps)
installed = json.loads(db.get_global("installed_apps") or "[]")
diff --git a/frappe/automation/doctype/assignment_rule/assignment_rule.js b/frappe/automation/doctype/assignment_rule/assignment_rule.js
index 774befc15e..ee1a076465 100644
--- a/frappe/automation/doctype/assignment_rule/assignment_rule.js
+++ b/frappe/automation/doctype/assignment_rule/assignment_rule.js
@@ -57,7 +57,8 @@ frappe.ui.form.on('Assignment Rule', {
frm.set_fields_as_options(
'field',
doctype,
- (df) => df.fieldtype == 'Link' && df.options == 'User',
+ (df) => ['Dynamic Link', 'Data'].includes(df.fieldtype)
+ || (df.fieldtype == 'Link' && df.options == 'User'),
[{ label: 'Owner', value: 'owner' }]
);
if (doctype) {
diff --git a/frappe/automation/doctype/assignment_rule/assignment_rule.py b/frappe/automation/doctype/assignment_rule/assignment_rule.py
index c85cb149ea..d20398d564 100644
--- a/frappe/automation/doctype/assignment_rule/assignment_rule.py
+++ b/frappe/automation/doctype/assignment_rule/assignment_rule.py
@@ -82,7 +82,7 @@ class AssignmentRule(Document):
elif self.rule == 'Load Balancing':
return self.get_user_load_balancing()
elif self.rule == 'Based on Field':
- return doc.get(self.field)
+ return self.get_user_based_on_field(doc)
def get_user_round_robin(self):
'''
@@ -119,6 +119,11 @@ class AssignmentRule(Document):
# pick the first user
return sorted_counts[0].get('user')
+ def get_user_based_on_field(self, doc):
+ val = doc.get(self.field)
+ if frappe.db.exists('User', val):
+ return val
+
def safe_eval(self, fieldname, doc):
try:
if self.get(fieldname):
diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.js b/frappe/automation/doctype/auto_repeat/auto_repeat.js
index a11de1d881..121b4bd2f0 100644
--- a/frappe/automation/doctype/auto_repeat/auto_repeat.js
+++ b/frappe/automation/doctype/auto_repeat/auto_repeat.js
@@ -44,6 +44,20 @@ frappe.ui.form.on('Auto Repeat', {
// auto repeat schedule
frappe.auto_repeat.render_schedule(frm);
+
+ frm.trigger('toggle_submit_on_creation');
+ },
+
+ reference_doctype: function(frm) {
+ frm.trigger('toggle_submit_on_creation');
+ },
+
+ toggle_submit_on_creation: function(frm) {
+ // submit on creation checkbox
+ frappe.model.with_doctype(frm.doc.reference_doctype, () => {
+ let meta = frappe.get_meta(frm.doc.reference_doctype);
+ frm.toggle_display('submit_on_creation', meta.is_submittable);
+ });
},
template: function(frm) {
diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.json b/frappe/automation/doctype/auto_repeat/auto_repeat.json
index 8ee6ca1d45..80975dd4f5 100644
--- a/frappe/automation/doctype/auto_repeat/auto_repeat.json
+++ b/frappe/automation/doctype/auto_repeat/auto_repeat.json
@@ -1,4 +1,5 @@
{
+ "actions": [],
"allow_import": 1,
"allow_rename": 1,
"autoname": "format:AUT-AR-{#####}",
@@ -12,6 +13,7 @@
"section_break_3",
"reference_doctype",
"reference_document",
+ "submit_on_creation",
"column_break_5",
"start_date",
"end_date",
@@ -186,9 +188,16 @@
"fieldname": "repeat_on_last_day",
"fieldtype": "Check",
"label": "Repeat on Last Day of the Month"
+ },
+ {
+ "default": "0",
+ "fieldname": "submit_on_creation",
+ "fieldtype": "Check",
+ "label": "Submit on Creation"
}
],
- "modified": "2019-07-17 11:30:51.412317",
+ "links": [],
+ "modified": "2020-12-10 10:43:13.449172",
"modified_by": "Administrator",
"module": "Automation",
"name": "Auto Repeat",
diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.py b/frappe/automation/doctype/auto_repeat/auto_repeat.py
index fcf24bf1a9..31d6539e61 100644
--- a/frappe/automation/doctype/auto_repeat/auto_repeat.py
+++ b/frappe/automation/doctype/auto_repeat/auto_repeat.py
@@ -21,6 +21,7 @@ class AutoRepeat(Document):
def validate(self):
self.update_status()
self.validate_reference_doctype()
+ self.validate_submit_on_creation()
self.validate_dates()
self.validate_email_id()
self.set_dates()
@@ -60,6 +61,11 @@ class AutoRepeat(Document):
if not frappe.get_meta(self.reference_doctype).allow_auto_repeat:
frappe.throw(_("Enable Allow Auto Repeat for the doctype {0} in Customize Form").format(self.reference_doctype))
+ def validate_submit_on_creation(self):
+ if self.submit_on_creation and not frappe.get_meta(self.reference_doctype).is_submittable:
+ frappe.throw(_('Cannot enable {0} for a non-submittable doctype').format(
+ frappe.bold('Submit on Creation')))
+
def validate_dates(self):
if frappe.flags.in_patch:
return
@@ -150,6 +156,9 @@ class AutoRepeat(Document):
self.update_doc(new_doc, reference_doc)
new_doc.insert(ignore_permissions = True)
+ if self.submit_on_creation:
+ new_doc.submit()
+
return new_doc
def update_doc(self, new_doc, reference_doc):
@@ -160,7 +169,7 @@ class AutoRepeat(Document):
if new_doc.meta.get_field('auto_repeat'):
new_doc.set('auto_repeat', self.name)
- for fieldname in ['naming_series', 'ignore_pricing_rule', 'posting_time', 'select_print_heading', 'remarks', 'owner']:
+ for fieldname in ['naming_series', 'ignore_pricing_rule', 'posting_time', 'select_print_heading', 'user_remark', 'remarks', 'owner']:
if new_doc.meta.get_field(fieldname):
new_doc.set(fieldname, reference_doc.get(fieldname))
diff --git a/frappe/automation/doctype/auto_repeat/test_auto_repeat.py b/frappe/automation/doctype/auto_repeat/test_auto_repeat.py
index 60fa9cb59e..e40b12e3b9 100644
--- a/frappe/automation/doctype/auto_repeat/test_auto_repeat.py
+++ b/frappe/automation/doctype/auto_repeat/test_auto_repeat.py
@@ -111,6 +111,25 @@ class TestAutoRepeat(unittest.TestCase):
doc = make_auto_repeat(frequency='Daily', reference_document=todo.name, start_date=add_days(today(), -2))
self.assertEqual(getdate(doc.next_schedule_date), current_date)
+ def test_submit_on_creation(self):
+ doctype = 'Test Submittable DocType'
+ create_submittable_doctype(doctype)
+
+ current_date = getdate()
+ submittable_doc = frappe.get_doc(dict(doctype=doctype, test='test submit on creation')).insert()
+ submittable_doc.submit()
+ doc = make_auto_repeat(frequency='Daily', reference_doctype=doctype, reference_document=submittable_doc.name,
+ start_date=add_days(current_date, -1), submit_on_creation=1)
+
+ data = get_auto_repeat_entries(current_date)
+ create_repeated_entries(data)
+ docnames = frappe.db.get_all(doc.reference_doctype,
+ filters={'auto_repeat': doc.name},
+ fields=['docstatus'],
+ limit=1
+ )
+ self.assertEquals(docnames[0].docstatus, 1)
+
def make_auto_repeat(**args):
args = frappe._dict(args)
@@ -118,6 +137,7 @@ def make_auto_repeat(**args):
'doctype': 'Auto Repeat',
'reference_doctype': args.reference_doctype or 'ToDo',
'reference_document': args.reference_document or frappe.db.get_value('ToDo', 'name'),
+ 'submit_on_creation': args.submit_on_creation or 0,
'frequency': args.frequency or 'Daily',
'start_date': args.start_date or add_days(today(), -1),
'end_date': args.end_date or "",
@@ -128,3 +148,34 @@ def make_auto_repeat(**args):
}).insert(ignore_permissions=True)
return doc
+
+
+def create_submittable_doctype(doctype):
+ if frappe.db.exists('DocType', doctype):
+ return
+ else:
+ doc = frappe.get_doc({
+ 'doctype': 'DocType',
+ '__newname': doctype,
+ 'module': 'Custom',
+ 'custom': 1,
+ 'is_submittable': 1,
+ 'fields': [{
+ 'fieldname': 'test',
+ 'label': 'Test',
+ 'fieldtype': 'Data'
+ }],
+ 'permissions': [{
+ 'role': 'System Manager',
+ 'read': 1,
+ 'write': 1,
+ 'create': 1,
+ 'delete': 1,
+ 'submit': 1,
+ 'cancel': 1,
+ 'amend': 1
+ }]
+ }).insert()
+
+ doc.allow_auto_repeat = 1
+ doc.save()
\ No newline at end of file
diff --git a/frappe/build.py b/frappe/build.py
index f14b250a92..f47a7cb32b 100644
--- a/frappe/build.py
+++ b/frappe/build.py
@@ -105,7 +105,7 @@ def download_frappe_assets(verbose=True):
if frappe_head:
try:
url = get_assets_link(frappe_head)
- click.secho("Retreiving assets...", fg="yellow")
+ click.secho("Retrieving assets...", fg="yellow")
prefix = mkdtemp(prefix="frappe-assets-", suffix=frappe_head)
assets_archive = download_file(url, prefix)
print("\n{0} Downloaded Frappe assets from {1}".format(green('✔'), url))
diff --git a/frappe/chat/util/util.py b/frappe/chat/util/util.py
index 5aa80a85ae..82df6dd127 100644
--- a/frappe/chat/util/util.py
+++ b/frappe/chat/util/util.py
@@ -1,27 +1,21 @@
from __future__ import unicode_literals
+# imports - standard imports
+import json
+from collections.abc import MutableMapping, MutableSequence, Sequence
+
# imports - third-party imports
import requests
-
-# imports - compatibility imports
-import six
-
-# imports - standard imports
-from collections import Sequence, MutableSequence, Mapping, MutableMapping
-if six.PY2:
- from urlparse import urlparse # PY2
-else:
- from urllib.parse import urlparse # PY3
-import json
+from urllib.parse import urlparse
# imports - module imports
-from frappe.model.document import Document
-from frappe.exceptions import DuplicateEntryError
-from frappe import _dict
import frappe
+from frappe.exceptions import DuplicateEntryError
+from frappe.model.document import Document
session = frappe.session
+
def get_user_doc(user = None):
if isinstance(user, Document):
return user
@@ -38,12 +32,12 @@ def squashify(what):
return what
def safe_json_loads(*args):
- results = [ ]
+ results = []
for arg in args:
try:
arg = json.loads(arg)
- except Exception as e:
+ except Exception:
pass
results.append(arg)
@@ -81,7 +75,7 @@ def dictify(arg):
for i, a in enumerate(arg):
arg[i] = dictify(a)
elif isinstance(arg, MutableMapping):
- arg = _dict(arg)
+ arg = frappe._dict(arg)
return arg
@@ -113,4 +107,4 @@ def get_emojis():
emojis = resp.json()
redis.hset('frappe_emojis', 'emojis', emojis)
- return dictify(emojis)
\ No newline at end of file
+ return dictify(emojis)
diff --git a/frappe/commands/site.py b/frappe/commands/site.py
index f8ff07db1d..4a631be3ac 100755
--- a/frappe/commands/site.py
+++ b/frappe/commands/site.py
@@ -9,7 +9,7 @@ import click
import frappe
from frappe.commands import get_site, pass_context
from frappe.exceptions import SiteNotSpecifiedError
-from frappe.utils import get_site_path, touch_file
+from frappe.installer import _new_site
@click.command('new-site')
@@ -42,57 +42,6 @@ def new_site(site, mariadb_root_username=None, mariadb_root_password=None, admin
if len(frappe.utils.get_sites()) == 1:
use(site)
-def _new_site(db_name, site, mariadb_root_username=None, mariadb_root_password=None,
- admin_password=None, verbose=False, install_apps=None, source_sql=None, force=False,
- no_mariadb_socket=False, reinstall=False, db_password=None, db_type=None, db_host=None,
- db_port=None, new_site=False):
- """Install a new Frappe site"""
-
- if not force and os.path.exists(site):
- print('Site {0} already exists'.format(site))
- sys.exit(1)
-
- if no_mariadb_socket and not db_type == "mariadb":
- print('--no-mariadb-socket requires db_type to be set to mariadb.')
- sys.exit(1)
-
- if not db_name:
- import hashlib
- db_name = '_' + hashlib.sha1(site.encode()).hexdigest()[:16]
-
- from frappe.commands.scheduler import _is_scheduler_enabled
- from frappe.installer import install_db, make_site_dirs
- from frappe.installer import install_app as _install_app
- import frappe.utils.scheduler
-
- frappe.init(site=site)
-
- try:
-
- # enable scheduler post install?
- enable_scheduler = _is_scheduler_enabled()
- except Exception:
- enable_scheduler = False
-
- make_site_dirs()
-
- installing = touch_file(get_site_path('locks', 'installing.lock'))
-
- install_db(root_login=mariadb_root_username, root_password=mariadb_root_password, db_name=db_name,
- admin_password=admin_password, verbose=verbose, source_sql=source_sql, force=force, reinstall=reinstall,
- db_password=db_password, db_type=db_type, db_host=db_host, db_port=db_port, no_mariadb_socket=no_mariadb_socket)
- apps_to_install = ['frappe'] + (frappe.conf.get("install_apps") or []) + (list(install_apps) or [])
- for app in apps_to_install:
- _install_app(app, verbose=verbose, set_as_patched=not source_sql)
-
- os.remove(installing)
-
- frappe.utils.scheduler.toggle_scheduler(enable_scheduler)
- frappe.db.commit()
-
- scheduler_status = "disabled" if frappe.utils.scheduler.is_scheduler_disabled() else "enabled"
- print("*** Scheduler is", scheduler_status, "***")
-
@click.command('restore')
@click.argument('sql-file-path')
@@ -103,36 +52,45 @@ def _new_site(db_name, site, mariadb_root_username=None, mariadb_root_password=N
@click.option('--install-app', multiple=True, help='Install app after installation')
@click.option('--with-public-files', help='Restores the public files of the site, given path to its tar file')
@click.option('--with-private-files', help='Restores the private files of the site, given path to its tar file')
-@click.option('--force', is_flag=True, default=False, help='Ignore the site downgrade warning, if applicable')
+@click.option('--force', is_flag=True, default=False, help='Ignore the validations and downgrade warnings. This action is not recommended')
@pass_context
def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_password=None, db_name=None, verbose=None, install_app=None, admin_password=None, force=None, with_public_files=None, with_private_files=None):
"Restore site database from an sql file"
- from frappe.installer import extract_sql_gzip, extract_files, is_downgrade
+ from frappe.installer import (
+ extract_sql_from_archive,
+ extract_files,
+ is_downgrade,
+ is_partial,
+ validate_database_sql
+ )
+
force = context.force or force
+ decompressed_file_name = extract_sql_from_archive(sql_file_path)
- # Extract the gzip file if user has passed *.sql.gz file instead of *.sql file
- if not os.path.exists(sql_file_path):
- base_path = '..'
- sql_file_path = os.path.join(base_path, sql_file_path)
- if not os.path.exists(sql_file_path):
- print('Invalid path {0}'.format(sql_file_path[3:]))
- sys.exit(1)
- elif sql_file_path.startswith(os.sep):
- base_path = os.sep
- else:
- base_path = '.'
+ # check if partial backup
+ if is_partial(decompressed_file_name):
+ click.secho(
+ "Partial Backup file detected. You cannot use a partial file to restore a Frappe Site.",
+ fg="red"
+ )
+ click.secho(
+ "Use `bench partial-restore` to restore a partial backup to an existing site.",
+ fg="yellow"
+ )
+ sys.exit(1)
- if sql_file_path.endswith('sql.gz'):
- decompressed_file_name = extract_sql_gzip(os.path.abspath(sql_file_path))
- else:
- decompressed_file_name = sql_file_path
+ # check if valid SQL file
+ validate_database_sql(decompressed_file_name, _raise=not force)
site = get_site(context)
frappe.init(site=site)
# dont allow downgrading to older versions of frappe without force
if not force and is_downgrade(decompressed_file_name, verbose=True):
- warn_message = "This is not recommended and may lead to unexpected behaviour. Do you want to continue anyway?"
+ warn_message = (
+ "This is not recommended and may lead to unexpected behaviour. "
+ "Do you want to continue anyway?"
+ )
click.confirm(warn_message, abort=True)
_new_site(frappe.conf.db_name, site, mariadb_root_username=mariadb_root_username,
@@ -142,22 +100,39 @@ def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_pas
# Extract public and/or private files to the restored site, if user has given the path
if with_public_files:
- with_public_files = os.path.join(base_path, with_public_files)
- public = extract_files(site, with_public_files, 'public')
+ public = extract_files(site, with_public_files)
os.remove(public)
if with_private_files:
- with_private_files = os.path.join(base_path, with_private_files)
- private = extract_files(site, with_private_files, 'private')
+ private = extract_files(site, with_private_files)
os.remove(private)
# Removing temporarily created file
if decompressed_file_name != sql_file_path:
os.remove(decompressed_file_name)
- success_message = "Site {0} has been restored{1}".format(site, " with files" if (with_public_files or with_private_files) else "")
+ success_message = "Site {0} has been restored{1}".format(
+ site,
+ " with files" if (with_public_files or with_private_files) else ""
+ )
click.secho(success_message, fg="green")
+
+@click.command('partial-restore')
+@click.argument('sql-file-path')
+@click.option("--verbose", "-v", is_flag=True)
+@pass_context
+def partial_restore(context, sql_file_path, verbose):
+ from frappe.installer import partial_restore
+ verbose = context.verbose or verbose
+
+ site = get_site(context)
+ frappe.init(site=site)
+ frappe.connect(site=site)
+ partial_restore(sql_file_path, verbose)
+ frappe.destroy()
+
+
@click.command('reinstall')
@click.option('--admin-password', help='Administrator Password for reinstalled site')
@click.option('--mariadb-root-username', help='Root username for MariaDB')
@@ -222,15 +197,51 @@ def install_app(context, apps):
sys.exit(exit_code)
-@click.command('list-apps')
+@click.command("list-apps")
@pass_context
def list_apps(context):
"List apps in site"
- site = get_site(context)
- frappe.init(site=site)
- frappe.connect()
- print("\n".join(frappe.get_installed_apps()))
- frappe.destroy()
+
+ def fix_whitespaces(text):
+ if site == context.sites[-1]:
+ text = text.rstrip()
+ if len(context.sites) == 1:
+ text = text.lstrip()
+ return text
+
+ for site in context.sites:
+ frappe.init(site=site)
+ frappe.connect()
+ site_title = (
+ click.style(f"{site}", fg="green") if len(context.sites) > 1 else ""
+ )
+ apps = frappe.get_single("Installed Applications").installed_applications
+
+ if apps:
+ name_len, ver_len = [
+ max([len(x.get(y)) for x in apps])
+ for y in ["app_name", "app_version"]
+ ]
+ template = "{{0:{0}}} {{1:{1}}} {{2}}".format(name_len, ver_len)
+
+ installed_applications = [
+ template.format(app.app_name, app.app_version, app.git_branch)
+ for app in apps
+ ]
+ applications_summary = "\n".join(installed_applications)
+ summary = f"{site_title}\n{applications_summary}\n"
+
+ else:
+ applications_summary = "\n".join(frappe.get_installed_apps())
+ summary = f"{site_title}\n{applications_summary}\n"
+
+ summary = fix_whitespaces(summary)
+
+ if applications_summary and summary:
+ print(summary)
+
+ frappe.destroy()
+
@click.command('add-system-manager')
@click.argument('email')
@@ -305,15 +316,16 @@ def migrate_to(context, frappe_provider):
@click.command('run-patch')
@click.argument('module')
+@click.option('--force', is_flag=True)
@pass_context
-def run_patch(context, module):
+def run_patch(context, module, force):
"Run a particular patch"
import frappe.modules.patch_handler
for site in context.sites:
frappe.init(site=site)
try:
frappe.connect()
- frappe.modules.patch_handler.run_single(module, force=context.force)
+ frappe.modules.patch_handler.run_single(module, force=force or context.force)
finally:
frappe.destroy()
if not context.sites:
@@ -378,16 +390,20 @@ def use(site, sites_path='.'):
@click.command('backup')
@click.option('--with-files', default=False, is_flag=True, help="Take backup with files")
+@click.option('--include', '--only', '-i', default="", type=str, help="Specify the DocTypes to backup seperated by commas")
+@click.option('--exclude', '-e', default="", type=str, help="Specify the DocTypes to not backup seperated by commas")
@click.option('--backup-path', default=None, help="Set path for saving all the files in this operation")
@click.option('--backup-path-db', default=None, help="Set path for saving database file")
@click.option('--backup-path-files', default=None, help="Set path for saving public file")
@click.option('--backup-path-private-files', default=None, help="Set path for saving private file")
@click.option('--backup-path-conf', default=None, help="Set path for saving config file")
+@click.option('--ignore-backup-conf', default=False, is_flag=True, help="Ignore excludes/includes set in config")
@click.option('--verbose', default=False, is_flag=True, help="Add verbosity")
@click.option('--compress', default=False, is_flag=True, help="Compress private and public files")
@pass_context
def backup(context, with_files=False, backup_path=None, backup_path_db=None, backup_path_files=None,
- backup_path_private_files=None, backup_path_conf=None, verbose=False, compress=False):
+ backup_path_private_files=None, backup_path_conf=None, ignore_backup_conf=False, verbose=False,
+ compress=False, include="", exclude=""):
"Backup"
from frappe.utils.backups import scheduled_backup
verbose = verbose or context.verbose
@@ -397,11 +413,27 @@ def backup(context, with_files=False, backup_path=None, backup_path_db=None, bac
try:
frappe.init(site=site)
frappe.connect()
- odb = scheduled_backup(ignore_files=not with_files, backup_path=backup_path, backup_path_db=backup_path_db, backup_path_files=backup_path_files, backup_path_private_files=backup_path_private_files, backup_path_conf=backup_path_conf, force=True, verbose=verbose, compress=compress)
+ odb = scheduled_backup(
+ ignore_files=not with_files,
+ backup_path=backup_path,
+ backup_path_db=backup_path_db,
+ backup_path_files=backup_path_files,
+ backup_path_private_files=backup_path_private_files,
+ backup_path_conf=backup_path_conf,
+ ignore_conf=ignore_backup_conf,
+ include_doctypes=include,
+ exclude_doctypes=exclude,
+ compress=compress,
+ verbose=verbose,
+ force=True
+ )
except Exception:
click.secho("Backup failed for Site {0}. Database or site_config.json may be corrupted".format(site), fg="red")
+ if verbose:
+ print(frappe.get_traceback())
exit_code = 1
continue
+
odb.print_summary()
click.secho("Backup for Site {0} has been successfully completed{1}".format(site, " with files" if with_files else ""), fg="green")
frappe.destroy()
@@ -474,13 +506,14 @@ def _drop_site(site, root_login='root', root_password=None, archived_sites_path=
if force:
pass
else:
- click.echo("="*80)
- click.echo("Error: The operation has stopped because backup of {s}'s database failed.".format(s=site))
- click.echo("Reason: {reason}{sep}".format(reason=str(err), sep="\n"))
- click.echo("Fix the issue and try again.")
- click.echo(
- "Hint: Use 'bench drop-site {s} --force' to force the removal of {s}".format(sep="\n", tab="\t", s=site)
- )
+ messages = [
+ "=" * 80,
+ "Error: The operation has stopped because backup of {0}'s database failed.".format(site),
+ "Reason: {0}\n".format(str(err)),
+ "Fix the issue and try again.",
+ "Hint: Use 'bench drop-site {0} --force' to force the removal of {0}".format(site)
+ ]
+ click.echo("\n".join(messages))
sys.exit(1)
drop_user_and_database(frappe.conf.db_name, root_login, root_password)
@@ -696,5 +729,6 @@ commands = [
stop_recording,
add_to_hosts,
start_ngrok,
- build_search_index
+ build_search_index,
+ partial_restore
]
diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py
index 8a9c130fbe..cce5968f9c 100644
--- a/frappe/core/doctype/doctype/doctype.py
+++ b/frappe/core/doctype/doctype/doctype.py
@@ -56,7 +56,8 @@ class DocType(Document):
- Check fieldnames (duplication etc)
- Clear permission table for child tables
- Add `amended_from` and `amended_by` if Amendable
- - Add custom field `auto_repeat` if Repeatable"""
+ - Add custom field `auto_repeat` if Repeatable
+ - Check if links point to valid fieldnames"""
self.check_developer_mode()
@@ -88,6 +89,7 @@ class DocType(Document):
self.make_repeatable()
self.validate_nestedset()
self.validate_website()
+ self.validate_links_table_fieldnames()
if not self.is_new():
self.before_update = frappe.get_doc('DocType', self.name)
@@ -392,7 +394,10 @@ class DocType(Document):
frappe.db.sql("""update tabSingles set value=%s
where doctype=%s and field='name' and value = %s""", (new, new, old))
else:
- frappe.db.sql("rename table `tab%s` to `tab%s`" % (old, new))
+ frappe.db.multisql({
+ "mariadb": f"RENAME TABLE `tab{old}` TO `tab{new}`",
+ "postgres": f"ALTER TABLE `tab{old}` RENAME TO `tab{new}`"
+ })
def rename_files_and_folders(self, old, new):
# move files
@@ -570,7 +575,8 @@ class DocType(Document):
def make_repeatable(self):
"""If allow_auto_repeat is set, add auto_repeat custom field."""
if self.allow_auto_repeat:
- if not frappe.db.exists('Custom Field', {'fieldname': 'auto_repeat', 'dt': self.name}):
+ if not frappe.db.exists('Custom Field', {'fieldname': 'auto_repeat', 'dt': self.name}) and \
+ not frappe.db.exists('DocField', {'fieldname': 'auto_repeat', 'parent': self.name}):
insert_after = self.fields[len(self.fields) - 1].fieldname
df = dict(fieldname='auto_repeat', label='Auto Repeat', fieldtype='Link', options='Auto Repeat', insert_after=insert_after, read_only=1, no_copy=1, print_hide=1)
create_custom_field(self.name, df)
@@ -656,6 +662,19 @@ class DocType(Document):
if not re.match("^(?![\W])[^\d_\s][\w ]+$", name, **flags):
frappe.throw(_("DocType's name should start with a letter and it can only consist of letters, numbers, spaces and underscores"), frappe.NameError)
+ def validate_links_table_fieldnames(self):
+ """Validate fieldnames in Links table"""
+ if frappe.flags.in_patch: return
+ if frappe.flags.in_fixtures: return
+ if not self.links: return
+
+ for index, link in enumerate(self.links):
+ meta = frappe.get_meta(link.link_doctype)
+ if not meta.get_field(link.link_fieldname):
+ message = _("Row #{0}: Could not find field {1} in {2} DocType").format(index+1, frappe.bold(link.link_fieldname), frappe.bold(link.link_doctype))
+ frappe.throw(message, InvalidFieldNameError, _("Invalid Fieldname"))
+
+
def validate_fields_for_doctype(doctype):
doc = frappe.get_doc("DocType", doctype)
diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py
index 6f4a400577..10169073e5 100644
--- a/frappe/core/doctype/doctype/test_doctype.py
+++ b/frappe/core/doctype/doctype/test_doctype.py
@@ -451,6 +451,33 @@ class TestDocType(unittest.TestCase):
test_doc_1.delete()
frappe.db.commit()
+ def test_links_table_fieldname_validation(self):
+ doc = new_doctype("Test Links Table Validation")
+
+ # check valid data
+ doc.append("links", {
+ 'link_doctype': "User",
+ 'link_fieldname': "first_name"
+ })
+ doc.validate_links_table_fieldnames() # no error
+ doc.links = [] # reset links table
+
+ # check invalid doctype
+ doc.append("links", {
+ 'link_doctype': "User2",
+ 'link_fieldname': "first_name"
+ })
+ self.assertRaises(frappe.DoesNotExistError, doc.validate_links_table_fieldnames)
+ doc.links = [] # reset links table
+
+ # check invalid fieldname
+ doc.append("links", {
+ 'link_doctype': "User",
+ 'link_fieldname': "a_field_that_does_not_exists"
+ })
+ self.assertRaises(InvalidFieldNameError, doc.validate_links_table_fieldnames)
+
+
def new_doctype(name, unique=0, depends_on='', fields=None):
doc = frappe.get_doc({
"doctype": "DocType",
diff --git a/frappe/core/doctype/domain_settings/domain_settings.js b/frappe/core/doctype/domain_settings/domain_settings.js
index 1428727993..7178cb4cd6 100644
--- a/frappe/core/doctype/domain_settings/domain_settings.js
+++ b/frappe/core/doctype/domain_settings/domain_settings.js
@@ -18,6 +18,9 @@ frappe.ui.form.on('Domain Settings', {
checked: active_domains.includes(domain)
};
});
+ },
+ on_change: () => {
+ frm.dirty();
}
},
render_input: true
diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py
index b8bed89a4d..8614740d26 100755
--- a/frappe/core/doctype/file/file.py
+++ b/frappe/core/doctype/file/file.py
@@ -30,7 +30,7 @@ import frappe
from frappe import _, conf
from frappe.model.document import Document
from frappe.utils import call_hook_method, cint, cstr, encode, get_files_path, get_hook_method, random_string, strip
-
+from frappe.utils.image import strip_exif_data
class MaxFileSizeReachedError(frappe.ValidationError):
pass
@@ -93,6 +93,7 @@ class File(Document):
self.set_is_private()
self.set_file_name()
self.validate_duplicate_entry()
+ self.validate_attachment_limit()
self.validate_folder()
if not self.file_url and not self.flags.ignore_file_validate:
@@ -140,6 +141,26 @@ class File(Document):
if self.file_url and (self.is_private != self.file_url.startswith('/private')):
frappe.throw(_('Invalid file URL. Please contact System Administrator.'))
+ def validate_attachment_limit(self):
+ attachment_limit = 0
+ if self.attached_to_doctype and self.attached_to_name:
+ attachment_limit = cint(frappe.get_meta(self.attached_to_doctype).max_attachments)
+
+ if attachment_limit:
+ current_attachment_count = len(frappe.get_all('File', filters={
+ 'attached_to_doctype': self.attached_to_doctype,
+ 'attached_to_name': self.attached_to_name,
+ }, limit=attachment_limit + 1))
+
+ if current_attachment_count >= attachment_limit:
+ frappe.throw(
+ _("Maximum Attachment Limit of {0} has been reached for {1} {2}.").format(
+ frappe.bold(attachment_limit), self.attached_to_doctype, self.attached_to_name
+ ),
+ exc=frappe.exceptions.AttachmentLimitReached,
+ title=_('Attachment Limit Reached')
+ )
+
def set_folder_name(self):
"""Make parent folders if not exists based on reference doctype and name"""
if self.attached_to_doctype and not self.folder:
@@ -435,6 +456,7 @@ class File(Document):
def save_file(self, content=None, decode=False, ignore_existing_file_check=False):
file_exists = False
self.content = content
+
if decode:
if isinstance(content, text_type):
self.content = content.encode("utf-8")
@@ -445,10 +467,19 @@ class File(Document):
if not self.is_private:
self.is_private = 0
- self.file_size = self.check_max_file_size()
- self.content_hash = get_content_hash(self.content)
+
self.content_type = mimetypes.guess_type(self.file_name)[0]
+
+ self.file_size = self.check_max_file_size()
+
+ if (
+ self.content_type and "image" in self.content_type
+ and frappe.get_system_settings("strip_exif_metadata_from_uploaded_images")
+ ):
+ self.content = strip_exif_data(self.content, self.content_type)
+ self.content_hash = get_content_hash(self.content)
+
duplicate_file = None
# check if a file exists with the same content hash and is also in the same folder (public or private)
@@ -612,7 +643,12 @@ def get_extension(filename, extn, content):
return extn
def get_local_image(file_url):
- file_path = frappe.get_site_path("public", file_url.lstrip("/"))
+ if file_url.startswith("/private"):
+ file_url_path = (file_url.lstrip("/"), )
+ else:
+ file_url_path = ("public", file_url.lstrip("/"))
+
+ file_path = frappe.get_site_path(*file_url_path)
try:
image = Image.open(file_path)
diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py
index 85397ea1ee..e627558680 100644
--- a/frappe/core/doctype/file/test_file.py
+++ b/frappe/core/doctype/file/test_file.py
@@ -160,6 +160,31 @@ class TestSameContent(unittest.TestCase):
def test_saved_content(self):
self.assertFalse(os.path.exists(get_files_path(self.dup_filename)))
+ def test_attachment_limit(self):
+ doctype, docname = make_test_doc()
+ from frappe.custom.doctype.property_setter.property_setter import make_property_setter
+ limit_property = make_property_setter('ToDo', None, 'max_attachments', 1, 'int', for_doctype=True)
+ file1 = frappe.get_doc({
+ "doctype": "File",
+ "file_name": 'test-attachment',
+ "attached_to_doctype": doctype,
+ "attached_to_name": docname,
+ "content": 'test'
+ })
+
+ file1.insert()
+
+ file2 = frappe.get_doc({
+ "doctype": "File",
+ "file_name": 'test-attachment',
+ "attached_to_doctype": doctype,
+ "attached_to_name": docname,
+ "content": 'test2'
+ })
+
+ self.assertRaises(frappe.exceptions.AttachmentLimitReached, file2.insert)
+ limit_property.delete()
+ frappe.clear_cache(doctype='ToDo')
def tearDown(self):
# File gets deleted on rollback, so blank
diff --git a/frappe/core/doctype/prepared_report/prepared_report.py b/frappe/core/doctype/prepared_report/prepared_report.py
index 2c02d99dad..1d0d6ebb09 100644
--- a/frappe/core/doctype/prepared_report/prepared_report.py
+++ b/frappe/core/doctype/prepared_report/prepared_report.py
@@ -89,20 +89,18 @@ def delete_expired_prepared_reports():
'creation': ['<', frappe.utils.add_days(frappe.utils.now(), -expiry_period)]
})
- args = {
- 'reports': prepared_reports_to_delete,
- 'limit': 50
- }
-
- enqueue(method=delete_prepared_reports, job_name="delete_prepared_reports", **args)
+ batches = frappe.utils.create_batch(prepared_reports_to_delete, 100)
+ for batch in batches:
+ args = {
+ 'reports': batch,
+ }
+ enqueue(method=delete_prepared_reports, job_name="delete_prepared_reports", **args)
@frappe.whitelist()
-def delete_prepared_reports(reports, limit=None):
+def delete_prepared_reports(reports):
reports = frappe.parse_json(reports)
- for index, doc in enumerate(reports):
- if limit and index == limit:
- return
- frappe.delete_doc('Prepared Report', doc['name'], ignore_permissions=True)
+ for report in reports:
+ frappe.delete_doc('Prepared Report', report['name'], ignore_permissions=True)
def create_json_gz_file(data, dt, dn):
# Storing data in CSV file causes information loss
diff --git a/frappe/core/doctype/report/report.py b/frappe/core/doctype/report/report.py
index 9d30409a2a..01c32bcb57 100644
--- a/frappe/core/doctype/report/report.py
+++ b/frappe/core/doctype/report/report.py
@@ -61,8 +61,9 @@ class Report(Document):
def set_doctype_roles(self):
if not self.get('roles') and self.is_standard == 'No':
meta = frappe.get_meta(self.ref_doctype)
- roles = [{'role': d.role} for d in meta.permissions if d.permlevel==0]
- self.set('roles', roles)
+ if not meta.istable:
+ roles = [{'role': d.role} for d in meta.permissions if d.permlevel==0]
+ self.set('roles', roles)
def is_permitted(self):
"""Returns true if Has Role is not set or the user is allowed."""
diff --git a/frappe/core/doctype/report_filter/report_filter.json b/frappe/core/doctype/report_filter/report_filter.json
index 9d277db11d..964294b96e 100644
--- a/frappe/core/doctype/report_filter/report_filter.json
+++ b/frappe/core/doctype/report_filter/report_filter.json
@@ -44,7 +44,7 @@
},
{
"fieldname": "options",
- "fieldtype": "Data",
+ "fieldtype": "Small Text",
"label": "Options"
},
{
@@ -58,7 +58,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2020-08-17 16:15:46.937267",
+ "modified": "2020-12-05 19:20:00.503097",
"modified_by": "Administrator",
"module": "Core",
"name": "Report Filter",
diff --git a/frappe/core/doctype/role/role.py b/frappe/core/doctype/role/role.py
index e458b401e4..1920189f78 100644
--- a/frappe/core/doctype/role/role.py
+++ b/frappe/core/doctype/role/role.py
@@ -37,7 +37,7 @@ class Role(Document):
def get_info_based_on_role(role, field='email'):
''' Get information of all users that have been assigned this role '''
users = frappe.get_list("Has Role", filters={"role": role, "parenttype": "User"},
- fields=["parent"])
+ fields=["parent as user_name"])
return get_user_info(users, field)
@@ -45,7 +45,7 @@ def get_user_info(users, field='email'):
''' Fetch details about users for the specified field '''
info_list = []
for user in users:
- user_info, enabled = frappe.db.get_value("User", user.parent, [field, "enabled"])
+ user_info, enabled = frappe.db.get_value("User", user.get("user_name"), [field, "enabled"])
if enabled and user_info not in ["admin@example.com", "guest@example.com"]:
info_list.append(user_info)
return info_list
diff --git a/frappe/core/doctype/server_script/server_script.js b/frappe/core/doctype/server_script/server_script.js
index 78ef2d0509..a317d69166 100644
--- a/frappe/core/doctype/server_script/server_script.js
+++ b/frappe/core/doctype/server_script/server_script.js
@@ -48,29 +48,33 @@ frappe.ui.form.on('Server Script', {
setup_help(frm) {
frm.get_field('help_html').html(`
-
Examples
DocType Event
-
+Add logic for standard doctype events like Before Insert, After Submit, etc.
+
+
# set property
if "test" in doc.description:
- doc.status = 'Closed'
+ doc.status = 'Closed'
# validate
if "validate" in doc.description:
- raise frappe.ValidationError
+ raise frappe.ValidationError
# auto create another document
-if doc.allocted_to:
- frappe.get_doc(dict(
- doctype = 'ToDo'
- owner = doc.allocated_to,
- description = doc.subject
- )).insert()
-
+if doc.allocated_to:
+ frappe.get_doc(dict(
+ doctype = 'ToDo'
+ owner = doc.allocated_to,
+ description = doc.subject
+ )).insert()
+
+
+
API Call
+Respond to /api/method/<method-name> calls, just like whitelisted methods
# respond to API
@@ -79,6 +83,21 @@ if frappe.form_dict.message == "ping":
else:
frappe.response['message'] = "ok"
+
+
+
+Permission Query
+Add conditions to the where clause of list queries.
+
+# generate dynamic conditions and set it in the conditions variable
+tenant_id = frappe.db.get_value(...)
+conditions = 'tenant_id = {}'.format(tenant_id)
+
+# resulting select query
+select name from \`tabPerson\`
+where tenant_id = 2
+order by creation desc
+
`);
}
diff --git a/frappe/core/doctype/server_script/server_script.json b/frappe/core/doctype/server_script/server_script.json
index cc3995ad1d..94a48f196c 100644
--- a/frappe/core/doctype/server_script/server_script.json
+++ b/frappe/core/doctype/server_script/server_script.json
@@ -24,17 +24,18 @@
"fieldtype": "Select",
"in_list_view": 1,
"label": "Script Type",
- "options": "DocType Event\nScheduler Event\nAPI",
+ "options": "DocType Event\nScheduler Event\nPermission Query\nAPI",
"reqd": 1
},
{
"fieldname": "script",
"fieldtype": "Code",
"label": "Script",
+ "options": "Python",
"reqd": 1
},
{
- "depends_on": "eval:doc.script_type==='DocType Event'",
+ "depends_on": "eval:['DocType Event', 'Permission Query'].includes(doc.script_type)",
"fieldname": "reference_doctype",
"fieldtype": "Link",
"in_list_view": 1,
@@ -87,7 +88,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2020-08-24 16:44:41.060350",
+ "modified": "2020-12-03 22:42:02.708148",
"modified_by": "Administrator",
"module": "Core",
"name": "Server Script",
diff --git a/frappe/core/doctype/server_script/server_script.py b/frappe/core/doctype/server_script/server_script.py
index 839b784651..88d68dba14 100644
--- a/frappe/core/doctype/server_script/server_script.py
+++ b/frappe/core/doctype/server_script/server_script.py
@@ -4,6 +4,8 @@
from __future__ import unicode_literals
+import ast
+
import frappe
from frappe.model.document import Document
from frappe.utils.safe_exec import safe_exec
@@ -11,9 +13,9 @@ from frappe import _
class ServerScript(Document):
- @staticmethod
- def validate():
+ def validate(self):
frappe.only_for('Script Manager', True)
+ ast.parse(self.script)
@staticmethod
def on_update():
@@ -41,6 +43,12 @@ class ServerScript(Document):
# wrong report type!
raise frappe.DoesNotExistError
+ def get_permission_query_conditions(self, user):
+ locals = {"user": user, "conditions": ""}
+ safe_exec(self.script, None, locals)
+ if locals["conditions"]:
+ return locals["conditions"]
+
@frappe.whitelist()
def setup_scheduler_events(script_name, frequency):
method = frappe.scrub('{0}-{1}'.format(script_name, frequency))
diff --git a/frappe/core/doctype/server_script/server_script_utils.py b/frappe/core/doctype/server_script/server_script_utils.py
index e03504f30b..4dc4f12b34 100644
--- a/frappe/core/doctype/server_script/server_script_utils.py
+++ b/frappe/core/doctype/server_script/server_script_utils.py
@@ -50,6 +50,9 @@ def get_server_script_map():
# },
# '_api': {
# '[path]': '[server script]'
+ # },
+ # 'permission_query': {
+ # 'DocType': '[server script]'
# }
# }
if frappe.flags.in_patch and not frappe.db.table_exists('Server Script'):
@@ -57,16 +60,20 @@ def get_server_script_map():
script_map = frappe.cache().get_value('server_script_map')
if script_map is None:
- script_map = {}
+ script_map = {
+ 'permission_query': {}
+ }
enabled_server_scripts = frappe.get_all('Server Script',
fields=('name', 'reference_doctype', 'doctype_event','api_method', 'script_type'),
filters={'disabled': 0})
for script in enabled_server_scripts:
if script.script_type == 'DocType Event':
script_map.setdefault(script.reference_doctype, {}).setdefault(script.doctype_event, []).append(script.name)
+ elif script.script_type == 'Permission Query':
+ script_map['permission_query'][script.reference_doctype] = script.name
else:
script_map.setdefault('_api', {})[script.api_method] = script.name
frappe.cache().set_value('server_script_map', script_map)
- return script_map
\ No newline at end of file
+ return script_map
diff --git a/frappe/core/doctype/server_script/test_server_script.py b/frappe/core/doctype/server_script/test_server_script.py
index 3356e584af..957cbbf72d 100644
--- a/frappe/core/doctype/server_script/test_server_script.py
+++ b/frappe/core/doctype/server_script/test_server_script.py
@@ -45,6 +45,22 @@ frappe.response['message'] = 'hello'
allow_guest = 1,
script = '''
frappe.flags = 'hello'
+'''
+ ),
+ dict(
+ name='test_permission_query',
+ script_type = 'Permission Query',
+ reference_doctype = 'ToDo',
+ script = '''
+conditions = '1 = 1'
+'''),
+ dict(
+ name='test_invalid_namespace_method',
+ script_type = 'DocType Event',
+ doctype_event = 'Before Insert',
+ reference_doctype = 'Note',
+ script = '''
+frappe.method_that_doesnt_exist("do some magic")
'''
)
]
@@ -85,3 +101,12 @@ class TestServerScript(unittest.TestCase):
def test_api_return(self):
self.assertEqual(frappe.get_doc('Server Script', 'test_return_value').execute_method(), 'hello')
+
+ def test_permission_query(self):
+ self.assertTrue('where (1 = 1)' in frappe.db.get_list('ToDo', return_query=1))
+ self.assertTrue(isinstance(frappe.db.get_list('ToDo'), list))
+
+ def test_attribute_error(self):
+ """Raise AttributeError if method not found in Namespace"""
+ note = frappe.get_doc({"doctype": "Note", "title": "Test Note: Server Script"})
+ self.assertRaises(AttributeError, note.insert)
diff --git a/frappe/core/doctype/system_settings/system_settings.js b/frappe/core/doctype/system_settings/system_settings.js
index b6514dea9f..c0c9074cbc 100644
--- a/frappe/core/doctype/system_settings/system_settings.js
+++ b/frappe/core/doctype/system_settings/system_settings.js
@@ -1,37 +1,36 @@
-frappe.ui.form.on("System Settings", "refresh", function(frm) {
- frappe.call({
- method: "frappe.core.doctype.system_settings.system_settings.load",
- callback: function(data) {
- frappe.all_timezones = data.message.timezones;
- frm.set_df_property("time_zone", "options", frappe.all_timezones);
+frappe.ui.form.on("System Settings", {
+ refresh: function(frm) {
+ frappe.call({
+ method: "frappe.core.doctype.system_settings.system_settings.load",
+ callback: function(data) {
+ frappe.all_timezones = data.message.timezones;
+ frm.set_df_property("time_zone", "options", frappe.all_timezones);
- $.each(data.message.defaults, function(key, val) {
- frm.set_value(key, val);
- frappe.sys_defaults[key] = val;
- })
+ $.each(data.message.defaults, function(key, val) {
+ frm.set_value(key, val);
+ frappe.sys_defaults[key] = val;
+ });
+ }
+ });
+ },
+ enable_password_policy: function(frm) {
+ if (frm.doc.enable_password_policy == 0) {
+ frm.set_value("minimum_password_score", "");
+ } else {
+ frm.set_value("minimum_password_score", "2");
}
- });
-});
-
-frappe.ui.form.on("System Settings", "enable_password_policy", function(frm) {
- if(frm.doc.enable_password_policy == 0){
- frm.set_value("minimum_password_score", "");
- } else {
- frm.set_value("minimum_password_score", "2");
- }
-});
-
-frappe.ui.form.on("System Settings", "enable_two_factor_auth", function(frm) {
- if(frm.doc.enable_two_factor_auth == 0){
- frm.set_value("bypass_2fa_for_retricted_ip_users", 0);
- frm.set_value("bypass_restrict_ip_check_if_2fa_enabled", 0);
- }
-});
-
-frappe.ui.form.on("System Settings", "enable_prepared_report_auto_deletion", function(frm) {
- if (frm.doc.enable_prepared_report_auto_deletion) {
- if (!frm.doc.prepared_report_expiry_period) {
- frm.set_value('prepared_report_expiry_period', 7);
+ },
+ enable_two_factor_auth: function(frm) {
+ if (frm.doc.enable_two_factor_auth == 0) {
+ frm.set_value("bypass_2fa_for_retricted_ip_users", 0);
+ frm.set_value("bypass_restrict_ip_check_if_2fa_enabled", 0);
+ }
+ },
+ enable_prepared_report_auto_deletion: function(frm) {
+ if (frm.doc.enable_prepared_report_auto_deletion) {
+ if (!frm.doc.prepared_report_expiry_period) {
+ frm.set_value('prepared_report_expiry_period', 7);
+ }
}
}
});
diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json
index 17f97b3e1a..79fb84923a 100644
--- a/frappe/core/doctype/system_settings/system_settings.json
+++ b/frappe/core/doctype/system_settings/system_settings.json
@@ -37,6 +37,7 @@
"allow_login_using_mobile_number",
"allow_login_using_user_name",
"allow_error_traceback",
+ "strip_exif_metadata_from_uploaded_images",
"password_settings",
"logout_on_password_reset",
"force_user_to_reset_password",
@@ -460,12 +461,18 @@
"fieldname": "prepared_report_section",
"fieldtype": "Section Break",
"label": "Prepared Report"
+ },
+ {
+ "default": "1",
+ "fieldname": "strip_exif_metadata_from_uploaded_images",
+ "fieldtype": "Check",
+ "label": "Strip EXIF tags from uploaded images"
}
],
"icon": "fa fa-cog",
"issingle": 1,
"links": [],
- "modified": "2020-08-12 14:35:45.214327",
+ "modified": "2020-11-30 18:52:22.161391",
"modified_by": "Administrator",
"module": "Core",
"name": "System Settings",
diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py
index 2c5865fb69..da4026d8fd 100644
--- a/frappe/core/doctype/user/user.py
+++ b/frappe/core/doctype/user/user.py
@@ -13,7 +13,7 @@ from frappe.utils.user import get_system_managers
from bs4 import BeautifulSoup
import frappe.permissions
import frappe.share
-
+import frappe.defaults
from frappe.website.utils import is_signup_enabled
from frappe.utils.background_jobs import enqueue
@@ -98,15 +98,20 @@ class User(Document):
self.share_with_self()
clear_notifications(user=self.name)
frappe.clear_cache(user=self.name)
+ now=frappe.flags.in_test or frappe.flags.in_install
self.send_password_notification(self.__new_password)
frappe.enqueue(
'frappe.core.doctype.user.user.create_contact',
user=self,
ignore_mandatory=True,
- now=frappe.flags.in_test or frappe.flags.in_install
+ now=now
)
if self.name not in ('Administrator', 'Guest') and not self.user_image:
- frappe.enqueue('frappe.core.doctype.user.user.update_gravatar', name=self.name)
+ frappe.enqueue('frappe.core.doctype.user.user.update_gravatar', name=self.name, now=now)
+
+ # Set user selected timezone
+ if self.time_zone:
+ frappe.defaults.set_default("time_zone", self.time_zone, self.name)
def has_website_permission(self, ptype, user, verbose=False):
"""Returns true if current user is the session user"""
@@ -1129,4 +1134,4 @@ def check_password_reset_limit(user, rate_limit):
frappe.throw(_("You have reached the hourly limit for generating password reset links. Please try again later."))
def get_generated_link_count(user):
- return cint(frappe.cache().hget("password_reset_link_count", user)) or 0
\ No newline at end of file
+ return cint(frappe.cache().hget("password_reset_link_count", user)) or 0
diff --git a/frappe/custom/doctype/customize_form/customize_form.js b/frappe/custom/doctype/customize_form/customize_form.js
index 2d220b864c..17343573ed 100644
--- a/frappe/custom/doctype/customize_form/customize_form.js
+++ b/frappe/custom/doctype/customize_form/customize_form.js
@@ -81,6 +81,11 @@ frappe.ui.form.on("Customize Form", {
} else {
f._sortable = false;
}
+ if (f.fieldtype == "Table") {
+ frm.add_custom_button(f.options, function() {
+ frm.set_value('doc_type', f.options);
+ }, __('Customize Child Table'));
+ }
});
frm.fields_dict.fields.grid.refresh();
},
diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py
index 9ce602906c..82513783c7 100644
--- a/frappe/custom/doctype/customize_form/customize_form.py
+++ b/frappe/custom/doctype/customize_form/customize_form.py
@@ -39,7 +39,7 @@ class CustomizeForm(Document):
translation = self.get_name_translation()
self.label = translation.translated_text if translation else ''
- self.create_auto_repeat_custom_field_if_requried(meta)
+ self.create_auto_repeat_custom_field_if_required(meta)
# NOTE doc (self) is sent to clientside by run_method
@@ -74,19 +74,25 @@ class CustomizeForm(Document):
for d in meta.get(fieldname):
self.append(fieldname, d)
- def create_auto_repeat_custom_field_if_requried(self, meta):
+ def create_auto_repeat_custom_field_if_required(self, meta):
+ '''
+ Create auto repeat custom field if it's not already present
+ '''
if self.allow_auto_repeat:
- if not frappe.db.exists('Custom Field', {'fieldname': 'auto_repeat',
- 'dt': self.doc_type}):
- insert_after = self.fields[len(self.fields) - 1].fieldname
- df = dict(
- fieldname='auto_repeat',
- label='Auto Repeat',
- fieldtype='Link',
- options='Auto Repeat',
- insert_after=insert_after,
- read_only=1, no_copy=1, print_hide=1)
- create_custom_field(self.doc_type, df)
+ all_fields = [df.fieldname for df in meta.fields]
+
+ if "auto_repeat" in all_fields:
+ return
+
+ insert_after = self.fields[len(self.fields) - 1].fieldname
+ create_custom_field(self.doc_type, dict(
+ fieldname='auto_repeat',
+ label='Auto Repeat',
+ fieldtype='Link',
+ options='Auto Repeat',
+ insert_after=insert_after,
+ read_only=1, no_copy=1, print_hide=1
+ ))
def get_name_translation(self):
diff --git a/frappe/database/db_manager.py b/frappe/database/db_manager.py
index 3345fce735..b8ffae519b 100644
--- a/frappe/database/db_manager.py
+++ b/frappe/database/db_manager.py
@@ -3,7 +3,6 @@ import frappe
class DbManager:
-
def __init__(self, db):
"""
Pass root_conn here for access to all databases.
@@ -66,10 +65,10 @@ class DbManager:
esc = make_esc('$ ')
from distutils.spawn import find_executable
- pipe = find_executable('pv')
- if pipe:
- pipe = '{pipe} {source} |'.format(
- pipe=pipe,
+ pv = find_executable('pv')
+ if pv:
+ pipe = '{pv} {source} |'.format(
+ pv=pv,
source=source
)
source = ''
@@ -78,7 +77,7 @@ class DbManager:
source = '< {source}'.format(source=source)
if pipe:
- print('Creating Database...')
+ print('Restoring Database file...')
command = '{pipe} mysql -u {user} -p{password} -h{host} ' + ('-P{port}' if frappe.db.port else '') + ' {target} {source}'
command = command.format(
diff --git a/frappe/database/mariadb/framework_mariadb.sql b/frappe/database/mariadb/framework_mariadb.sql
index 15b0bed699..a52efd01e3 100644
--- a/frappe/database/mariadb/framework_mariadb.sql
+++ b/frappe/database/mariadb/framework_mariadb.sql
@@ -233,7 +233,7 @@ CREATE TABLE `tabDocType` (
DROP TABLE IF EXISTS `tabSeries`;
CREATE TABLE `tabSeries` (
- `name` varchar(100) DEFAULT NULL,
+ `name` varchar(100),
`current` int(10) NOT NULL DEFAULT 0,
PRIMARY KEY(`name`)
) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/frappe/database/mariadb/setup_db.py b/frappe/database/mariadb/setup_db.py
index a4e4d624ae..9b73d77171 100644
--- a/frappe/database/mariadb/setup_db.py
+++ b/frappe/database/mariadb/setup_db.py
@@ -1,7 +1,7 @@
from __future__ import unicode_literals
import frappe
-import os, sys
+import os
from frappe.database.db_manager import DbManager
expected_settings_10_2_earlier = {
@@ -86,6 +86,8 @@ def drop_user_and_database(db_name, root_login, root_password):
dbman.drop_database(db_name)
def bootstrap_database(db_name, verbose, source_sql=None):
+ import sys
+
frappe.connect(db_name=db_name)
if not check_database_settings():
print('Database settings do not match expected values; stopping database setup.')
@@ -94,9 +96,17 @@ def bootstrap_database(db_name, verbose, source_sql=None):
import_db_from_sql(source_sql, verbose)
frappe.connect(db_name=db_name)
- if not 'tabDefaultValue' in frappe.db.get_tables():
- print('''Database not installed, this can due to lack of permission, or that the database name exists.
- Check your mysql root password, or use --force to reinstall''')
+ if 'tabDefaultValue' not in frappe.db.get_tables():
+ from click import secho
+
+ secho(
+ "Table 'tabDefaultValue' missing in the restored site. "
+ "Database not installed correctly, this can due to lack of "
+ "permission, or that the database name exists. Check your mysql"
+ " root password, validity of the backup file or use --force to"
+ " reinstall",
+ fg="red"
+ )
sys.exit(1)
def import_db_from_sql(source_sql=None, verbose=False):
diff --git a/frappe/database/postgres/setup_db.py b/frappe/database/postgres/setup_db.py
index f53872db82..3ee6b6a286 100644
--- a/frappe/database/postgres/setup_db.py
+++ b/frappe/database/postgres/setup_db.py
@@ -1,5 +1,7 @@
-import frappe, subprocess, os
-from six.moves import input
+import os
+
+import frappe
+
def setup_database(force, source_sql=None, verbose=False):
root_conn = get_root_connection()
@@ -10,24 +12,62 @@ def setup_database(force, source_sql=None, verbose=False):
root_conn.sql("CREATE user {0} password '{1}'".format(frappe.conf.db_name,
frappe.conf.db_password))
root_conn.sql("GRANT ALL PRIVILEGES ON DATABASE `{0}` TO {0}".format(frappe.conf.db_name))
+ root_conn.close()
+
+ bootstrap_database(frappe.conf.db_name, verbose, source_sql=source_sql)
+ frappe.connect()
+
+def bootstrap_database(db_name, verbose, source_sql=None):
+ frappe.connect(db_name=db_name)
+ import_db_from_sql(source_sql, verbose)
+ frappe.connect(db_name=db_name)
+
+ if 'tabDefaultValue' not in frappe.db.get_tables():
+ import sys
+ from click import secho
+
+ secho(
+ "Table 'tabDefaultValue' missing in the restored site. "
+ "This may be due to incorrect permissions or the result of a restore from a bad backup file. "
+ "Database not installed correctly.",
+ fg="red"
+ )
+ sys.exit(1)
+
+def import_db_from_sql(source_sql=None, verbose=False):
+ from shutil import which
+ from subprocess import run, PIPE
# we can't pass psql password in arguments in postgresql as mysql. So
# set password connection parameter in environment variable
subprocess_env = os.environ.copy()
subprocess_env['PGPASSWORD'] = str(frappe.conf.db_password)
+
# bootstrap db
if not source_sql:
source_sql = os.path.join(os.path.dirname(__file__), 'framework_postgres.sql')
- subprocess.check_output([
- 'psql', frappe.conf.db_name,
- '-h', frappe.conf.db_host or 'localhost',
- '-p', str(frappe.conf.db_port or '5432'),
- '-U', frappe.conf.db_name,
- '-f', source_sql
- ], env=subprocess_env)
+ pv = which('pv')
- frappe.connect()
+ _command = (
+ f"psql {frappe.conf.db_name} "
+ f"-h {frappe.conf.db_host or 'localhost'} -p {str(frappe.conf.db_port or '5432')} "
+ f"-U {frappe.conf.db_name}"
+ )
+
+ if pv:
+ command = f"{pv} {source_sql} | " + _command
+ else:
+ command = _command + f" -f {source_sql}"
+
+ print("Restoring Database file...")
+ if verbose:
+ print(command)
+
+ restore_proc = run(command, env=subprocess_env, shell=True, stdout=PIPE)
+
+ if verbose:
+ print(f"\nSTDOUT by psql:\n{restore_proc.stdout.decode()}\nImported from Database File: {source_sql}")
def setup_help_database(help_db_name):
root_conn = get_root_connection()
@@ -38,19 +78,20 @@ def setup_help_database(help_db_name):
root_conn.sql("GRANT ALL PRIVILEGES ON DATABASE `{0}` TO {0}".format(help_db_name))
def get_root_connection(root_login=None, root_password=None):
- import getpass
if not frappe.local.flags.root_connection:
if not root_login:
root_login = frappe.conf.get("root_login") or None
if not root_login:
+ from six.moves import input
root_login = input("Enter postgres super user: ")
if not root_password:
root_password = frappe.conf.get("root_password") or None
if not root_password:
- root_password = getpass.getpass("Postgres super user password: ")
+ from getpass import getpass
+ root_password = getpass("Postgres super user password: ")
frappe.local.flags.root_connection = frappe.database.get_db(user=root_login, password=root_password)
diff --git a/frappe/desk/doctype/dashboard/dashboard.py b/frappe/desk/doctype/dashboard/dashboard.py
index b12bcfe27d..fa03bf8f80 100644
--- a/frappe/desk/doctype/dashboard/dashboard.py
+++ b/frappe/desk/doctype/dashboard/dashboard.py
@@ -5,6 +5,7 @@
from __future__ import unicode_literals
from frappe.model.document import Document
from frappe.modules.export_file import export_to_files
+from frappe.config import get_modules_from_all_apps_for_user
import frappe
from frappe import _
import json
@@ -42,6 +43,24 @@ class Dashboard(Document):
except ValueError as error:
frappe.throw(_("Invalid json added in the custom options: {0}").format(error))
+
+def get_permission_query_conditions(user):
+ if not user:
+ user = frappe.session.user
+
+ if user == 'Administrator':
+ return
+
+ roles = frappe.get_roles(user)
+ if "System Manager" in roles:
+ return None
+
+ allowed_modules = [frappe.db.escape(module.get('module_name')) for module in get_modules_from_all_apps_for_user()]
+ module_condition = '`tabDashboard`.`module` in ({allowed_modules}) or `tabDashboard`.`module` is NULL'.format(
+ allowed_modules=','.join(allowed_modules))
+
+ return module_condition
+
@frappe.whitelist()
def get_permitted_charts(dashboard_name):
permitted_charts = []
diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py
index 7e2d952928..2fa36b5514 100644
--- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py
+++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py
@@ -7,17 +7,18 @@ import frappe
from frappe import _
import datetime
import json
-from frappe.utils.dashboard import cache_source, get_from_date_from_timespan
-from frappe.utils import nowdate, add_to_date, getdate, get_last_day, formatdate,\
- get_datetime, cint, now_datetime
+from frappe.utils.dashboard import cache_source
+from frappe.utils import nowdate, getdate, get_datetime, cint, now_datetime
+from frappe.utils.dateutils import\
+ get_period, get_period_beginning, get_from_date_from_timespan, get_dates_from_timegrain
from frappe.model.naming import append_number_if_name_exists
from frappe.boot import get_allowed_reports
+from frappe.config import get_modules_from_all_apps_for_user
from frappe.model.document import Document
from frappe.modules.export_file import export_to_files
def get_permission_query_conditions(user):
-
if not user:
user = frappe.session.user
@@ -30,9 +31,11 @@ def get_permission_query_conditions(user):
doctype_condition = False
report_condition = False
+ module_condition = False
allowed_doctypes = [frappe.db.escape(doctype) for doctype in frappe.permissions.get_doctypes_with_read()]
allowed_reports = [frappe.db.escape(key) if type(key) == str else key.encode('UTF8') for key in get_allowed_reports()]
+ allowed_modules = [frappe.db.escape(module.get('module_name')) for module in get_modules_from_all_apps_for_user()]
if allowed_doctypes:
doctype_condition = '`tabDashboard Chart`.`document_type` in ({allowed_doctypes})'.format(
@@ -40,18 +43,24 @@ def get_permission_query_conditions(user):
if allowed_reports:
report_condition = '`tabDashboard Chart`.`report_name` in ({allowed_reports})'.format(
allowed_reports=','.join(allowed_reports))
+ if allowed_modules:
+ module_condition = '''`tabDashboard Chart`.`module` in ({allowed_modules})
+ or `tabDashboard Chart`.`module` is NULL'''.format(
+ allowed_modules=','.join(allowed_modules))
return '''
- (`tabDashboard Chart`.`chart_type` in ('Count', 'Sum', 'Average')
- and {doctype_condition})
- or
- (`tabDashboard Chart`.`chart_type` = 'Report'
- and {report_condition})
- '''.format(
- doctype_condition=doctype_condition,
- report_condition=report_condition
- )
-
+ ((`tabDashboard Chart`.`chart_type` in ('Count', 'Sum', 'Average')
+ and {doctype_condition})
+ or
+ (`tabDashboard Chart`.`chart_type` = 'Report'
+ and {report_condition}))
+ and
+ ({module_condition})
+ '''.format(
+ doctype_condition=doctype_condition,
+ report_condition=report_condition,
+ module_condition=module_condition
+ )
def has_permission(doc, ptype, user):
roles = frappe.get_roles(user)
@@ -156,6 +165,7 @@ def add_chart_to_dashboard(args):
def get_chart_config(chart, filters, timespan, timegrain, from_date, to_date):
if not from_date:
from_date = get_from_date_from_timespan(to_date, timespan)
+ from_date = get_period_beginning(from_date, timegrain)
if not to_date:
to_date = now_datetime()
@@ -185,7 +195,7 @@ def get_chart_config(chart, filters, timespan, timegrain, from_date, to_date):
result = get_result(data, timegrain, from_date, to_date)
chart_config = {
- "labels": [formatdate(r[0].strftime('%Y-%m-%d')) for r in result],
+ "labels": [get_period(r[0], timegrain) for r in result],
"datasets": [{
"name": chart.name,
"values": [r[1] for r in result]
@@ -279,16 +289,8 @@ def get_aggregate_function(chart_type):
def get_result(data, timegrain, from_date, to_date):
- start_date = getdate(from_date)
- end_date = getdate(to_date)
-
- result = [[start_date, 0.0]]
-
- while start_date < end_date:
- next_date = get_next_expected_date(start_date, timegrain)
- result.append([next_date, 0.0])
- start_date = next_date
-
+ dates = get_dates_from_timegrain(from_date, to_date, timegrain)
+ result = [[date, 0] for date in dates]
data_index = 0
if data:
for i, d in enumerate(result):
@@ -298,65 +300,6 @@ def get_result(data, timegrain, from_date, to_date):
return result
-def get_next_expected_date(date, timegrain):
- next_date = None
- # given date is always assumed to be the period ending date
- next_date = get_period_ending(add_to_date(date, days=1), timegrain)
- return getdate(next_date)
-
-def get_period_ending(date, timegrain):
- date = getdate(date)
- if timegrain == 'Daily':
- pass
- elif timegrain == 'Weekly':
- date = get_week_ending(date)
- elif timegrain == 'Monthly':
- date = get_month_ending(date)
- elif timegrain == 'Quarterly':
- date = get_quarter_ending(date)
- elif timegrain == 'Yearly':
- date = get_year_ending(date)
-
- return getdate(date)
-
-def get_week_ending(date):
- # week starts on monday
- from datetime import timedelta
- start = date - timedelta(days = date.weekday())
- end = start + timedelta(days=6)
-
- return end
-
-def get_month_ending(date):
- month_of_the_year = int(date.strftime('%m'))
- # first day of next month (note month starts from 1)
-
- date = add_to_date('{}-01-01'.format(date.year), months = month_of_the_year)
- # last day of this month
- return add_to_date(date, days=-1)
-
-def get_quarter_ending(date):
- date = getdate(date)
-
- # find the earliest quarter ending date that is after
- # the given date
- for month in (3, 6, 9, 12):
- quarter_end_month = getdate('{}-{}-01'.format(date.year, month))
- quarter_end_date = getdate(get_last_day(quarter_end_month))
- if date <= quarter_end_date:
- date = quarter_end_date
- break
-
- return date
-
-def get_year_ending(date):
- ''' returns year ending of the given date '''
-
- # first day of next year (note year starts from 1)
- date = add_to_date('{}-01-01'.format(date.year), months = 12)
- # last day of this month
- return add_to_date(date, days=-1)
-
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_charts_for_user(doctype, txt, searchfield, start, page_len, filters):
diff --git a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py
index 5e39998e62..3c37ad4a09 100644
--- a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py
+++ b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py
@@ -5,8 +5,8 @@ from __future__ import unicode_literals
import unittest, frappe
from frappe.utils import getdate, formatdate, get_last_day
-from frappe.desk.doctype.dashboard_chart.dashboard_chart import (get,
- get_period_ending)
+from frappe.utils.dateutils import get_period_ending, get_period
+from frappe.desk.doctype.dashboard_chart.dashboard_chart import get
from datetime import datetime
from dateutil.relativedelta import relativedelta
@@ -53,15 +53,11 @@ class TestDashboardChart(unittest.TestCase):
cur_date = datetime.now() - relativedelta(years=1)
result = get(chart_name='Test Dashboard Chart', refresh=1)
- self.assertEqual(result.get('labels')[0], formatdate(cur_date.strftime('%Y-%m-%d')))
- if formatdate(cur_date.strftime('%Y-%m-%d')) == formatdate(get_last_day(cur_date).strftime('%Y-%m-%d')):
- cur_date += relativedelta(months=1)
-
- for idx in range(1, 13):
+ for idx in range(13):
month = get_last_day(cur_date)
month = formatdate(month.strftime('%Y-%m-%d'))
- self.assertEqual(result.get('labels')[idx], month)
+ self.assertEqual(result.get('labels')[idx], get_period(month))
cur_date += relativedelta(months=1)
frappe.db.rollback()
@@ -87,15 +83,11 @@ class TestDashboardChart(unittest.TestCase):
cur_date = datetime.now() - relativedelta(years=1)
result = get(chart_name ='Test Empty Dashboard Chart', refresh=1)
- self.assertEqual(result.get('labels')[0], formatdate(cur_date.strftime('%Y-%m-%d')))
- if formatdate(cur_date.strftime('%Y-%m-%d')) == formatdate(get_last_day(cur_date).strftime('%Y-%m-%d')):
- cur_date += relativedelta(months=1)
-
- for idx in range(1, 13):
+ for idx in range(13):
month = get_last_day(cur_date)
month = formatdate(month.strftime('%Y-%m-%d'))
- self.assertEqual(result.get('labels')[idx], month)
+ self.assertEqual(result.get('labels')[idx], get_period(month))
cur_date += relativedelta(months=1)
frappe.db.rollback()
@@ -124,15 +116,11 @@ class TestDashboardChart(unittest.TestCase):
cur_date = datetime.now() - relativedelta(years=1)
result = get(chart_name ='Test Empty Dashboard Chart 2', refresh = 1)
- self.assertEqual(result.get('labels')[0], formatdate(cur_date.strftime('%Y-%m-%d')))
- if formatdate(cur_date.strftime('%Y-%m-%d')) == formatdate(get_last_day(cur_date).strftime('%Y-%m-%d')):
- cur_date += relativedelta(months=1)
-
- for idx in range(1, 13):
+ for idx in range(13):
month = get_last_day(cur_date)
month = formatdate(month.strftime('%Y-%m-%d'))
- self.assertEqual(result.get('labels')[idx], month)
+ self.assertEqual(result.get('labels')[idx], get_period(month))
cur_date += relativedelta(months=1)
# only 1 data point with value
@@ -183,13 +171,12 @@ class TestDashboardChart(unittest.TestCase):
timeseries = 1
)).insert()
- result = get(chart_name ='Test Daily Dashboard Chart', refresh = 1)
+ result = get(chart_name = 'Test Daily Dashboard Chart', refresh = 1)
self.assertEqual(result.get('datasets')[0].get('values'), [200.0, 400.0, 300.0, 0.0, 100.0, 0.0])
self.assertEqual(
result.get('labels'),
- [formatdate('2019-01-06'), formatdate('2019-01-07'), formatdate('2019-01-08'),\
- formatdate('2019-01-09'), formatdate('2019-01-10'), formatdate('2019-01-11')]
+ ['06-01-19', '07-01-19', '08-01-19', '09-01-19', '10-01-19', '11-01-19']
)
frappe.db.rollback()
@@ -218,7 +205,10 @@ class TestDashboardChart(unittest.TestCase):
result = get(chart_name ='Test Weekly Dashboard Chart', refresh = 1)
self.assertEqual(result.get('datasets')[0].get('values'), [50.0, 300.0, 800.0, 0.0])
- self.assertEqual(result.get('labels'), [formatdate('2018-12-30'), formatdate('2019-01-06'), formatdate('2019-01-13'), formatdate('2019-01-20')])
+ self.assertEqual(
+ result.get('labels'),
+ ['30-12-18', '06-01-19', '13-01-19', '20-01-19']
+ )
frappe.db.rollback()
diff --git a/frappe/desk/doctype/number_card/number_card.py b/frappe/desk/doctype/number_card/number_card.py
index d4a2b00c57..6bddd09fc7 100644
--- a/frappe/desk/doctype/number_card/number_card.py
+++ b/frappe/desk/doctype/number_card/number_card.py
@@ -8,6 +8,7 @@ from frappe.model.document import Document
from frappe.utils import cint
from frappe.model.naming import append_number_if_name_exists
from frappe.modules.export_file import export_to_files
+from frappe.config import get_modules_from_all_apps_for_user
class NumberCard(Document):
def autoname(self):
@@ -33,16 +34,24 @@ def get_permission_query_conditions(user=None):
return None
doctype_condition = False
+ module_condition = False
allowed_doctypes = [frappe.db.escape(doctype) for doctype in frappe.permissions.get_doctypes_with_read()]
+ allowed_modules = [frappe.db.escape(module.get('module_name')) for module in get_modules_from_all_apps_for_user()]
if allowed_doctypes:
doctype_condition = '`tabNumber Card`.`document_type` in ({allowed_doctypes})'.format(
allowed_doctypes=','.join(allowed_doctypes))
+ if allowed_modules:
+ module_condition = '''`tabNumber Card`.`module` in ({allowed_modules})
+ or `tabNumber Card`.`module` is NULL'''.format(
+ allowed_modules=','.join(allowed_modules))
return '''
- {doctype_condition}
- '''.format(doctype_condition=doctype_condition)
+ {doctype_condition}
+ and
+ {module_condition}
+ '''.format(doctype_condition=doctype_condition, module_condition=module_condition)
def has_permission(doc, ptype, user):
roles = frappe.get_roles(user)
diff --git a/frappe/desk/form/document_follow.py b/frappe/desk/form/document_follow.py
index 3aa3a4fa88..66164948f2 100644
--- a/frappe/desk/form/document_follow.py
+++ b/frappe/desk/form/document_follow.py
@@ -21,7 +21,7 @@ def follow_document(doctype, doc_name, user, force=False):
avoided for some doctype
follow only if track changes are set to 1
'''
- if (doctype in ("Communication", "ToDo", "Email Unsubscribe", "File", "Comment")
+ if (doctype in ("Communication", "ToDo", "Email Unsubscribe", "File", "Comment", "Email Account", "Email Domain")
or doctype in log_types):
return
diff --git a/frappe/email/doctype/email_template/email_template.json b/frappe/email/doctype/email_template/email_template.json
index 0d0922f16f..dc73acacc1 100644
--- a/frappe/email/doctype/email_template/email_template.json
+++ b/frappe/email/doctype/email_template/email_template.json
@@ -1,4 +1,5 @@
{
+ "actions": [],
"allow_import": 1,
"allow_rename": 1,
"autoname": "Prompt",
@@ -8,6 +9,8 @@
"engine": "InnoDB",
"field_order": [
"subject",
+ "use_html",
+ "response_html",
"response",
"owner",
"section_break_4",
@@ -22,11 +25,12 @@
"reqd": 1
},
{
+ "depends_on": "eval:!doc.use_html",
"fieldname": "response",
"fieldtype": "Text Editor",
"in_list_view": 1,
"label": "Response",
- "reqd": 1
+ "mandatory_depends_on": "eval:!doc.use_html"
},
{
"default": "user",
@@ -45,10 +49,24 @@
"fieldtype": "HTML",
"label": "Email Reply Help",
"options": "Email Reply Example
\n\nOrder Overdue\n\nTransaction {{ name }} has exceeded Due Date. Please take necessary action.\n\nDetails\n\n- Customer: {{ customer }}\n- Amount: {{ grand_total }}\n\n\nHow to get fieldnames
\n\nThe fieldnames you can use in your email template are the fields in the document from which you are sending the email. You can find out the fields of any documents via Setup > Customize Form View and selecting the document type (e.g. Sales Invoice)
\n\nTemplating
\n\nTemplates are compiled using the Jinja Templating Language. To learn more about Jinja, read this documentation.
\n"
+ },
+ {
+ "default": "0",
+ "fieldname": "use_html",
+ "fieldtype": "Check",
+ "label": "Use HTML"
+ },
+ {
+ "depends_on": "eval:doc.use_html",
+ "fieldname": "response_html",
+ "fieldtype": "Code",
+ "label": "Response ",
+ "options": "HTML"
}
],
"icon": "fa fa-comment",
- "modified": "2019-10-30 14:15:00.956347",
+ "links": [],
+ "modified": "2020-11-30 14:12:50.321633",
"modified_by": "Administrator",
"module": "Email",
"name": "Email Template",
diff --git a/frappe/email/doctype/email_template/email_template.py b/frappe/email/doctype/email_template/email_template.py
index 2743032331..6708e9dd3f 100644
--- a/frappe/email/doctype/email_template/email_template.py
+++ b/frappe/email/doctype/email_template/email_template.py
@@ -9,7 +9,29 @@ from six import string_types
class EmailTemplate(Document):
def validate(self):
- validate_template(self.response)
+ if self.use_html:
+ validate_template(self.response_html)
+ else:
+ validate_template(self.response)
+
+ def get_formatted_subject(self, doc):
+ return frappe.render_template(self.subject, doc)
+
+ def get_formatted_response(self, doc):
+ if self.use_html:
+ return frappe.render_template(self.response_html, doc)
+
+ return frappe.render_template(self.response, doc)
+
+ def get_formatted_email(self, doc):
+ if isinstance(doc, string_types):
+ doc = json.loads(doc)
+
+ return {
+ "subject" : self.get_formatted_subject(doc),
+ "message" : self.get_formatted_response(doc)
+ }
+
@frappe.whitelist()
def get_email_template(template_name, doc):
@@ -18,5 +40,4 @@ def get_email_template(template_name, doc):
doc = json.loads(doc)
email_template = frappe.get_doc("Email Template", template_name)
- return {"subject" : frappe.render_template(email_template.subject, doc),
- "message" : frappe.render_template(email_template.response, doc)}
\ No newline at end of file
+ return email_template.get_formatted_email(doc)
\ No newline at end of file
diff --git a/frappe/email/doctype/newsletter/newsletter.py b/frappe/email/doctype/newsletter/newsletter.py
index a4d60706eb..2791ebb75b 100755
--- a/frappe/email/doctype/newsletter/newsletter.py
+++ b/frappe/email/doctype/newsletter/newsletter.py
@@ -85,11 +85,11 @@ class Newsletter(WebsiteGenerator):
self.db_set("scheduled_to_send", len(self.recipients))
def get_message(self):
-
+ if self.content_type == "HTML":
+ return frappe.render_template(self.message_html, {"doc": self.as_dict()})
return {
'Rich Text': self.message,
- 'Markdown': markdown(self.message_md),
- 'HTML': self.message_html
+ 'Markdown': markdown(self.message_md)
}[self.content_type or 'Rich Text']
def get_recipients(self):
diff --git a/frappe/email/doctype/notification/notification.js b/frappe/email/doctype/notification/notification.js
index 2cc027acd6..27fcd0e453 100644
--- a/frappe/email/doctype/notification/notification.js
+++ b/frappe/email/doctype/notification/notification.js
@@ -97,14 +97,7 @@ frappe.notification = {
},
setup_example_message: function(frm) {
let template = '';
- if (frm.doc.channel === 'WhatsApp') {
- template = `Warning:
Only Use Pre-Approved WhatsApp for Business Template
-Message Example
-
-
-Your appointment is coming up on {{ doc.date }} at {{ doc.time }}
-`;
- } else if (frm.doc.channel === 'Email') {
+ if (frm.doc.channel === 'Email') {
template = `Message Example
<h3>Order Overdue</h3>
@@ -124,7 +117,7 @@ Last comment: {{ comments[-1].comment }} by {{ comments[-1].by }}
</ul>
`;
- } else {
+ } else if (in_list(['Slack', 'System Notification', 'SMS'], frm.doc.channel)) {
template = `Message Example
*Order Overdue*
@@ -142,7 +135,9 @@ Last comment: {{ comments[-1].comment }} by {{ comments[-1].by }}
• Amount: {{ doc.grand_total }}
`;
}
- frm.set_df_property('message_examples', 'options', template);
+ if (template) {
+ frm.set_df_property('message_examples', 'options', template);
+ }
}
};
diff --git a/frappe/email/doctype/notification/notification.json b/frappe/email/doctype/notification/notification.json
index 2a8ee1aeb1..c1c877efd4 100644
--- a/frappe/email/doctype/notification/notification.json
+++ b/frappe/email/doctype/notification/notification.json
@@ -10,7 +10,6 @@
"enabled",
"column_break_2",
"channel",
- "twilio_number",
"slack_webhook_url",
"filters",
"subject",
@@ -61,7 +60,7 @@
"fieldname": "channel",
"fieldtype": "Select",
"label": "Channel",
- "options": "Email\nSlack\nSystem Notification\nWhatsApp\nSMS",
+ "options": "Email\nSlack\nSystem Notification\nSMS",
"reqd": 1,
"set_only_once": 1
},
@@ -80,14 +79,14 @@
"label": "Filters"
},
{
- "depends_on": "eval: !in_list(['SMS', 'WhatsApp'], doc.channel)",
+ "depends_on": "eval: in_list(['Email', 'Slack', 'System Notification'], doc.channel)",
"description": "To add dynamic subject, use jinja tags like\n\n",
"fieldname": "subject",
"fieldtype": "Data",
"ignore_xss_filter": 1,
"in_list_view": 1,
"label": "Subject",
- "mandatory_depends_on": "eval:!in_list(['SMS', 'WhatsApp'], doc.channel)"
+ "mandatory_depends_on": "eval: in_list(['Email', 'Slack', 'System Notification'], doc.channel)"
},
{
"fieldname": "document_type",
@@ -208,7 +207,7 @@
"label": "Value To Be Set"
},
{
- "depends_on": "eval:in_list(['Email', 'SMS', 'WhatsApp'], doc.channel)",
+ "depends_on": "eval:doc.channel !=\"Slack\"",
"fieldname": "column_break_5",
"fieldtype": "Section Break",
"label": "Recipients"
@@ -263,15 +262,6 @@
"label": "Print Format",
"options": "Print Format"
},
- {
- "depends_on": "eval: doc.channel==='WhatsApp'",
- "description": "To use WhatsApp for Business, initialize Twilio Settings.",
- "fieldname": "twilio_number",
- "fieldtype": "Link",
- "label": "Twilio Number",
- "mandatory_depends_on": "eval: doc.channel==='WhatsApp'",
- "options": "Twilio Number Group"
- },
{
"default": "0",
"depends_on": "eval: doc.channel !== 'System Notification'",
@@ -291,7 +281,7 @@
"icon": "fa fa-envelope",
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2020-09-03 10:33:23.084590",
+ "modified": "2020-11-24 14:25:43.245677",
"modified_by": "Administrator",
"module": "Email",
"name": "Notification",
diff --git a/frappe/email/doctype/notification/notification.py b/frappe/email/doctype/notification/notification.py
index 62be313b82..2ea7a3785e 100644
--- a/frappe/email/doctype/notification/notification.py
+++ b/frappe/email/doctype/notification/notification.py
@@ -14,7 +14,6 @@ from frappe.utils.safe_exec import get_safe_globals
from frappe.modules.utils import export_module_json, get_doc_module
from six import string_types
from frappe.integrations.doctype.slack_webhook_url.slack_webhook_url import send_slack_message
-from frappe.integrations.doctype.twilio_settings.twilio_settings import send_whatsapp_message
from frappe.core.doctype.sms_settings.sms_settings import send_sms
from frappe.desk.doctype.notification_log.notification_log import enqueue_create_notification
@@ -29,7 +28,7 @@ class Notification(Document):
self.name = self.subject
def validate(self):
- if self.channel not in ('WhatsApp', 'SMS'):
+ if self.channel in ("Email", "Slack", "System Notification"):
validate_template(self.subject)
validate_template(self.message)
@@ -43,7 +42,6 @@ class Notification(Document):
self.validate_forbidden_types()
self.validate_condition()
self.validate_standard()
- self.validate_twilio_settings()
frappe.cache().hdel('notifications', self.document_type)
def on_update(self):
@@ -70,11 +68,6 @@ def get_context(context):
if self.is_standard and not frappe.conf.developer_mode:
frappe.throw(_('Cannot edit Standard Notification. To edit, please disable this and duplicate it'))
- def validate_twilio_settings(self):
- if self.enabled and self.channel == "WhatsApp" \
- and not frappe.db.get_single_value("Twilio Settings", "enabled"):
- frappe.throw(_("Please enable Twilio settings to send WhatsApp messages"))
-
def validate_condition(self):
temp_doc = frappe.new_doc(self.document_type)
if self.condition:
@@ -137,9 +130,6 @@ def get_context(context):
if self.channel == 'Slack':
self.send_a_slack_msg(doc, context)
- if self.channel == 'WhatsApp':
- self.send_whatsapp_msg(doc, context)
-
if self.channel == 'SMS':
self.send_sms(doc, context)
@@ -191,6 +181,7 @@ def get_context(context):
'document_type': doc.doctype,
'document_name': doc.name,
'subject': subject,
+ 'from_user': doc.modified_by or doc.owner,
'email_content': frappe.render_template(self.message, context),
'attached_file': attachments and json.dumps(attachments[0])
}
@@ -230,13 +221,6 @@ def get_context(context):
reference_doctype=doc.doctype,
reference_name=doc.name)
- def send_whatsapp_msg(self, doc, context):
- send_whatsapp_message(
- sender=self.twilio_number,
- receiver_list=self.get_receiver_list(doc, context),
- message=frappe.render_template(self.message, context),
- )
-
def send_sms(self, doc, context):
send_sms(
receiver_list=self.get_receiver_list(doc, context),
@@ -302,7 +286,7 @@ def get_context(context):
# For sending messages to the owner's mobile phone number
if recipient.receiver_by_document_field == 'owner':
- receiver_list.append(get_user_info(doc.get('owner'), 'mobile_no'))
+ receiver_list += get_user_info([dict(user_name=doc.get('owner'))], 'mobile_no')
# For sending messages to the number specified in the receiver field
elif recipient.receiver_by_document_field:
receiver_list.append(doc.get(recipient.receiver_by_document_field))
diff --git a/frappe/email/smtp.py b/frappe/email/smtp.py
index f53b835757..9ba81fa146 100644
--- a/frappe/email/smtp.py
+++ b/frappe/email/smtp.py
@@ -210,10 +210,9 @@ class SMTPServer:
try:
if self.use_ssl:
if not self.port:
- self.smtp_port = 465
+ self.port = 465
- self._sess = smtplib.SMTP_SSL((self.server or "").encode('utf-8'),
- cint(self.port) or None)
+ self._sess = smtplib.SMTP_SSL((self.server or ""), cint(self.port))
else:
if self.use_tls and not self.port:
self.port = 587
diff --git a/frappe/email/test_smtp.py b/frappe/email/test_smtp.py
new file mode 100644
index 0000000000..869d708430
--- /dev/null
+++ b/frappe/email/test_smtp.py
@@ -0,0 +1,25 @@
+# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
+# License: The MIT License
+
+import unittest
+from frappe.email.smtp import SMTPServer
+
+class TestSMTP(unittest.TestCase):
+ def test_smtp_ssl_session(self):
+ for port in [None, 0, 465, "465"]:
+ make_server(port, 1, 0)
+
+ def test_smtp_tls_session(self):
+ for port in [None, 0, 587, "587"]:
+ make_server(port, 0, 1)
+
+
+def make_server(port, ssl, tls):
+ server = SMTPServer(
+ server = "smtp.gmail.com",
+ port = port,
+ use_ssl = ssl,
+ use_tls = tls
+ )
+
+ server.sess
\ No newline at end of file
diff --git a/frappe/event_streaming/doctype/event_consumer/event_consumer.py b/frappe/event_streaming/doctype/event_consumer/event_consumer.py
index 1505c3a05d..5789e09e74 100644
--- a/frappe/event_streaming/doctype/event_consumer/event_consumer.py
+++ b/frappe/event_streaming/doctype/event_consumer/event_consumer.py
@@ -31,10 +31,12 @@ class EventConsumer(Document):
self.update_consumer_status()
else:
frappe.db.set_value(self.doctype, self.name, 'incoming_change', 0)
-
+
frappe.cache().delete_value('event_consumer_document_type_map')
def on_trash(self):
+ for i in frappe.get_all('Event Update Log Consumer', {'consumer': self.name}):
+ frappe.delete_doc('Event Update Log Consumer', i.name)
frappe.cache().delete_value('event_consumer_document_type_map')
def update_consumer_status(self):
@@ -88,8 +90,9 @@ def register_consumer(data):
for entry in consumer_doctypes:
consumer.append('consumer_doctypes', {
- 'ref_doctype': entry,
- 'status': 'Pending'
+ 'ref_doctype': entry.get('doctype'),
+ 'status': 'Pending',
+ 'condition': entry.get('condition')
})
consumer.insert()
@@ -153,3 +156,53 @@ def notify(consumer):
jobs = get_jobs()
if not jobs or enqueued_method not in jobs[frappe.local.site] and not consumer.flags.notifed:
frappe.enqueue(enqueued_method, queue='long', enqueue_after_commit=True, **{'consumer': consumer})
+
+
+def has_consumer_access(consumer, update_log):
+ """Checks if consumer has completely satisfied all the conditions on the doc"""
+
+ if isinstance(consumer, str):
+ consumer = frappe.get_doc('Event Consumer', consumer)
+
+ if not frappe.db.exists(update_log.ref_doctype, update_log.docname):
+ # Delete Log
+ # Check if the last Update Log of this document was read by this consumer
+ last_update_log = frappe.get_all(
+ 'Event Update Log',
+ filters={
+ 'ref_doctype': update_log.ref_doctype,
+ 'docname': update_log.docname,
+ 'creation': ['<', update_log.creation]
+ },
+ order_by='creation desc',
+ limit_page_length=1
+ )
+ if not len(last_update_log):
+ return False
+
+ last_update_log = frappe.get_doc('Event Update Log', last_update_log[0].name)
+ return len([x for x in last_update_log.consumers if x.consumer == consumer.name])
+
+ doc = frappe.get_doc(update_log.ref_doctype, update_log.docname)
+ try:
+ for dt_entry in consumer.consumer_doctypes:
+ if dt_entry.ref_doctype != update_log.ref_doctype:
+ continue
+
+ if not dt_entry.condition:
+ return True
+
+ condition: str = dt_entry.condition
+ if condition.startswith('cmd:'):
+ cmd = condition.split('cmd:')[1].strip()
+ args = {
+ 'consumer': consumer,
+ 'doc': doc,
+ 'update_log': update_log
+ }
+ return frappe.call(cmd, **args)
+ else:
+ return frappe.safe_eval(condition, frappe._dict(doc=doc))
+ except Exception as e:
+ frappe.log_error(title='has_consumer_access error', message=e)
+ return False
\ No newline at end of file
diff --git a/frappe/event_streaming/doctype/event_consumer_document_type/event_consumer_document_type.json b/frappe/event_streaming/doctype/event_consumer_document_type/event_consumer_document_type.json
index 71dcc63127..c243334a09 100644
--- a/frappe/event_streaming/doctype/event_consumer_document_type/event_consumer_document_type.json
+++ b/frappe/event_streaming/doctype/event_consumer_document_type/event_consumer_document_type.json
@@ -7,7 +7,8 @@
"field_order": [
"ref_doctype",
"status",
- "unsubscribed"
+ "unsubscribed",
+ "condition"
],
"fields": [
{
@@ -37,11 +38,17 @@
"in_list_view": 1,
"label": "Unsubscribed",
"read_only": 1
+ },
+ {
+ "fieldname": "condition",
+ "fieldtype": "Code",
+ "label": "Condition",
+ "read_only": 1
}
],
"istable": 1,
"links": [],
- "modified": "2020-08-14 12:38:40.918620",
+ "modified": "2020-11-07 09:26:49.894294",
"modified_by": "Administrator",
"module": "Event Streaming",
"name": "Event Consumer Document Type",
diff --git a/frappe/event_streaming/doctype/event_producer/event_producer.py b/frappe/event_streaming/doctype/event_producer/event_producer.py
index d458f3c24b..d8a6a55510 100644
--- a/frappe/event_streaming/doctype/event_producer/event_producer.py
+++ b/frappe/event_streaming/doctype/event_producer/event_producer.py
@@ -102,9 +102,13 @@ class EventProducer(Document):
for entry in self.producer_doctypes:
if entry.has_mapping:
# if mapping, subscribe to remote doctype on consumer's site
- consumer_doctypes.append(frappe.db.get_value('Document Type Mapping', entry.mapping, 'remote_doctype'))
+ dt = frappe.db.get_value('Document Type Mapping', entry.mapping, 'remote_doctype')
else:
- consumer_doctypes.append(entry.ref_doctype)
+ dt = entry.ref_doctype
+ consumer_doctypes.append({
+ "doctype": dt,
+ "condition": entry.condition
+ })
user_key = frappe.db.get_value('User', self.user, 'api_key')
user_secret = get_decrypted_password('User', self.user, 'api_secret')
@@ -145,7 +149,8 @@ class EventProducer(Document):
event_consumer.consumer_doctypes.append({
'ref_doctype': ref_doctype,
'status': get_approval_status(config, ref_doctype),
- 'unsubscribed': entry.unsubscribe
+ 'unsubscribed': entry.unsubscribe,
+ 'condition': entry.condition
})
event_consumer.user = self.user
event_consumer.incoming_change = True
@@ -347,13 +352,13 @@ def set_delete(update):
def get_updates(producer_site, last_update, doctypes):
"""Get all updates generated after the last update timestamp"""
- docs = producer_site.get_list(
- doctype='Event Update Log',
- filters={'ref_doctype': ('in', doctypes), 'creation': ('>', last_update)},
- fields=['update_type', 'ref_doctype', 'docname', 'data', 'name', 'creation']
- )
- docs.reverse()
- return [frappe._dict(d) for d in docs]
+ docs = producer_site.post_request({
+ 'cmd': 'frappe.event_streaming.doctype.event_update_log.event_update_log.get_update_logs_for_consumer',
+ 'event_consumer': get_url(),
+ 'doctypes': frappe.as_json(doctypes),
+ 'last_update': last_update
+ })
+ return [frappe._dict(d) for d in (docs or [])]
def get_local_doc(update):
diff --git a/frappe/event_streaming/doctype/event_producer/test_event_producer.py b/frappe/event_streaming/doctype/event_producer/test_event_producer.py
index fa2461a9d8..4c259c3729 100644
--- a/frappe/event_streaming/doctype/event_producer/test_event_producer.py
+++ b/frappe/event_streaming/doctype/event_producer/test_event_producer.py
@@ -152,6 +152,82 @@ class TestEventProducer(unittest.TestCase):
reset_configuration(producer_url)
+ def test_conditional_events(self):
+ producer = get_remote_site()
+
+ # Add Condition
+ event_producer = frappe.get_doc('Event Producer', producer_url)
+ note_producer_entry = [
+ x for x in event_producer.producer_doctypes if x.ref_doctype == 'Note'
+ ][0]
+ note_producer_entry.condition = 'doc.public == 1'
+ event_producer.save()
+
+ # Make test doc
+ producer_note1 = frappe._dict(doctype='Note', public=0, title='test conditional sync')
+ delete_on_remote_if_exists(producer, 'Note', {'title': producer_note1['title']})
+ producer_note1 = producer.insert(producer_note1)
+
+ # Make Update
+ producer_note1['content'] = 'Test Conditional Sync Content'
+ producer_note1 = producer.update(producer_note1)
+
+ self.pull_producer_data()
+
+ # Check if synced here
+ self.assertFalse(frappe.db.exists('Note', producer_note1.name))
+
+ # Lets satisfy the condition
+ producer_note1['public'] = 1
+ producer_note1 = producer.update(producer_note1)
+
+ self.pull_producer_data()
+
+ # it should sync now
+ self.assertTrue(frappe.db.exists('Note', producer_note1.name))
+ local_note = frappe.get_doc('Note', producer_note1.name)
+ self.assertEqual(local_note.content, producer_note1.content)
+
+ reset_configuration(producer_url)
+
+ def test_conditional_events_with_cmd(self):
+ producer = get_remote_site()
+
+ # Add Condition
+ event_producer = frappe.get_doc('Event Producer', producer_url)
+ note_producer_entry = [
+ x for x in event_producer.producer_doctypes if x.ref_doctype == 'Note'
+ ][0]
+ note_producer_entry.condition = 'cmd: frappe.event_streaming.doctype.event_producer.test_event_producer.can_sync_note'
+ event_producer.save()
+
+ # Make test doc
+ producer_note1 = frappe._dict(doctype='Note', public=0, title='test conditional sync cmd')
+ delete_on_remote_if_exists(producer, 'Note', {'title': producer_note1['title']})
+ producer_note1 = producer.insert(producer_note1)
+
+ # Make Update
+ producer_note1['content'] = 'Test Conditional Sync Content'
+ producer_note1 = producer.update(producer_note1)
+
+ self.pull_producer_data()
+
+ # Check if synced here
+ self.assertFalse(frappe.db.exists('Note', producer_note1.name))
+
+ # Lets satisfy the condition
+ producer_note1['public'] = 1
+ producer_note1 = producer.update(producer_note1)
+
+ self.pull_producer_data()
+
+ # it should sync now
+ self.assertTrue(frappe.db.exists('Note', producer_note1.name))
+ local_note = frappe.get_doc('Note', producer_note1.name)
+ self.assertEqual(local_note.content, producer_note1.content)
+
+ reset_configuration(producer_url)
+
def test_update_log(self):
producer = get_remote_site()
producer_doc = insert_into_producer(producer, 'test update log')
@@ -221,6 +297,8 @@ class TestEventProducer(unittest.TestCase):
reset_configuration(producer_url)
+def can_sync_note(consumer, doc, update_log):
+ return doc.public == 1
def setup_event_producer_for_inner_mapping():
event_producer = frappe.get_doc('Event Producer', producer_url, for_update=True)
@@ -322,6 +400,7 @@ def create_event_producer(producer_url):
def reset_configuration(producer_url):
event_producer = frappe.get_doc('Event Producer', producer_url, for_update=True)
event_producer.producer_doctypes = []
+ event_producer.conditions = []
event_producer.producer_url = producer_url
event_producer.append('producer_doctypes', {
'ref_doctype': 'ToDo',
diff --git a/frappe/event_streaming/doctype/event_producer_document_type/event_producer_document_type.json b/frappe/event_streaming/doctype/event_producer_document_type/event_producer_document_type.json
index e5fe9497f8..17fd51d12d 100644
--- a/frappe/event_streaming/doctype/event_producer_document_type/event_producer_document_type.json
+++ b/frappe/event_streaming/doctype/event_producer_document_type/event_producer_document_type.json
@@ -10,7 +10,8 @@
"use_same_name",
"unsubscribe",
"has_mapping",
- "mapping"
+ "mapping",
+ "condition"
],
"fields": [
{
@@ -63,11 +64,16 @@
"fieldtype": "Check",
"in_list_view": 1,
"label": "Unsubscribe"
+ },
+ {
+ "fieldname": "condition",
+ "fieldtype": "Code",
+ "label": "Condition"
}
],
"istable": 1,
"links": [],
- "modified": "2020-08-14 11:38:01.278996",
+ "modified": "2020-11-07 09:26:58.463868",
"modified_by": "Administrator",
"module": "Event Streaming",
"name": "Event Producer Document Type",
diff --git a/frappe/event_streaming/doctype/event_update_log/event_update_log.json b/frappe/event_streaming/doctype/event_update_log/event_update_log.json
index 452a656b8b..a42bc7ec87 100644
--- a/frappe/event_streaming/doctype/event_update_log/event_update_log.json
+++ b/frappe/event_streaming/doctype/event_update_log/event_update_log.json
@@ -1,4 +1,5 @@
{
+ "actions": [],
"creation": "2019-07-30 15:31:26.352527",
"doctype": "DocType",
"editable_grid": 1,
@@ -7,7 +8,8 @@
"update_type",
"ref_doctype",
"docname",
- "data"
+ "data",
+ "consumers"
],
"fields": [
{
@@ -31,7 +33,6 @@
"fieldtype": "Data",
"in_list_view": 1,
"label": "Document Name",
- "options": "ref_doctype",
"read_only": 1
},
{
@@ -39,10 +40,18 @@
"fieldtype": "Code",
"label": "Data",
"read_only": 1
+ },
+ {
+ "fieldname": "consumers",
+ "fieldtype": "Table MultiSelect",
+ "label": "Consumers",
+ "options": "Event Update Log Consumer",
+ "read_only": 1
}
],
"in_create": 1,
- "modified": "2019-09-24 23:16:07.207707",
+ "links": [],
+ "modified": "2020-09-04 07:31:52.599804",
"modified_by": "Administrator",
"module": "Event Streaming",
"name": "Event Update Log",
diff --git a/frappe/event_streaming/doctype/event_update_log/event_update_log.py b/frappe/event_streaming/doctype/event_update_log/event_update_log.py
index 646331a02c..1c31718c2b 100644
--- a/frappe/event_streaming/doctype/event_update_log/event_update_log.py
+++ b/frappe/event_streaming/doctype/event_update_log/event_update_log.py
@@ -140,3 +140,137 @@ def check_docstatus(out, old, new, for_child):
if not for_child and old.docstatus != new.docstatus:
out.changed['docstatus'] = new.docstatus
return out
+
+
+def is_consumer_uptodate(update_log, consumer):
+ """
+ Checks if Consumer has read all the UpdateLogs before the specified update_log
+ :param update_log: The UpdateLog Doc in context
+ :param consumer: The EventConsumer doc
+ """
+ if update_log.update_type == 'Create':
+ # consumer is obviously up to date
+ return True
+
+ prev_logs = frappe.get_all(
+ 'Event Update Log',
+ filters={
+ 'ref_doctype': update_log.ref_doctype,
+ 'docname': update_log.docname,
+ 'creation': ['<', update_log.creation]
+ },
+ order_by='creation desc',
+ limit_page_length=1
+ )
+
+ if not len(prev_logs):
+ return False
+
+ prev_log_consumers = frappe.get_all(
+ 'Event Update Log Consumer',
+ fields=['consumer'],
+ filters={
+ 'parent': prev_logs[0].name,
+ 'parenttype': 'Event Update Log',
+ 'consumer': consumer.name
+ }
+ )
+
+ return len(prev_log_consumers) > 0
+
+
+def mark_consumer_read(update_log_name, consumer_name):
+ """
+ This function appends the Consumer to the list of Consumers that has 'read' an Update Log
+ """
+ update_log = frappe.get_doc('Event Update Log', update_log_name)
+ if len([x for x in update_log.consumers if x.consumer == consumer_name]):
+ return
+
+ frappe.get_doc(frappe._dict(
+ doctype='Event Update Log Consumer',
+ consumer=consumer_name,
+ parent=update_log_name,
+ parenttype='Event Update Log',
+ parentfield='consumers'
+ )).insert(ignore_permissions=True)
+
+
+def get_unread_update_logs(consumer_name, dt, dn):
+ """
+ Get old logs unread by the consumer on a particular document
+ """
+ already_consumed = [x[0] for x in frappe.db.sql("""
+ SELECT
+ update_log.name
+ FROM `tabEvent Update Log` update_log
+ JOIN `tabEvent Update Log Consumer` consumer ON consumer.parent = update_log.name
+ WHERE
+ consumer.consumer = %(consumer)s
+ AND update_log.ref_doctype = %(dt)s
+ AND update_log.docname = %(dn)s
+ """, {'consumer': consumer_name, "dt": dt, "dn": dn}, as_dict=0)]
+
+ logs = frappe.get_all(
+ 'Event Update Log',
+ fields=['update_type', 'ref_doctype',
+ 'docname', 'data', 'name', 'creation'],
+ filters={
+ 'ref_doctype': dt,
+ 'docname': dn,
+ 'name': ['not in', already_consumed]
+ },
+ order_by='creation'
+ )
+
+ return logs
+
+
+@frappe.whitelist()
+def get_update_logs_for_consumer(event_consumer, doctypes, last_update):
+ """
+ Fetches all the UpdateLogs for the consumer
+ It will inject old un-consumed Update Logs if a doc was just found to be accessible to the Consumer
+ """
+
+ if isinstance(doctypes, str):
+ doctypes = frappe.parse_json(doctypes)
+
+ from frappe.event_streaming.doctype.event_consumer.event_consumer import has_consumer_access
+
+ consumer = frappe.get_doc('Event Consumer', event_consumer)
+ docs = frappe.get_list(
+ doctype='Event Update Log',
+ filters={'ref_doctype': ('in', doctypes),
+ 'creation': ('>', last_update)},
+ fields=['update_type', 'ref_doctype',
+ 'docname', 'data', 'name', 'creation'],
+ order_by='creation desc'
+ )
+
+ result = []
+ to_update_history = []
+ for d in docs:
+ if (d.ref_doctype, d.docname) in to_update_history:
+ # will be notified by background jobs
+ continue
+
+ if not has_consumer_access(consumer=consumer, update_log=d):
+ continue
+
+ if not is_consumer_uptodate(d, consumer):
+ to_update_history.append((d.ref_doctype, d.docname))
+ # get_unread_update_logs will have the current log
+ old_logs = get_unread_update_logs(consumer.name, d.ref_doctype, d.docname)
+ if old_logs:
+ old_logs.reverse()
+ result.extend(old_logs)
+ else:
+ result.append(d)
+
+
+ for d in result:
+ mark_consumer_read(update_log_name=d.name, consumer_name=consumer.name)
+
+ result.reverse()
+ return result
\ No newline at end of file
diff --git a/frappe/integrations/doctype/twilio_number_group/__init__.py b/frappe/event_streaming/doctype/event_update_log_consumer/__init__.py
similarity index 100%
rename from frappe/integrations/doctype/twilio_number_group/__init__.py
rename to frappe/event_streaming/doctype/event_update_log_consumer/__init__.py
diff --git a/frappe/event_streaming/doctype/event_update_log_consumer/event_update_log_consumer.json b/frappe/event_streaming/doctype/event_update_log_consumer/event_update_log_consumer.json
new file mode 100644
index 0000000000..b3484c6481
--- /dev/null
+++ b/frappe/event_streaming/doctype/event_update_log_consumer/event_update_log_consumer.json
@@ -0,0 +1,32 @@
+{
+ "actions": [],
+ "creation": "2020-06-30 10:54:53.301787",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "consumer"
+ ],
+ "fields": [
+ {
+ "fieldname": "consumer",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Consumer",
+ "options": "Event Consumer",
+ "reqd": 1
+ }
+ ],
+ "istable": 1,
+ "links": [],
+ "modified": "2020-06-30 10:54:53.301787",
+ "modified_by": "Administrator",
+ "module": "Event Streaming",
+ "name": "Event Update Log Consumer",
+ "owner": "Administrator",
+ "permissions": [],
+ "quick_entry": 1,
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/frappe/integrations/doctype/twilio_number_group/twilio_number_group.py b/frappe/event_streaming/doctype/event_update_log_consumer/event_update_log_consumer.py
similarity index 85%
rename from frappe/integrations/doctype/twilio_number_group/twilio_number_group.py
rename to frappe/event_streaming/doctype/event_update_log_consumer/event_update_log_consumer.py
index 04cb9ae146..ee6d5d8ca9 100644
--- a/frappe/integrations/doctype/twilio_number_group/twilio_number_group.py
+++ b/frappe/event_streaming/doctype/event_update_log_consumer/event_update_log_consumer.py
@@ -6,5 +6,5 @@ from __future__ import unicode_literals
# import frappe
from frappe.model.document import Document
-class TwilioNumberGroup(Document):
+class EventUpdateLogConsumer(Document):
pass
diff --git a/frappe/exceptions.py b/frappe/exceptions.py
index 60c17f6d5c..82fbff7a90 100644
--- a/frappe/exceptions.py
+++ b/frappe/exceptions.py
@@ -106,7 +106,10 @@ class InvalidDates(ValidationError): pass
class DataTooLongException(ValidationError): pass
class FileAlreadyAttachedException(Exception): pass
class DocumentAlreadyRestored(Exception): pass
+class AttachmentLimitReached(Exception): pass
# OAuth exceptions
class InvalidAuthorizationHeader(CSRFTokenError): pass
class InvalidAuthorizationPrefix(CSRFTokenError): pass
class InvalidAuthorizationToken(CSRFTokenError): pass
+class InvalidDatabaseFile(ValidationError): pass
+class ExecutableNotFound(FileNotFoundError): pass
diff --git a/frappe/hooks.py b/frappe/hooks.py
index d8c8cd841c..3d7ae0abb4 100644
--- a/frappe/hooks.py
+++ b/frappe/hooks.py
@@ -94,6 +94,7 @@ permission_query_conditions = {
"User": "frappe.core.doctype.user.user.get_permission_query_conditions",
"Dashboard Settings": "frappe.desk.doctype.dashboard_settings.dashboard_settings.get_permission_query_conditions",
"Notification Log": "frappe.desk.doctype.notification_log.notification_log.get_permission_query_conditions",
+ "Dashboard": "frappe.desk.doctype.dashboard.dashboard.get_permission_query_conditions",
"Dashboard Chart": "frappe.desk.doctype.dashboard_chart.dashboard_chart.get_permission_query_conditions",
"Number Card": "frappe.desk.doctype.number_card.number_card.get_permission_query_conditions",
"Notification Settings": "frappe.desk.doctype.notification_settings.notification_settings.get_permission_query_conditions",
diff --git a/frappe/installer.py b/frappe/installer.py
index df767a3294..a11c8dfbfa 100755
--- a/frappe/installer.py
+++ b/frappe/installer.py
@@ -3,8 +3,90 @@
import json
import os
-from frappe.defaults import _clear_cache
+import sys
+
import frappe
+from frappe.defaults import _clear_cache
+
+
+def _new_site(
+ db_name,
+ site,
+ mariadb_root_username=None,
+ mariadb_root_password=None,
+ admin_password=None,
+ verbose=False,
+ install_apps=None,
+ source_sql=None,
+ force=False,
+ no_mariadb_socket=False,
+ reinstall=False,
+ db_password=None,
+ db_type=None,
+ db_host=None,
+ db_port=None,
+ new_site=False,
+):
+ """Install a new Frappe site"""
+
+ if not force and os.path.exists(site):
+ print("Site {0} already exists".format(site))
+ sys.exit(1)
+
+ if no_mariadb_socket and not db_type == "mariadb":
+ print("--no-mariadb-socket requires db_type to be set to mariadb.")
+ sys.exit(1)
+
+ if not db_name:
+ import hashlib
+ db_name = "_" + hashlib.sha1(site.encode()).hexdigest()[:16]
+
+ frappe.init(site=site)
+
+ from frappe.commands.scheduler import _is_scheduler_enabled
+ from frappe.utils import get_site_path, scheduler, touch_file
+
+ try:
+ # enable scheduler post install?
+ enable_scheduler = _is_scheduler_enabled()
+ except Exception:
+ enable_scheduler = False
+
+ make_site_dirs()
+
+ installing = touch_file(get_site_path("locks", "installing.lock"))
+
+ install_db(
+ root_login=mariadb_root_username,
+ root_password=mariadb_root_password,
+ db_name=db_name,
+ admin_password=admin_password,
+ verbose=verbose,
+ source_sql=source_sql,
+ force=force,
+ reinstall=reinstall,
+ db_password=db_password,
+ db_type=db_type,
+ db_host=db_host,
+ db_port=db_port,
+ no_mariadb_socket=no_mariadb_socket,
+ )
+ apps_to_install = (
+ ["frappe"] + (frappe.conf.get("install_apps") or []) + (list(install_apps) or [])
+ )
+
+ for app in apps_to_install:
+ install_app(app, verbose=verbose, set_as_patched=not source_sql)
+
+ os.remove(installing)
+
+ scheduler.toggle_scheduler(enable_scheduler)
+ frappe.db.commit()
+
+ scheduler_status = (
+ "disabled" if frappe.utils.scheduler.is_scheduler_disabled() else "enabled"
+ )
+ print("*** Scheduler is", scheduler_status, "***")
def install_db(root_login="root", root_password=None, db_name=None, source_sql=None,
@@ -36,9 +118,9 @@ def install_db(root_login="root", root_password=None, db_name=None, source_sql=N
def install_app(name, verbose=False, set_as_patched=True):
from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs
- from frappe.utils.fixtures import sync_fixtures
from frappe.model.sync import sync_for
from frappe.modules.utils import sync_customizations
+ from frappe.utils.fixtures import sync_fixtures
frappe.flags.in_install = name
frappe.flags.ignore_in_install = False
@@ -122,64 +204,80 @@ def remove_app(app_name, dry_run=False, yes=False, no_backup=False, force=False)
"""Remove app and all linked to the app's module with the app from a site."""
import click
+ site = frappe.local.site
+
# dont allow uninstall app if not installed unless forced
if not force:
if app_name not in frappe.get_installed_apps():
- click.secho("App {0} not installed on Site {1}".format(app_name, frappe.local.site), fg="yellow")
+ click.secho(f"App {app_name} not installed on Site {site}", fg="yellow")
return
- print("Uninstalling App {0} from Site {1}...".format(app_name, frappe.local.site))
+ print(f"Uninstalling App {app_name} from Site {site}...")
if not dry_run and not yes:
- confirm = click.confirm("All doctypes (including custom), modules related to this app will be deleted. Are you sure you want to continue?")
+ confirm = click.confirm(
+ "All doctypes (including custom), modules related to this app will be"
+ " deleted. Are you sure you want to continue?"
+ )
if not confirm:
return
- if not no_backup:
+ if not (dry_run or no_backup):
from frappe.utils.backups import scheduled_backup
+
print("Backing up...")
scheduled_backup(ignore_files=True)
frappe.flags.in_uninstall = True
drop_doctypes = []
- modules = (x.name for x in frappe.get_all("Module Def", filters={"app_name": app_name}))
+ modules = frappe.get_all("Module Def", filters={"app_name": app_name}, pluck="name")
for module_name in modules:
- print("Deleting Module '{0}'".format(module_name))
+ print(f"Deleting Module '{module_name}'")
- for doctype in frappe.get_list("DocType", filters={"module": module_name}, fields=["name", "issingle"]):
- print("* removing DocType '{0}'...".format(doctype.name))
+ for doctype in frappe.get_all(
+ "DocType", filters={"module": module_name}, fields=["name", "issingle"]
+ ):
+ print(f"* removing DocType '{doctype.name}'...")
if not dry_run:
- frappe.delete_doc("DocType", doctype.name)
+ frappe.delete_doc("DocType", doctype.name, ignore_on_trash=True)
if not doctype.issingle:
drop_doctypes.append(doctype.name)
- linked_doctypes = frappe.get_all("DocField", filters={"fieldtype": "Link", "options": "Module Def"}, fields=['parent'])
+ linked_doctypes = frappe.get_all(
+ "DocField", filters={"fieldtype": "Link", "options": "Module Def"}, fields=["parent"]
+ )
ordered_doctypes = ["Desk Page", "Report", "Page", "Web Form"]
- doctypes_with_linked_modules = ordered_doctypes + [doctype.parent for doctype in linked_doctypes if doctype.parent not in ordered_doctypes]
-
+ all_doctypes_with_linked_modules = ordered_doctypes + [
+ doctype.parent
+ for doctype in linked_doctypes
+ if doctype.parent not in ordered_doctypes
+ ]
+ doctypes_with_linked_modules = [
+ x for x in all_doctypes_with_linked_modules if frappe.db.exists("DocType", x)
+ ]
for doctype in doctypes_with_linked_modules:
- for record in frappe.get_list(doctype, filters={"module": module_name}):
- print("* removing {0} '{1}'...".format(doctype, record.name))
+ for record in frappe.get_all(doctype, filters={"module": module_name}, pluck="name"):
+ print(f"* removing {doctype} '{record}'...")
if not dry_run:
- frappe.delete_doc(doctype, record.name)
+ frappe.delete_doc(doctype, record, ignore_on_trash=True, force=True)
- print("* removing Module Def '{0}'...".format(module_name))
+ print(f"* removing Module Def '{module_name}'...")
if not dry_run:
- frappe.delete_doc("Module Def", module_name)
+ frappe.delete_doc("Module Def", module_name, ignore_on_trash=True, force=True)
+
+ for doctype in set(drop_doctypes):
+ print(f"* dropping Table for '{doctype}'...")
+ if not dry_run:
+ frappe.db.sql_ddl(f"drop table `tab{doctype}`")
if not dry_run:
remove_from_installed_apps(app_name)
-
- for doctype in set(drop_doctypes):
- print("* dropping Table for '{0}'...".format(doctype))
- frappe.db.sql_ddl("drop table `tab{0}`".format(doctype))
-
frappe.db.commit()
- click.secho("Uninstalled App {0} from Site {1}".format(app_name, frappe.local.site), fg="green")
+ click.secho(f"Uninstalled App {app_name} from Site {site}", fg="green")
frappe.flags.in_uninstall = False
@@ -331,6 +429,28 @@ def remove_missing_apps():
frappe.db.set_global("installed_apps", json.dumps(installed_apps))
+def extract_sql_from_archive(sql_file_path):
+ """Return the path of an SQL file if the passed argument is the path of a gzipped
+ SQL file or an SQL file path. The path may be absolute or relative from the bench
+ root directory or the sites sub-directory.
+
+ Args:
+ sql_file_path (str): Path of the SQL file
+
+ Returns:
+ str: Path of the decompressed SQL file
+ """
+ from frappe.utils import get_bench_relative_path
+ sql_file_path = get_bench_relative_path(sql_file_path)
+ # Extract the gzip file if user has passed *.sql.gz file instead of *.sql file
+ if sql_file_path.endswith('sql.gz'):
+ decompressed_file_name = extract_sql_gzip(sql_file_path)
+ else:
+ decompressed_file_name = sql_file_path
+
+ return decompressed_file_name
+
+
def extract_sql_gzip(sql_gz_path):
import subprocess
@@ -345,9 +465,13 @@ def extract_sql_gzip(sql_gz_path):
return decompressed_file
-def extract_files(site_name, file_path, folder_name):
- import subprocess
+
+def extract_files(site_name, file_path):
import shutil
+ import subprocess
+ from frappe.utils import get_bench_relative_path
+
+ file_path = get_bench_relative_path(file_path)
# Need to do frappe.init to maintain the site locals
frappe.init(site=site_name)
@@ -375,6 +499,12 @@ def extract_files(site_name, file_path, folder_name):
def is_downgrade(sql_file_path, verbose=False):
"""checks if input db backup will get downgraded on current bench"""
+
+ # This function is only tested with mariadb
+ # TODO: Add postgres support
+ if frappe.conf.db_type not in (None, "mariadb"):
+ return False
+
from semantic_version import Version
head = "INSERT INTO `tabInstalled Application` VALUES"
@@ -406,3 +536,69 @@ def is_downgrade(sql_file_path, verbose=False):
print("Your site will be downgraded from Frappe {0} to {1}".format(current_version, backup_version))
return downgrade
+
+
+def is_partial(sql_file_path):
+ with open(sql_file_path) as f:
+ header = " ".join([f.readline() for _ in range(5)])
+ if "Partial Backup" in header:
+ return True
+ return False
+
+
+def partial_restore(sql_file_path, verbose=False):
+ sql_file = extract_sql_from_archive(sql_file_path)
+
+ if frappe.conf.db_type in (None, "mariadb"):
+ from frappe.database.mariadb.setup_db import import_db_from_sql
+ elif frappe.conf.db_type == "postgres":
+ from frappe.database.postgres.setup_db import import_db_from_sql
+ import warnings
+ from click import style
+ warn = style(
+ "Delete the tables you want to restore manually before attempting"
+ " partial restore operation for PostreSQL databases",
+ fg="yellow"
+ )
+ warnings.warn(warn)
+
+ import_db_from_sql(source_sql=sql_file, verbose=verbose)
+
+ # Removing temporarily created file
+ if sql_file != sql_file_path:
+ os.remove(sql_file)
+
+
+def validate_database_sql(path, _raise=True):
+ """Check if file has contents and if DefaultValue table exists
+
+ Args:
+ path (str): Path of the decompressed SQL file
+ _raise (bool, optional): Raise exception if invalid file. Defaults to True.
+ """
+ empty_file = False
+ missing_table = True
+
+ error_message = ""
+
+ if not os.path.getsize(path):
+ error_message = f"{path} is an empty file!"
+ empty_file = True
+
+ # dont bother checking if empty file
+ if not empty_file:
+ with open(path, "r") as f:
+ for line in f:
+ if 'tabDefaultValue' in line:
+ missing_table = False
+ break
+
+ if missing_table:
+ error_message = "Table `tabDefaultValue` not found in file."
+
+ if error_message:
+ import click
+ click.secho(error_message, fg="red")
+
+ if _raise and (missing_table or empty_file):
+ raise frappe.InvalidDatabaseFile
diff --git a/frappe/integrations/desk_page/integrations/integrations.json b/frappe/integrations/desk_page/integrations/integrations.json
index cbf7c9c085..1acf4e6c4a 100644
--- a/frappe/integrations/desk_page/integrations/integrations.json
+++ b/frappe/integrations/desk_page/integrations/integrations.json
@@ -23,7 +23,7 @@
{
"hidden": 0,
"label": "Settings",
- "links": "[\n {\n \"description\": \"Webhooks calling API requests into web apps\",\n \"label\": \"Webhook\",\n \"name\": \"Webhook\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Slack Webhooks for internal integration\",\n \"label\": \"Slack Webhook URL\",\n \"name\": \"Slack Webhook URL\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Twilio Settings for WhatsApp integration\",\n \"label\": \"Twilio Settings\",\n \"name\": \"Twilio Settings\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"SMS Settings for sending sms\",\n \"label\": \"SMS Settings\",\n \"name\": \"SMS Settings\",\n \"type\": \"doctype\"\n }\n]"
+ "links": "[\n {\n \"description\": \"Webhooks calling API requests into web apps\",\n \"label\": \"Webhook\",\n \"name\": \"Webhook\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"Slack Webhooks for internal integration\",\n \"label\": \"Slack Webhook URL\",\n \"name\": \"Slack Webhook URL\",\n \"type\": \"doctype\"\n },\n {\n \"description\": \"SMS Settings for sending sms\",\n \"label\": \"SMS Settings\",\n \"name\": \"SMS Settings\",\n \"type\": \"doctype\"\n }\n]"
}
],
"category": "Administration",
@@ -38,7 +38,7 @@
"idx": 0,
"is_standard": 1,
"label": "Integrations",
- "modified": "2020-08-20 23:04:04.528572",
+ "modified": "2020-10-28 10:25:54.792363",
"modified_by": "Administrator",
"module": "Integrations",
"name": "Integrations",
diff --git a/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py b/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py
index 6b95a3f5bf..71445b44d7 100644
--- a/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py
+++ b/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py
@@ -9,7 +9,7 @@ import frappe
import os
from frappe import _
from frappe.model.document import Document
-from frappe.integrations.offsite_backup_utils import get_latest_backup_file, send_email, validate_file_size
+from frappe.integrations.offsite_backup_utils import get_latest_backup_file, send_email, validate_file_size, get_chunk_site
from frappe.integrations.utils import make_post_request
from frappe.utils import (cint, get_request_site_address,
get_files_path, get_backups_path, get_url, encode)
@@ -167,8 +167,9 @@ def upload_file_to_dropbox(filename, folder, dropbox_client):
return
create_folder_if_not_exists(folder, dropbox_client)
- chunk_size = 15 * 1024 * 1024
file_size = os.path.getsize(encode(filename))
+ chunk_size = get_chunk_site(file_size)
+
mode = (dropbox.files.WriteMode.overwrite)
f = open(encode(filename), 'rb')
diff --git a/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.json b/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.json
index 123bb21e88..2ca1723cb2 100755
--- a/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.json
+++ b/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.json
@@ -18,12 +18,9 @@
"bucket",
"endpoint_url",
"column_break_13",
- "region",
"backup_details_section",
"frequency",
- "backup_files",
- "column_break_18",
- "backup_limit"
+ "backup_files"
],
"fields": [
{
@@ -42,7 +39,7 @@
},
{
"default": "1",
- "description": "Note: By default emails for failed backups are sent.",
+ "description": "By default, emails are only sent for failed backups.",
"fieldname": "send_email_for_successful_backup",
"fieldtype": "Check",
"label": "Send Email for Successful Backup"
@@ -73,14 +70,7 @@
"reqd": 1
},
{
- "default": "us-east-1",
- "description": "See https://docs.aws.amazon.com/general/latest/gr/s3.html for details.",
- "fieldname": "region",
- "fieldtype": "Select",
- "label": "Region",
- "options": "us-east-1\nus-east-2\nus-west-1\nus-west-2\naf-south-1\nap-east-1\nap-south-1\nap-southeast-1\nap-southeast-2\nap-northeast-1\nap-northeast-2\nap-northeast-3\nca-central-1\ncn-north-1\ncn-northwest-1\neu-central-1\neu-west-1\neu-west-2\neu-west-3\neu-south-1\neu-north-1\nme-south-1\nsa-east-1"
- },
- {
+ "default": "https://s3.amazonaws.com",
"fieldname": "endpoint_url",
"fieldtype": "Data",
"label": "Endpoint URL"
@@ -92,14 +82,6 @@
"mandatory_depends_on": "enabled",
"reqd": 1
},
- {
- "description": "Set to 0 for no limit on the number of backups taken",
- "fieldname": "backup_limit",
- "fieldtype": "Int",
- "label": "Backup Limit",
- "mandatory_depends_on": "enabled",
- "reqd": 1
- },
{
"depends_on": "enabled",
"fieldname": "api_access_section",
@@ -142,16 +124,12 @@
"fieldname": "backup_files",
"fieldtype": "Check",
"label": "Backup Files"
- },
- {
- "fieldname": "column_break_18",
- "fieldtype": "Column Break"
}
],
"hide_toolbar": 1,
"issingle": 1,
"links": [],
- "modified": "2020-07-27 17:27:21.400000",
+ "modified": "2020-12-07 15:30:55.047689",
"modified_by": "Administrator",
"module": "Integrations",
"name": "S3 Backup Settings",
@@ -172,4 +150,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
-}
+}
\ No newline at end of file
diff --git a/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.py b/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.py
index 7c90d37f82..308d34c5c2 100755
--- a/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.py
+++ b/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.py
@@ -24,6 +24,7 @@ class S3BackupSettings(Document):
if not self.endpoint_url:
self.endpoint_url = 'https://s3.amazonaws.com'
+
conn = boto3.client(
's3',
aws_access_key_id=self.access_key_id,
@@ -31,25 +32,21 @@ class S3BackupSettings(Document):
endpoint_url=self.endpoint_url
)
- bucket_lower = str(self.bucket)
-
- try:
- conn.list_buckets()
-
- except ClientError:
- frappe.throw(_("Invalid Access Key ID or Secret Access Key."))
-
try:
# Head_bucket returns a 200 OK if the bucket exists and have access to it.
- conn.head_bucket(Bucket=bucket_lower)
+ # Requires ListBucket permission
+ conn.head_bucket(Bucket=self.bucket)
except ClientError as e:
error_code = e.response['Error']['Code']
+ bucket_name = frappe.bold(self.bucket)
if error_code == '403':
- frappe.throw(_("Do not have permission to access {0} bucket.").format(bucket_lower))
- else: # '400'-Bad request or '404'-Not Found return
- # try to create bucket
- conn.create_bucket(Bucket=bucket_lower, CreateBucketConfiguration={
- 'LocationConstraint': self.region})
+ msg = _("Do not have permission to access bucket {0}.").format(bucket_name)
+ elif error_code == '404':
+ msg = _("Bucket {0} not found.").format(bucket_name)
+ else:
+ msg = e.args[0]
+
+ frappe.throw(msg)
@frappe.whitelist()
@@ -70,11 +67,13 @@ def take_backups_weekly():
def take_backups_monthly():
take_backups_if("Monthly")
+
def take_backups_if(freq):
if cint(frappe.db.get_value("S3 Backup Settings", None, "enabled")):
if frappe.db.get_value("S3 Backup Settings", None, "frequency") == freq:
take_backups_s3()
+
@frappe.whitelist()
def take_backups_s3(retry_count=0):
try:
@@ -146,42 +145,13 @@ def backup_to_s3():
if files_filename:
upload_file_to_s3(files_filename, folder, conn, bucket)
- delete_old_backups(doc.backup_limit, bucket)
-
def upload_file_to_s3(filename, folder, conn, bucket):
destpath = os.path.join(folder, os.path.basename(filename))
try:
print("Uploading file:", filename)
- conn.upload_file(filename, bucket, destpath)
+ conn.upload_file(filename, bucket, destpath) # Requires PutObject permission
except Exception as e:
frappe.log_error()
print("Error uploading: %s" % (e))
-
-
-def delete_old_backups(limit, bucket):
- all_backups = []
- doc = frappe.get_single("S3 Backup Settings")
- backup_limit = int(limit)
-
- s3 = boto3.resource(
- 's3',
- aws_access_key_id=doc.access_key_id,
- aws_secret_access_key=doc.get_password('secret_access_key'),
- endpoint_url=doc.endpoint_url or 'https://s3.amazonaws.com'
- )
-
- bucket = s3.Bucket(bucket)
- objects = bucket.meta.client.list_objects_v2(Bucket=bucket.name, Delimiter='/')
- if objects:
- for obj in objects.get('CommonPrefixes'):
- all_backups.append(obj.get('Prefix'))
-
- oldest_backup = sorted(all_backups)[0] if all_backups else ''
-
- if len(all_backups) > backup_limit:
- print("Deleting Backup: {0}".format(oldest_backup))
- for obj in bucket.objects.filter(Prefix=oldest_backup):
- # delete all keys that are inside the oldest_backup
- s3.Object(bucket.name, obj.key).delete()
diff --git a/frappe/integrations/doctype/twilio_number_group/twilio_number_group.json b/frappe/integrations/doctype/twilio_number_group/twilio_number_group.json
deleted file mode 100644
index 9d51e4b452..0000000000
--- a/frappe/integrations/doctype/twilio_number_group/twilio_number_group.json
+++ /dev/null
@@ -1,36 +0,0 @@
-{
- "actions": [],
- "autoname": "field:phone_number",
- "creation": "2020-02-24 13:58:58.036914",
- "doctype": "DocType",
- "editable_grid": 1,
- "engine": "InnoDB",
- "field_order": [
- "phone_number"
- ],
- "fields": [
- {
- "fieldname": "phone_number",
- "fieldtype": "Data",
- "in_list_view": 1,
- "label": "Phone Number",
- "options": "Phone",
- "show_days": 1,
- "show_seconds": 1,
- "unique": 1
- }
- ],
- "index_web_pages_for_search": 1,
- "istable": 1,
- "links": [],
- "modified": "2020-08-20 22:48:57.166791",
- "modified_by": "Administrator",
- "module": "Integrations",
- "name": "Twilio Number Group",
- "owner": "Administrator",
- "permissions": [],
- "quick_entry": 1,
- "sort_field": "modified",
- "sort_order": "DESC",
- "track_changes": 1
-}
\ No newline at end of file
diff --git a/frappe/integrations/doctype/twilio_settings/__init__.py b/frappe/integrations/doctype/twilio_settings/__init__.py
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/frappe/integrations/doctype/twilio_settings/test_twilio_settings.py b/frappe/integrations/doctype/twilio_settings/test_twilio_settings.py
deleted file mode 100644
index bcb1368d68..0000000000
--- a/frappe/integrations/doctype/twilio_settings/test_twilio_settings.py
+++ /dev/null
@@ -1,10 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright (c) 2020, Frappe Technologies and Contributors
-# See license.txt
-from __future__ import unicode_literals
-
-# import frappe
-import unittest
-
-class TestTwilioSettings(unittest.TestCase):
- pass
diff --git a/frappe/integrations/doctype/twilio_settings/twilio_settings.js b/frappe/integrations/doctype/twilio_settings/twilio_settings.js
deleted file mode 100644
index 59ebcf2e7d..0000000000
--- a/frappe/integrations/doctype/twilio_settings/twilio_settings.js
+++ /dev/null
@@ -1,8 +0,0 @@
-// Copyright (c) 2020, Frappe Technologies and contributors
-// For license information, please see license.txt
-
-frappe.ui.form.on('Twilio Settings', {
- refresh: function(frm) {
- frm.dashboard.set_headline(__("For more information, {0}.", [`${__('Click here')}`]));
- }
-});
diff --git a/frappe/integrations/doctype/twilio_settings/twilio_settings.json b/frappe/integrations/doctype/twilio_settings/twilio_settings.json
deleted file mode 100644
index 9eb2c0c512..0000000000
--- a/frappe/integrations/doctype/twilio_settings/twilio_settings.json
+++ /dev/null
@@ -1,67 +0,0 @@
-{
- "actions": [],
- "creation": "2020-01-28 15:21:44.457163",
- "doctype": "DocType",
- "editable_grid": 1,
- "engine": "InnoDB",
- "field_order": [
- "enabled",
- "account_sid",
- "auth_token",
- "column_break_2",
- "twilio_number"
- ],
- "fields": [
- {
- "fieldname": "account_sid",
- "fieldtype": "Data",
- "label": "Account SID",
- "mandatory_depends_on": "eval: doc.enabled"
- },
- {
- "fieldname": "auth_token",
- "fieldtype": "Password",
- "label": "Auth Token",
- "mandatory_depends_on": "eval: doc.enabled"
- },
- {
- "fieldname": "column_break_2",
- "fieldtype": "Column Break"
- },
- {
- "fieldname": "twilio_number",
- "fieldtype": "Table",
- "label": "Twilio Number",
- "options": "Twilio Number Group"
- },
- {
- "default": "0",
- "fieldname": "enabled",
- "fieldtype": "Check",
- "label": "Enabled"
- }
- ],
- "index_web_pages_for_search": 1,
- "issingle": 1,
- "links": [],
- "modified": "2020-09-03 10:17:21.318743",
- "modified_by": "Administrator",
- "module": "Integrations",
- "name": "Twilio Settings",
- "owner": "Administrator",
- "permissions": [
- {
- "create": 1,
- "delete": 1,
- "email": 1,
- "print": 1,
- "read": 1,
- "role": "System Manager",
- "share": 1,
- "write": 1
- }
- ],
- "sort_field": "modified",
- "sort_order": "DESC",
- "track_changes": 1
-}
\ No newline at end of file
diff --git a/frappe/integrations/doctype/twilio_settings/twilio_settings.py b/frappe/integrations/doctype/twilio_settings/twilio_settings.py
deleted file mode 100644
index b8f991e829..0000000000
--- a/frappe/integrations/doctype/twilio_settings/twilio_settings.py
+++ /dev/null
@@ -1,63 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright (c) 2020, Frappe Technologies and contributors
-# For license information, please see license.txt
-
-from __future__ import unicode_literals
-import frappe
-from frappe.model.document import Document
-from frappe import _
-from frappe.utils.password import get_decrypted_password
-from twilio.rest import Client
-from six import string_types
-from json import loads
-
-class TwilioSettings(Document):
- def on_update(self):
- if self.enabled:
- self.validate_twilio_credentials()
-
- def validate_twilio_credentials(self):
- try:
- auth_token = get_decrypted_password("Twilio Settings", "Twilio Settings", 'auth_token')
- client = Client(self.account_sid, auth_token)
- client.api.accounts(self.account_sid).fetch()
- except Exception:
- frappe.throw(_("Invalid Account SID or Auth Token."))
-
-def send_whatsapp_message(sender, receiver_list, message):
- twilio_settings = frappe.get_doc("Twilio Settings")
- if not twilio_settings.enabled:
- frappe.throw(_("Please enable twilio settings before sending WhatsApp messages"))
-
- if isinstance(receiver_list, string_types):
- receiver_list = loads(receiver_list)
- if not isinstance(receiver_list, list):
- receiver_list = [receiver_list]
-
- auth_token = get_decrypted_password("Twilio Settings", "Twilio Settings", 'auth_token')
- client = Client(twilio_settings.account_sid, auth_token)
- args = {
- "from_": 'whatsapp:+{}'.format(sender),
- "body": message
- }
-
- failed_delivery = []
-
- for rec in receiver_list:
- args.update({"to": 'whatsapp:{}'.format(rec)})
- resp = _send_whatsapp(args, client)
- if not resp or resp.error_message:
- failed_delivery.append(rec)
-
- if failed_delivery:
- frappe.log_error(_("The message wasn't correctly delivered to: {}".format(", ".join(failed_delivery))), _('Delivery Failed'))
-
-
-def _send_whatsapp(message_dict, client):
- response = frappe._dict()
- try:
- response = client.messages.create(**message_dict)
- except Exception as e:
- frappe.log_error(e, title = _('Twilio WhatsApp Message Error'))
-
- return response
\ No newline at end of file
diff --git a/frappe/integrations/frappe_providers/frappecloud.py b/frappe/integrations/frappe_providers/frappecloud.py
index e09f09a44b..f60344ee8f 100644
--- a/frappe/integrations/frappe_providers/frappecloud.py
+++ b/frappe/integrations/frappe_providers/frappecloud.py
@@ -6,7 +6,7 @@ import frappe
def frappecloud_migrator(local_site):
- print("Retreiving Site Migrator...")
+ print("Retrieving Site Migrator...")
remote_site = frappe.conf.frappecloud_url or "frappecloud.com"
request_url = "https://{}/api/method/press.api.script".format(remote_site)
request = requests.get(request_url)
diff --git a/frappe/integrations/oauth2.py b/frappe/integrations/oauth2.py
index c8dfc52c95..a750c8328c 100644
--- a/frappe/integrations/oauth2.py
+++ b/frappe/integrations/oauth2.py
@@ -1,41 +1,50 @@
from __future__ import unicode_literals
-import frappe, json
-from frappe.oauth import OAuthWebRequestValidator, WebApplicationServer
+
+import hashlib
+import json
+from urllib.parse import quote, urlencode, urlparse
+
+import jwt
from oauthlib.oauth2 import FatalClientError, OAuth2Error
-from werkzeug import url_fix
-from six.moves.urllib.parse import quote, urlencode, urlparse
-from frappe.integrations.doctype.oauth_provider_settings.oauth_provider_settings import get_oauth_settings
+
+import frappe
from frappe import _
+from frappe.oauth import OAuthWebRequestValidator, WebApplicationServer
+from frappe.integrations.doctype.oauth_provider_settings.oauth_provider_settings import get_oauth_settings
def get_oauth_server():
if not getattr(frappe.local, 'oauth_server', None):
oauth_validator = OAuthWebRequestValidator()
- frappe.local.oauth_server = WebApplicationServer(oauth_validator)
+ frappe.local.oauth_server = WebApplicationServer(oauth_validator)
return frappe.local.oauth_server
-def get_urlparams_from_kwargs(param_kwargs):
+def sanitize_kwargs(param_kwargs):
arguments = param_kwargs
- if arguments.get("data"):
- arguments.pop("data")
- if arguments.get("cmd"):
- arguments.pop("cmd")
+ arguments.pop('data', None)
+ arguments.pop('cmd', None)
- return urlencode(arguments)
+ return arguments
@frappe.whitelist()
def approve(*args, **kwargs):
r = frappe.request
- uri = url_fix(r.url.replace("+"," "))
- http_method = r.method
- body = r.get_data()
- headers = r.headers
try:
- scopes, frappe.flags.oauth_credentials = get_oauth_server().validate_authorization_request(uri, http_method, body, headers)
+ scopes, frappe.flags.oauth_credentials = get_oauth_server().validate_authorization_request(
+ r.url,
+ r.method,
+ r.get_data(),
+ r.headers
+ )
- headers, body, status = get_oauth_server().create_authorization_response(uri=frappe.flags.oauth_credentials['redirect_uri'], \
- body=body, headers=headers, scopes=scopes, credentials=frappe.flags.oauth_credentials)
+ headers, body, status = get_oauth_server().create_authorization_response(
+ uri=frappe.flags.oauth_credentials['redirect_uri'],
+ body=r.get_data(),
+ headers=r.headers,
+ scopes=scopes,
+ credentials=frappe.flags.oauth_credentials
+ )
uri = headers.get('Location', None)
frappe.local.response["type"] = "redirect"
@@ -47,34 +56,28 @@ def approve(*args, **kwargs):
return e
@frappe.whitelist(allow_guest=True)
-def authorize(*args, **kwargs):
- #Fetch provider URL from settings
- oauth_settings = get_oauth_settings()
- params = get_urlparams_from_kwargs(kwargs)
- request_url = urlparse(frappe.request.url)
- success_url = request_url.scheme + "://" + request_url.netloc + "/api/method/frappe.integrations.oauth2.approve?" + params
+def authorize(**kwargs):
+ success_url = "/api/method/frappe.integrations.oauth2.approve?" + encode_params(sanitize_kwargs(kwargs))
failure_url = frappe.form_dict["redirect_uri"] + "?error=access_denied"
- if frappe.session['user']=='Guest':
+ if frappe.session.user == 'Guest':
#Force login, redirect to preauth again.
frappe.local.response["type"] = "redirect"
- frappe.local.response["location"] = "/login?redirect-to=/api/method/frappe.integrations.oauth2.authorize?" + quote(params.replace("+"," "))
-
- elif frappe.session['user']!='Guest':
+ frappe.local.response["location"] = "/login?" + encode_params({'redirect-to': frappe.request.url})
+ else:
try:
r = frappe.request
- uri = url_fix(r.url)
- http_method = r.method
- body = r.get_data()
- headers = r.headers
-
- scopes, frappe.flags.oauth_credentials = get_oauth_server().validate_authorization_request(uri, http_method, body, headers)
+ scopes, frappe.flags.oauth_credentials = get_oauth_server().validate_authorization_request(
+ r.url,
+ r.method,
+ r.get_data(),
+ r.headers
+ )
skip_auth = frappe.db.get_value("OAuth Client", frappe.flags.oauth_credentials['client_id'], "skip_authorization")
unrevoked_tokens = frappe.get_all("OAuth Bearer Token", filters={"status":"Active"})
- if skip_auth or (oauth_settings["skip_authorization"] == "Auto" and len(unrevoked_tokens)):
-
+ if skip_auth or (get_oauth_settings().skip_authorization == "Auto" and unrevoked_tokens):
frappe.local.response["type"] = "redirect"
frappe.local.response["location"] = success_url
else:
@@ -87,7 +90,6 @@ def authorize(*args, **kwargs):
})
resp_html = frappe.render_template("templates/includes/oauth_confirmation.html", response_html_params)
frappe.respond_as_web_page("Confirm Access", resp_html)
-
except FatalClientError as e:
return e
except OAuth2Error as e:
@@ -95,20 +97,20 @@ def authorize(*args, **kwargs):
@frappe.whitelist(allow_guest=True)
def get_token(*args, **kwargs):
- r = frappe.request
-
- uri = url_fix(r.url)
- http_method = r.method
- body = r.form
- headers = r.headers
-
#Check whether frappe server URL is set
frappe_server_url = frappe.db.get_value("Social Login Key", "frappe", "base_url") or None
if not frappe_server_url:
frappe.throw(_("Please set Base URL in Social Login Key for Frappe"))
try:
- headers, body, status = get_oauth_server().create_token_response(uri, http_method, body, headers, frappe.flags.oauth_credentials)
+ r = frappe.request
+ headers, body, status = get_oauth_server().create_token_response(
+ r.url,
+ r.method,
+ r.form,
+ r.headers,
+ frappe.flags.oauth_credentials
+ )
out = frappe._dict(json.loads(body))
if not out.error and "openid" in out.scope:
token_user = frappe.db.get_value("OAuth Bearer Token", out.access_token, "user")
@@ -116,7 +118,7 @@ def get_token(*args, **kwargs):
client_secret = frappe.db.get_value("OAuth Client", token_client, "client_secret")
if token_user in ["Guest", "Administrator"]:
frappe.throw(_("Logged in as Guest or Administrator"))
- import hashlib
+
id_token_header = {
"typ":"jwt",
"alg":"HS256"
@@ -128,9 +130,10 @@ def get_token(*args, **kwargs):
"iss": frappe_server_url,
"at_hash": frappe.oauth.calculate_at_hash(out.access_token, hashlib.sha256)
}
- import jwt
+
id_token_encoded = jwt.encode(id_token, client_secret, algorithm='HS256', headers=id_token_header)
- out.update({"id_token":str(id_token_encoded)})
+ out.update({"id_token": str(id_token_encoded)})
+
frappe.local.response = out
except FatalClientError as e:
@@ -140,12 +143,12 @@ def get_token(*args, **kwargs):
@frappe.whitelist(allow_guest=True)
def revoke_token(*args, **kwargs):
r = frappe.request
- uri = url_fix(r.url)
- http_method = r.method
- body = r.form
- headers = r.headers
-
- headers, body, status = get_oauth_server().create_revocation_response(uri, headers=headers, body=body, http_method=http_method)
+ headers, body, status = get_oauth_server().create_revocation_response(
+ r.url,
+ headers=r.headers,
+ body=r.form,
+ http_method=r.method
+ )
frappe.local.response['http_status_code'] = status
if status == 200:
@@ -174,15 +177,22 @@ def openid_profile(*args, **kwargs):
"email": name,
"picture": picture
})
-
+
frappe.local.response = user_profile
def validate_url(url_string):
try:
result = urlparse(url_string)
- if result.scheme and result.scheme in ["http", "https", "ftp", "ftps"]:
- return True
- else:
- return False
+ return result.scheme and result.scheme in ["http", "https", "ftp", "ftps"]
except:
- return False
\ No newline at end of file
+ return False
+
+def encode_params(params):
+ """
+ Encode a dict of params into a query string.
+
+ Use `quote_via=urllib.parse.quote` so that whitespaces will be encoded as
+ `%20` instead of as `+`. This is needed because oauthlib cannot handle `+`
+ as a whitespace.
+ """
+ return urlencode(params, quote_via=quote)
diff --git a/frappe/integrations/offsite_backup_utils.py b/frappe/integrations/offsite_backup_utils.py
index db176538e4..48a2c89107 100644
--- a/frappe/integrations/offsite_backup_utils.py
+++ b/frappe/integrations/offsite_backup_utils.py
@@ -6,8 +6,7 @@ from __future__ import unicode_literals
import frappe
import glob
import os
-from frappe.utils import split_emails, get_backups_path
-
+from frappe.utils import split_emails, cint
def send_email(success, service_name, doctype, email_field, error_status=None):
recipients = get_recipients(doctype, email_field)
@@ -81,6 +80,22 @@ def get_file_size(file_path, unit):
return file_size
+def get_chunk_site(file_size):
+ ''' this function will return chunk size in megabytes based on file size '''
+
+ file_size_in_gb = cint(file_size/1024/1024)
+
+ MB = 1024 * 1024
+ if file_size_in_gb > 5000:
+ return 200 * MB
+ elif file_size_in_gb >= 3000:
+ return 150 * MB
+ elif file_size_in_gb >= 1000:
+ return 100 * MB
+ elif file_size_in_gb >= 500:
+ return 50 * MB
+ else:
+ return 15 * MB
def validate_file_size():
frappe.flags.create_new_backup = True
@@ -98,4 +113,4 @@ def generate_files_backup():
db_type=frappe.conf.db_type, db_port=frappe.conf.db_port)
backup.set_backup_file_name()
- backup.zip_files()
\ No newline at end of file
+ backup.zip_files()
diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py
index 0a219b4253..5d86b3bac8 100644
--- a/frappe/model/base_document.py
+++ b/frappe/model/base_document.py
@@ -802,12 +802,12 @@ class BaseDocument(object):
if translated:
val = _(val)
- if absolute_value and isinstance(val, (int, float)):
- val = abs(self.get(fieldname))
-
if not doc:
doc = getattr(self, "parent_doc", None) or self
+ if (absolute_value or doc.get('absolute_value')) and isinstance(val, (int, float)):
+ val = abs(self.get(fieldname))
+
return format_value(val, df=df, doc=doc, currency=currency)
def is_print_hide(self, fieldname, df=None, for_print=True):
diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py
index ace9b04cec..b936251b50 100644
--- a/frappe/model/db_query.py
+++ b/frappe/model/db_query.py
@@ -18,6 +18,7 @@ from frappe.client import check_parent_permission
from frappe.model.utils.user_settings import get_user_settings, update_user_settings
from frappe.utils import flt, cint, get_time, make_filter_tuple, get_filter, add_to_date, cstr, get_timespan_date_range
from frappe.model.meta import get_table_columns
+from frappe.core.doctype.server_script.server_script_utils import get_server_script_map
class DatabaseQuery(object):
def __init__(self, doctype, user=None):
@@ -683,15 +684,23 @@ class DatabaseQuery(object):
self.match_filters.append(match_filters)
def get_permission_query_conditions(self):
+ conditions = []
condition_methods = frappe.get_hooks("permission_query_conditions", {}).get(self.doctype, [])
if condition_methods:
- conditions = []
for method in condition_methods:
c = frappe.call(frappe.get_attr(method), self.user)
if c:
conditions.append(c)
- return " and ".join(conditions) if conditions else None
+ permision_script_name = get_server_script_map().get("permission_query").get(self.doctype)
+ if permision_script_name:
+ script = frappe.get_doc("Server Script", permision_script_name)
+ condition = script.get_permission_query_conditions(self.user)
+ if condition:
+ conditions.append(condition)
+
+ return " and ".join(conditions) if conditions else ""
+
def run_custom_query(self, query):
if '%(key)s' in query:
diff --git a/frappe/model/delete_doc.py b/frappe/model/delete_doc.py
index a38470e3f5..862abe375c 100644
--- a/frappe/model/delete_doc.py
+++ b/frappe/model/delete_doc.py
@@ -335,19 +335,25 @@ def clear_timeline_references(link_doctype, link_name):
WHERE `tabCommunication Link`.link_doctype=%s AND `tabCommunication Link`.link_name=%s""", (link_doctype, link_name))
def insert_feed(doc):
- from frappe.utils import get_fullname
-
- if frappe.flags.in_install or frappe.flags.in_import or getattr(doc, "no_feed_on_delete", False):
+ if (
+ frappe.flags.in_install
+ or frappe.flags.in_uninstall
+ or frappe.flags.in_import
+ or getattr(doc, "no_feed_on_delete", False)
+ ):
return
+ from frappe.utils import get_fullname
+
frappe.get_doc({
"doctype": "Comment",
"comment_type": "Deleted",
"reference_doctype": doc.doctype,
"subject": "{0} {1}".format(_(doc.doctype), doc.name),
- "full_name": get_fullname(doc.owner)
+ "full_name": get_fullname(doc.owner),
}).insert(ignore_permissions=True)
+
def delete_controllers(doctype, module):
"""
Delete controller code in the doctype folder
diff --git a/frappe/model/meta.py b/frappe/model/meta.py
index 8c17a5b19b..c740d495c1 100644
--- a/frappe/model/meta.py
+++ b/frappe/model/meta.py
@@ -209,7 +209,8 @@ class Meta(Document):
'owner': _('Created By'),
'modified_by': _('Modified By'),
'creation': _('Created On'),
- 'modified': _('Last Modified On')
+ 'modified': _('Last Modified On'),
+ '_assign': _('Assigned To')
}.get(fieldname) or _('No Label')
return label
diff --git a/frappe/model/rename_doc.py b/frappe/model/rename_doc.py
index 7a2129e76e..35fbf94dc6 100644
--- a/frappe/model/rename_doc.py
+++ b/frappe/model/rename_doc.py
@@ -1,14 +1,15 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt
-from __future__ import unicode_literals, print_function
+from __future__ import print_function, unicode_literals
+
import frappe
from frappe import _, bold
-from frappe.utils import cint
-from frappe.model.naming import validate_name
from frappe.model.dynamic_links import get_dynamic_link_map
-from frappe.utils.password import rename_password
+from frappe.model.naming import validate_name
from frappe.model.utils.user_settings import sync_user_settings, update_user_settings_data
+from frappe.utils import cint
+from frappe.utils.password import rename_password
@frappe.whitelist()
@@ -42,7 +43,6 @@ def rename_doc(doctype, old, new, force=False, merge=False, ignore_permissions=F
force = cint(force)
merge = cint(merge)
-
meta = frappe.get_meta(doctype)
# call before_rename
@@ -249,8 +249,17 @@ def update_link_field_values(link_fields, old, new, doctype):
# or no longer exists
pass
else:
- # because the table hasn't been renamed yet!
- parent = field['parent'] if field['parent']!=new else old
+ parent = field['parent']
+
+ # Handles the case where one of the link fields belongs to
+ # the DocType being renamed.
+ # Here this field could have the current DocType as its value too.
+
+ # In this case while updating link field value, the field's parent
+ # or the current DocType table name hasn't been renamed yet,
+ # so consider it's old name.
+ if parent == new and doctype == "DocType":
+ parent = old
frappe.db.sql("""
update `tab{table_name}` set `{fieldname}`=%s
@@ -306,8 +315,7 @@ def get_link_fields(doctype):
def update_options_for_fieldtype(fieldtype, old, new):
if frappe.conf.developer_mode:
- for name in frappe.db.sql_list("""select parent from
- tabDocField where options=%s""", old):
+ for name in frappe.get_all("DocField", filters={"options": old}, pluck="parent"):
doctype = frappe.get_doc("DocType", name)
save = False
for f in doctype.fields:
@@ -413,20 +421,21 @@ def update_parenttype_values(old, new):
child_doctypes += custom_child_doctypes
fields = [d['fieldname'] for d in child_doctypes]
- property_setter_child_doctypes = frappe.db.sql("""\
- select value as options from `tabProperty Setter`
- where doc_type=%s and property='options' and
- field_name in ("%s")""" % ('%s', '", "'.join(fields)),
- (new,))
+ property_setter_child_doctypes = frappe.get_all(
+ "Property Setter",
+ filters={
+ "doc_type": new,
+ "property": "options",
+ "field_name": ("in", fields)
+ },
+ pluck="value"
+ )
+ child_doctypes = list(d['options'] for d in child_doctypes)
child_doctypes += property_setter_child_doctypes
- child_doctypes = (d['options'] for d in child_doctypes)
for doctype in child_doctypes:
- frappe.db.sql("""\
- update `tab%s` set parenttype=%s
- where parenttype=%s""" % (doctype, '%s', '%s'),
- (new, old))
+ frappe.db.sql(f"update `tab{doctype}` set parenttype=%s where parenttype=%s", (new, old))
def rename_dynamic_links(doctype, old, new):
for df in get_dynamic_link_map().get(doctype, []):
@@ -482,60 +491,30 @@ def bulk_rename(doctype, rows=None, via_console = False):
return rename_log
def update_linked_doctypes(doctype, docname, linked_to, value, ignore_doctypes=None):
- """
- linked_doctype_info_list = list formed by get_fetch_fields() function
- docname = Master DocType's name in which modification are made
- value = Value for the field thats set in other DocType's by fetching from Master DocType
- """
- linked_doctype_info_list = get_fetch_fields(doctype, linked_to, ignore_doctypes)
+ from frappe.model.utils.rename_doc import update_linked_doctypes
+ show_deprecation_warning("update_linked_doctypes")
+
+ return update_linked_doctypes(
+ doctype=doctype,
+ docname=docname,
+ linked_to=linked_to,
+ value=value,
+ ignore_doctypes=ignore_doctypes,
+ )
- for d in linked_doctype_info_list:
- frappe.db.sql("""
- update
- `tab{doctype}`
- set
- {linked_to_fieldname} = "{value}"
- where
- {master_fieldname} = {docname}
- and {linked_to_fieldname} != "{value}"
- """.format(
- doctype = d['doctype'],
- linked_to_fieldname = d['linked_to_fieldname'],
- value = value,
- master_fieldname = d['master_fieldname'],
- docname = frappe.db.escape(docname)
- ))
def get_fetch_fields(doctype, linked_to, ignore_doctypes=None):
- """
- doctype = Master DocType in which the changes are being made
- linked_to = DocType name of the field thats being updated in Master
+ from frappe.model.utils.rename_doc import get_fetch_fields
+ show_deprecation_warning("get_fetch_fields")
- This function fetches list of all DocType where both doctype and linked_to is found
- as link fields.
- Forms a list of dict in the form -
- [{doctype: , master_fieldname: , linked_to_fieldname: ]
- where
- doctype = DocType where changes need to be made
- master_fieldname = Fieldname where options = doctype
- linked_to_fieldname = Fieldname where options = linked_to
- """
+ return get_fetch_fields(
+ doctype=doctype, linked_to=linked_to, ignore_doctypes=ignore_doctypes
+ )
- master_list = get_link_fields(doctype)
- linked_to_list = get_link_fields(linked_to)
- out = []
-
- from itertools import product
- product_list = product(master_list, linked_to_list)
-
- for d in product_list:
- linked_doctype_info = frappe._dict()
- if d[0]['parent'] == d[1]['parent'] \
- and (not ignore_doctypes or d[0]['parent'] not in ignore_doctypes) \
- and not d[1]['issingle']:
- linked_doctype_info['doctype'] = d[0]['parent']
- linked_doctype_info['master_fieldname'] = d[0]['fieldname']
- linked_doctype_info['linked_to_fieldname'] = d[1]['fieldname']
- out.append(linked_doctype_info)
-
- return out
+def show_deprecation_warning(funct):
+ from click import secho
+ message = (
+ f"Function frappe.model.rename_doc.{funct} has been deprecated and "
+ "moved to the frappe.model.utils.rename_doc"
+ )
+ secho(message, fg="yellow")
diff --git a/frappe/model/utils/rename_doc.py b/frappe/model/utils/rename_doc.py
new file mode 100644
index 0000000000..bf71d36a42
--- /dev/null
+++ b/frappe/model/utils/rename_doc.py
@@ -0,0 +1,58 @@
+from itertools import product
+
+import frappe
+from frappe.model.rename_doc import get_link_fields
+
+
+def update_linked_doctypes(doctype, docname, linked_to, value, ignore_doctypes=None):
+ """
+ linked_doctype_info_list = list formed by get_fetch_fields() function
+ docname = Master DocType's name in which modification are made
+ value = Value for the field thats set in other DocType's by fetching from Master DocType
+ """
+ linked_doctype_info_list = get_fetch_fields(doctype, linked_to, ignore_doctypes)
+
+ for d in linked_doctype_info_list:
+ frappe.db.set_value(
+ d.doctype,
+ {
+ d.master_fieldname : docname,
+ d.linked_to_fieldname : ("!=", value),
+ },
+ d.linked_to_fieldname,
+ value,
+ )
+
+
+def get_fetch_fields(doctype, linked_to, ignore_doctypes=None):
+ """
+ doctype = Master DocType in which the changes are being made
+ linked_to = DocType name of the field thats being updated in Master
+ This function fetches list of all DocType where both doctype and linked_to is found
+ as link fields.
+ Forms a list of dict in the form -
+ [{doctype: , master_fieldname: , linked_to_fieldname: ]
+ where
+ doctype = DocType where changes need to be made
+ master_fieldname = Fieldname where options = doctype
+ linked_to_fieldname = Fieldname where options = linked_to
+ """
+
+ out = []
+ master_list = get_link_fields(doctype)
+ linked_to_list = get_link_fields(linked_to)
+ product_list = product(master_list, linked_to_list)
+
+ for d in product_list:
+ linked_doctype_info = frappe._dict()
+ if (
+ d[0]["parent"] == d[1]["parent"]
+ and (not ignore_doctypes or d[0]["parent"] not in ignore_doctypes)
+ and not d[1]["issingle"]
+ ):
+ linked_doctype_info.doctype = d[0]["parent"]
+ linked_doctype_info.master_fieldname = d[0]["fieldname"]
+ linked_doctype_info.linked_to_fieldname = d[1]["fieldname"]
+ out.append(linked_doctype_info)
+
+ return out
diff --git a/frappe/model/workflow.py b/frappe/model/workflow.py
index 7239b202bd..72ce8c9ce4 100644
--- a/frappe/model/workflow.py
+++ b/frappe/model/workflow.py
@@ -29,6 +29,8 @@ def get_transitions(doc, workflow = None, raise_exception=False):
if doc.is_new():
return []
+ doc.load_from_db()
+
frappe.has_permission(doc, 'read', throw=True)
roles = frappe.get_roles()
diff --git a/frappe/oauth.py b/frappe/oauth.py
index bf225ac118..09af5ad809 100644
--- a/frappe/oauth.py
+++ b/frappe/oauth.py
@@ -148,7 +148,7 @@ class OAuthWebRequestValidator(RequestValidator):
print("Failed body authentication: Application %s does not exist".format(cid=request.client_id))
cookie_dict = get_cookie_dict_from_headers(request)
- user_id = unquote(cookie_dict['user_id']) if 'user_id' in cookie_dict else "Guest"
+ user_id = unquote(cookie_dict.get('user_id').value) if 'user_id' in cookie_dict else "Guest"
return frappe.session.user == user_id
def authenticate_client_id(self, client_id, request, *args, **kwargs):
diff --git a/frappe/patches.txt b/frappe/patches.txt
index 0daf29e001..b459019dd7 100644
--- a/frappe/patches.txt
+++ b/frappe/patches.txt
@@ -21,6 +21,7 @@ execute:frappe.reload_doc('email', 'doctype', 'document_follow')
execute:frappe.reload_doc('core', 'doctype', 'communication_link') #2019-10-02
execute:frappe.reload_doc('core', 'doctype', 'has_role')
execute:frappe.reload_doc('core', 'doctype', 'communication') #2019-10-02
+execute:frappe.reload_doc('core', 'doctype', 'server_script')
frappe.patches.v11_0.replicate_old_user_permissions
frappe.patches.v11_0.reload_and_rename_view_log #2019-01-03
frappe.patches.v7_1.rename_scheduler_log_to_error_log
diff --git a/frappe/printing/doctype/print_format/print_format.js b/frappe/printing/doctype/print_format/print_format.js
index e6599b2496..786f8f97ab 100644
--- a/frappe/printing/doctype/print_format/print_format.js
+++ b/frappe/printing/doctype/print_format/print_format.js
@@ -19,6 +19,7 @@ frappe.ui.form.on("Print Format", {
}
frm.trigger('render_buttons');
frm.toggle_display('standard', frappe.boot.developer_mode);
+ frm.trigger('hide_absolute_value_field');
},
render_buttons: function (frm) {
frm.page.clear_inner_toolbar();
@@ -58,5 +59,20 @@ frappe.ui.form.on("Print Format", {
frm.set_value('show_section_headings', value);
frm.set_value('line_breaks', value);
frm.trigger('render_buttons');
+ },
+ doc_type: function (frm) {
+ frm.trigger('hide_absolute_value_field');
+ },
+ hide_absolute_value_field: function (frm) {
+ // TODO: make it work with frm.doc.doc_type
+ // Problem: frm isn't updated in some random cases
+ const doctype = locals[frm.doc.doctype][frm.doc.name].doc_type;
+ if (doctype) {
+ frappe.model.with_doctype(doctype, () => {
+ const meta = frappe.get_meta(doctype);
+ const has_int_float_currency_field = meta.fields.filter(df => in_list(['Int', 'Float', 'Currency'], df.fieldtype));
+ frm.toggle_display('absolute_value', has_int_float_currency_field.length);
+ });
+ }
}
-})
+});
diff --git a/frappe/printing/doctype/print_format/print_format.json b/frappe/printing/doctype/print_format/print_format.json
index 63448ccc39..3a47fb554f 100644
--- a/frappe/printing/doctype/print_format/print_format.json
+++ b/frappe/printing/doctype/print_format/print_format.json
@@ -22,6 +22,7 @@
"align_labels_right",
"show_section_headings",
"line_breaks",
+ "absolute_value",
"column_break_11",
"font",
"css_section",
@@ -196,13 +197,21 @@
"fieldtype": "Check",
"hidden": 1,
"label": "Print Format Builder"
+ },
+ {
+ "default": "0",
+ "depends_on": "doc_type",
+ "description": "If checked, negative numeric values of Currency, Quantity or Count would be shown as positive",
+ "fieldname": "absolute_value",
+ "fieldtype": "Check",
+ "label": "Show Absolute Values"
}
],
"icon": "fa fa-print",
"idx": 1,
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2020-10-27 18:27:58.307070",
+ "modified": "2020-12-14 11:38:49.132061",
"modified_by": "Administrator",
"module": "Printing",
"name": "Print Format",
diff --git a/frappe/public/build.json b/frappe/public/build.json
index 242cf0160a..a3622499d5 100755
--- a/frappe/public/build.json
+++ b/frappe/public/build.json
@@ -245,7 +245,9 @@
"public/js/frappe/ui/chart.js",
"public/js/frappe/ui/datatable.js",
"public/js/frappe/ui/driver.js",
- "public/js/frappe/barcode_scanner/index.js"
+ "public/js/frappe/barcode_scanner/index.js",
+
+ "public/js/frappe/widgets/utils.js"
],
"css/form.min.css": [
"public/less/form_grid.less"
diff --git a/frappe/public/js/frappe/data_import/import_preview.js b/frappe/public/js/frappe/data_import/import_preview.js
index 6c17cb4351..477cfb0786 100644
--- a/frappe/public/js/frappe/data_import/import_preview.js
+++ b/frappe/public/js/frappe/data_import/import_preview.js
@@ -101,6 +101,7 @@ frappe.data_import.ImportPreview = class ImportPreview {
.replace('%H', 'HH')
.replace('%M', 'mm')
.replace('%S', 'ss')
+ .replace('%b', 'Mon')
: null;
let column_title = `
diff --git a/frappe/public/js/frappe/desk.js b/frappe/public/js/frappe/desk.js
index c8ed29fb76..5fa7a9dbcb 100644
--- a/frappe/public/js/frappe/desk.js
+++ b/frappe/public/js/frappe/desk.js
@@ -148,7 +148,6 @@ frappe.Application = Class.extend({
user: frappe.session.user
},
callback: function(r) {
- console.log(r);
if(r.message.show_alert){
frappe.show_alert({
indicator: 'red',
diff --git a/frappe/public/js/frappe/file_uploader/index.js b/frappe/public/js/frappe/file_uploader/index.js
index 62a7bff822..646f60715a 100644
--- a/frappe/public/js/frappe/file_uploader/index.js
+++ b/frappe/public/js/frappe/file_uploader/index.js
@@ -15,7 +15,11 @@ export default class FileUploader {
allow_multiple,
as_dataurl,
disable_file_browser,
+ frm
} = {}) {
+
+ frm && frm.attachments.max_reached(true);
+
if (!wrapper) {
this.make_dialog();
} else {
diff --git a/frappe/public/js/frappe/form/controls/code.js b/frappe/public/js/frappe/form/controls/code.js
index a547cfcf32..f3c51e0232 100644
--- a/frappe/public/js/frappe/form/controls/code.js
+++ b/frappe/public/js/frappe/form/controls/code.js
@@ -66,6 +66,7 @@ frappe.ui.form.ControlCode = frappe.ui.form.ControlText.extend({
const ace_language_mode = language_map[language] || '';
this.editor.session.setMode(ace_language_mode);
+ this.editor.setKeyboardHandler('ace/keyboard/vscode');
},
parse(value) {
diff --git a/frappe/public/js/frappe/form/controls/comment.js b/frappe/public/js/frappe/form/controls/comment.js
index a64df56bca..d00c915065 100644
--- a/frappe/public/js/frappe/form/controls/comment.js
+++ b/frappe/public/js/frappe/form/controls/comment.js
@@ -60,7 +60,7 @@ frappe.ui.form.ControlComment = frappe.ui.form.ControlTextEditor.extend({
update_state() {
const value = this.get_value();
- if (strip_html(value).trim() != "") {
+ if (strip_html(value).trim() != "" || value.includes('img')) {
this.button.removeClass('btn-default').addClass('btn-primary');
} else {
this.button.addClass('btn-default').removeClass('btn-primary');
diff --git a/frappe/public/js/frappe/form/controls/data.js b/frappe/public/js/frappe/form/controls/data.js
index 4db2553bd1..401de2ed5d 100644
--- a/frappe/public/js/frappe/form/controls/data.js
+++ b/frappe/public/js/frappe/form/controls/data.js
@@ -22,27 +22,9 @@ frappe.ui.form.ControlData = frappe.ui.form.ControlInput.extend({
this.has_input = true;
this.bind_change_event();
this.setup_autoname_check();
- if (this.df.options == 'Phone') {
- this.setup_phone();
- }
// somehow this event does not bubble up to document
// after v7, if you can debug, remove this
},
- setup_phone() {
- if (frappe.phone_call.handler) {
- this.$wrapper.find('.control-input')
- .append(`
-
-
-
-
- `)
- .find('.phone-btn')
- .click(() => {
- frappe.phone_call.handler(this.get_value(), this.frm);
- });
- }
- },
setup_autoname_check: function() {
if (!this.df.parent) return;
this.meta = frappe.get_meta(this.df.parent);
diff --git a/frappe/public/js/frappe/form/dashboard.js b/frappe/public/js/frappe/form/dashboard.js
index 3b6ccd9a5c..b6daabc616 100644
--- a/frappe/public/js/frappe/form/dashboard.js
+++ b/frappe/public/js/frappe/form/dashboard.js
@@ -35,6 +35,7 @@ frappe.ui.form.Dashboard = Class.extend({
// clear custom
this.wrapper.find('.custom').remove();
+ this.hide();
},
set_headline: function(html, color) {
this.frm.layout.show_message(html, color);
@@ -171,7 +172,7 @@ frappe.ui.form.Dashboard = Class.extend({
if(this.data.graph) {
this.setup_graph();
- show = true;
+ // show = true;
}
if(show) {
@@ -494,6 +495,9 @@ frappe.ui.form.Dashboard = Class.extend({
callback: function(r) {
if(r.message) {
me.render_graph(r.message);
+ me.show();
+ } else {
+ me.hide();
}
}
});
diff --git a/frappe/public/js/frappe/form/footer/timeline.js b/frappe/public/js/frappe/form/footer/timeline.js
index 84f34d4757..159ab8a61b 100644
--- a/frappe/public/js/frappe/form/footer/timeline.js
+++ b/frappe/public/js/frappe/form/footer/timeline.js
@@ -30,7 +30,7 @@ frappe.ui.form.Timeline = class Timeline {
render_input: true,
only_input: true,
on_submit: (val) => {
- if(strip_html(val).trim() != "") {
+ if (strip_html(val).trim() != "" || val.includes('img')) {
this.insert_comment(val, this.comment_area.button);
}
}
@@ -547,10 +547,7 @@ frappe.ui.form.Timeline = class Timeline {
log.color = 'dark';
log.sender = log.owner;
log.comment_type = 'Milestone';
- log.content = __('{0} changed {1} to {2}', [
- frappe.user.full_name(log.owner).bold(),
- frappe.meta.get_label(this.frm.doctype, log.track_field),
- log.value.bold()]);
+ log.content = __('{0} changed {1} to {2}', [ frappe.user.full_name(log.owner).bold(), frappe.meta.get_label(this.frm.doctype, log.track_field), log.value.bold()]);
return log;
});
return milestones;
@@ -613,11 +610,7 @@ frappe.ui.form.Timeline = class Timeline {
const field_display_status = frappe.perm.get_field_display_status(df, null,
me.frm.perm);
if (field_display_status === 'Read' || field_display_status === 'Write') {
- parts.push(__('{0} from {1} to {2}', [
- __(df.label),
- me.format_content_for_timeline(p[1]),
- me.format_content_for_timeline(p[2])
- ]));
+ parts.push(__('{0} from {1} to {2}', [ __(df.label), me.format_content_for_timeline(p[1]), me.format_content_for_timeline(p[2])]));
}
}
}
@@ -648,13 +641,7 @@ frappe.ui.form.Timeline = class Timeline {
null, me.frm.perm);
if (field_display_status === 'Read' || field_display_status === 'Write') {
- parts.push(__('{0} from {1} to {2} in row #{3}', [
- frappe.meta.get_label(me.frm.fields_dict[row[0]].grid.doctype,
- p[0]),
- me.format_content_for_timeline(p[1]),
- me.format_content_for_timeline(p[2]),
- row[1]
- ]));
+ parts.push(__('{0} from {1} to {2} in row #{3}', [ frappe.meta.get_label( me.frm.fields_dict[row[0]].grid.doctype, p[0]), me.format_content_for_timeline(p[1]), me.format_content_for_timeline(p[2]), row[1] ]));
}
}
return parts.length < 3;
@@ -691,8 +678,7 @@ frappe.ui.form.Timeline = class Timeline {
return p;
});
if (parts.length) {
- out.push(me.get_version_comment(version, __("{0} rows for {1}",
- [__(key), parts.join(', ')])));
+ out.push(me.get_version_comment(version, __("{0} rows for {1}", [__(key), parts.join(', ')])));
}
}
});
diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js
index bb9e8c22d1..90b628f269 100644
--- a/frappe/public/js/frappe/form/form.js
+++ b/frappe/public/js/frappe/form/form.js
@@ -232,14 +232,10 @@ frappe.ui.form.Form = class FrappeForm {
throw "attach error";
}
- if(me.attachments.max_reached()) {
- frappe.msgprint(__("Maximum Attachment Limit for this record reached."));
- throw "attach error";
- }
-
new frappe.ui.FileUploader({
doctype: me.doctype,
docname: me.docname,
+ frm: me,
files: dataTransfer.files,
folder: 'Home/Attachments',
on_success(file_doc) {
diff --git a/frappe/public/js/frappe/form/grid_row.js b/frappe/public/js/frappe/form/grid_row.js
index 827fbfdee6..ec9cee9c39 100644
--- a/frappe/public/js/frappe/form/grid_row.js
+++ b/frappe/public/js/frappe/form/grid_row.js
@@ -373,6 +373,7 @@ export default class GridRow {
// no text editor in grid
if (df.fieldtype=='Text Editor') {
+ df = Object.assign({}, df);
df.fieldtype = 'Text';
}
diff --git a/frappe/public/js/frappe/form/layout.js b/frappe/public/js/frappe/form/layout.js
index 2195568317..3505cf4857 100644
--- a/frappe/public/js/frappe/form/layout.js
+++ b/frappe/public/js/frappe/form/layout.js
@@ -113,7 +113,7 @@ frappe.ui.form.Layout = Class.extend({
label: __('Dashboard'),
cssClass: 'form-dashboard',
collapsible: 1,
- //hidden: 1
+ // hidden: 1
});
},
diff --git a/frappe/public/js/frappe/form/quick_entry.js b/frappe/public/js/frappe/form/quick_entry.js
index 2da7b8f236..eed49e070b 100644
--- a/frappe/public/js/frappe/form/quick_entry.js
+++ b/frappe/public/js/frappe/form/quick_entry.js
@@ -36,9 +36,14 @@ frappe.ui.form.QuickEntryForm = Class.extend({
this.render_dialog();
resolve(this);
} else {
+ // no quick entry, open full form
frappe.quick_entry = null;
frappe.set_route('Form', this.doctype, this.doc.name)
.then(() => resolve(this));
+ // call init_callback for consistency
+ if (this.init_callback) {
+ this.init_callback(this.doc);
+ }
}
});
});
diff --git a/frappe/public/js/frappe/form/sidebar/attachments.js b/frappe/public/js/frappe/form/sidebar/attachments.js
index 165527e281..56b484e7c4 100644
--- a/frappe/public/js/frappe/form/sidebar/attachments.js
+++ b/frappe/public/js/frappe/form/sidebar/attachments.js
@@ -16,15 +16,19 @@ frappe.ui.form.Attachments = Class.extend({
this.add_attachment_wrapper = this.parent.find(".add_attachment").parent();
this.attachments_label = this.parent.find(".attachments-label");
},
- max_reached: function() {
- // no of attachments
- var n = Object.keys(this.get_attachments()).length;
-
- // button if the number of attachments is less than max
- if(n < this.frm.meta.max_attachments || !this.frm.meta.max_attachments) {
- return false;
+ max_reached: function(raise_exception=false) {
+ const attachment_count = Object.keys(this.get_attachments()).length;
+ const attachment_limit = this.frm.meta.max_attachments;
+ if (attachment_limit && attachment_count >= attachment_limit) {
+ if (raise_exception) {
+ frappe.throw({
+ title: __("Attachment Limit Reached"),
+ message: __("Maximum attachment limit of {0} has been reached.", [cstr(attachment_limit).bold()]),
+ });
+ }
+ return true;
}
- return true;
+ return false;
},
refresh: function() {
var me = this;
@@ -140,7 +144,6 @@ frappe.ui.form.Attachments = Class.extend({
});
},
new_attachment: function(fieldname) {
- var me = this;
if (this.dialog) {
// remove upload dialog
this.dialog.$wrapper.remove();
@@ -149,6 +152,7 @@ frappe.ui.form.Attachments = Class.extend({
new frappe.ui.FileUploader({
doctype: this.frm.doctype,
docname: this.frm.docname,
+ frm: this.frm,
folder: 'Home/Attachments',
on_success: (file_doc) => {
this.attachment_uploaded(file_doc);
diff --git a/frappe/public/js/frappe/form/templates/form_sidebar.html b/frappe/public/js/frappe/form/templates/form_sidebar.html
index 67e674b1c1..481d0159c3 100644
--- a/frappe/public/js/frappe/form/templates/form_sidebar.html
+++ b/frappe/public/js/frappe/form/templates/form_sidebar.html
@@ -60,7 +60,7 @@