From 2bbe464f59e2808a35f2d8d2bc938e05b6ef8af2 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 24 Jul 2020 17:53:22 +0530 Subject: [PATCH 001/197] feat: Allow skip tables during backups --- frappe/utils/backups.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index 3b905de6bd..d0867e40e6 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -148,7 +148,11 @@ class BackupGenerator: args = dict([item[0], frappe.utils.esc(str(item[1]), '$ ')] for item in self.__dict__.copy().items()) - cmd_string = """mysqldump --single-transaction --quick --lock-tables=false -u %(user)s -p%(password)s %(db_name)s -h %(db_host)s -P %(db_port)s | gzip > %(backup_path_db)s """ % args + if self.verbose: + print("Skipping Tables: {0}\n".format(", ".join(frappe.conf.ignore_tables_in_backup))) + + args["skip_tables"] = " ".join(["--ignore-table={0}.{1}".format(frappe.conf.db_name, table) for table in frappe.conf.ignore_tables_in_backup]) + cmd_string = """mysqldump --single-transaction --quick --lock-tables=false -u %(user)s -p%(password)s %(db_name)s -h %(db_host)s -P %(db_port)s %(skip_tables)s | gzip > %(backup_path_db)s """ % args if self.db_type == 'postgres': cmd_string = "pg_dump postgres://{user}:{password}@{db_host}:{db_port}/{db_name} | gzip > {backup_path_db}".format( From befea91e6d5b7081960807976107f823ab559d18 Mon Sep 17 00:00:00 2001 From: gavin Date: Mon, 27 Jul 2020 11:01:01 +0530 Subject: [PATCH 002/197] fix: empty conf value for ignore_tables_in_backup Co-authored-by: Faris Ansari --- frappe/utils/backups.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index d0867e40e6..e475cef276 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -151,7 +151,7 @@ class BackupGenerator: if self.verbose: print("Skipping Tables: {0}\n".format(", ".join(frappe.conf.ignore_tables_in_backup))) - args["skip_tables"] = " ".join(["--ignore-table={0}.{1}".format(frappe.conf.db_name, table) for table in frappe.conf.ignore_tables_in_backup]) + args["skip_tables"] = " ".join(["--ignore-table={0}.{1}".format(frappe.conf.db_name, table) for table in frappe.conf.ignore_tables_in_backup or []]) cmd_string = """mysqldump --single-transaction --quick --lock-tables=false -u %(user)s -p%(password)s %(db_name)s -h %(db_host)s -P %(db_port)s %(skip_tables)s | gzip > %(backup_path_db)s """ % args if self.db_type == 'postgres': From 6cb6a18b48a050efc7ee1e307f180d3e4ba55ec3 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 23 Sep 2020 13:18:04 +0530 Subject: [PATCH 003/197] feat: Ability to exclude/include certain doctypes from conf or via CLI --- frappe/commands/site.py | 18 +++++++- frappe/utils/backups.py | 92 +++++++++++++++++++++++++++++++++-------- 2 files changed, 90 insertions(+), 20 deletions(-) diff --git a/frappe/commands/site.py b/frappe/commands/site.py index c5008b32a5..a727341b73 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -387,10 +387,13 @@ def use(site, sites_path='.'): @click.command('backup') @click.option('--with-files', default=False, is_flag=True, help="Take backup with files") +@click.option('--ignore-backup-conf', default=False, is_flag=True, help="Ignore excludes/includes set in config") +@click.option('--include', default="", type=str, help="Specify the DocTypes to backup seperated by commas") +@click.option('--exclude', default="", type=str, help="Specify the DocTypes to not backup seperated by commas") @click.option('--verbose', default=False, is_flag=True) @pass_context def backup(context, with_files=False, backup_path_db=None, backup_path_files=None, - backup_path_private_files=None, quiet=False, verbose=False): + backup_path_private_files=None, quiet=False, verbose=False, ignore_backup_conf=False, include="", exclude=""): "Backup" from frappe.utils.backups import scheduled_backup verbose = verbose or context.verbose @@ -399,10 +402,21 @@ def backup(context, with_files=False, backup_path_db=None, backup_path_files=Non try: frappe.init(site=site) frappe.connect() - odb = scheduled_backup(ignore_files=not with_files, backup_path_db=backup_path_db, backup_path_files=backup_path_files, backup_path_private_files=backup_path_private_files, force=True, verbose=verbose) + odb = scheduled_backup( + ignore_files=not with_files, + backup_path_db=backup_path_db, + backup_path_files=backup_path_files, + backup_path_private_files=backup_path_private_files, + ignore_conf=ignore_backup_conf, + include_doctypes=include, + exclude_doctypes=exclude, + verbose=verbose, + force=True, + ) except Exception as e: if verbose: print("Backup failed for {0}. Database or site_config.json may be corrupted".format(site)) + print(e) exit_code = 1 continue diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index 4d29134abd..d3a063cd89 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -18,6 +18,7 @@ from frappe.utils import cstr, get_url, now_datetime # backup variable for backwards compatibility verbose = False _verbose = verbose +base_tables = ["__Auth", "__global_search", "__UserSettings"] class BackupGenerator: @@ -29,7 +30,7 @@ class BackupGenerator: """ def __init__(self, db_name, user, password, backup_path_db=None, backup_path_files=None, backup_path_private_files=None, db_host="localhost", db_port=None, verbose=False, - db_type='mariadb', backup_path_conf=None): + db_type='mariadb', backup_path_conf=None, ignore_conf=False, include_doctypes="", exclude_doctypes=""): global _verbose self.db_host = db_host self.db_port = db_port @@ -41,6 +42,9 @@ class BackupGenerator: self.backup_path_db = backup_path_db self.backup_path_files = backup_path_files self.backup_path_private_files = backup_path_private_files + self.ignore_conf = ignore_conf + self.include_doctypes = include_doctypes + self.exclude_doctypes = exclude_doctypes if not self.db_type: self.db_type = 'mariadb' @@ -54,6 +58,7 @@ class BackupGenerator: self.site_slug = site.replace('.', '_') self.verbose = verbose self.setup_backup_directory() + self.setup_backup_tables() _verbose = verbose def setup_backup_directory(self): @@ -68,6 +73,35 @@ class BackupGenerator: dir = os.path.dirname(file_path) os.makedirs(dir, exist_ok=True) + def setup_backup_tables(self): + """Sets self.backup_includes, self.backup_excludes based on passed args + """ + def get_tables(doctypes): + tables = [] + for doctype in doctypes: + if doctype: + if doctype.startswith("tab"): + tables.append(doctype) + else: + tables.append("tab" + doctype) + return tables + + passed_tables = { + "include": get_tables(self.include_doctypes.strip().split(",")), + "exclude": get_tables(self.exclude_doctypes.strip().split(",")) + } + conf_tables = { + "include": get_tables(frappe.conf.backup.get("includes", [])) + base_tables, + "exclude": get_tables(frappe.conf.backup.get("excludes", [])) + } + + self.backup_includes = passed_tables["include"] + self.backup_excludes = passed_tables["exclude"] + + if not self.ignore_conf: + self.backup_includes = self.backup_includes or conf_tables["include"] + self.backup_excludes = self.backup_excludes or conf_tables["exclude"] + @property def site_config_backup_path(self): # For backwards compatibility @@ -189,23 +223,42 @@ class BackupGenerator: args = dict([item[0], frappe.utils.esc(str(item[1]), '$ ')] for item in self.__dict__.copy().items()) - if self.verbose: - print("Skipping Tables: {0}\n".format(", ".join(frappe.conf.ignore_tables_in_backup))) - - args["skip_tables"] = " ".join(["--ignore-table={0}.{1}".format(frappe.conf.db_name, table) for table in frappe.conf.ignore_tables_in_backup or []]) - cmd_string = """mysqldump --single-transaction --quick --lock-tables=false -u %(user)s -p%(password)s %(db_name)s -h %(db_host)s -P %(db_port)s %(skip_tables)s | gzip > %(backup_path_db)s """ % args + if self.backup_includes: + print("Backing Up Tables: {0}\n".format(", ".join(self.backup_includes))) + elif self.backup_excludes: + print("Skipping Tables: {0}\n".format(", ".join(self.backup_excludes))) if self.db_type == 'postgres': - cmd_string = "pg_dump postgres://{user}:{password}@{db_host}:{db_port}/{db_name} | gzip > {backup_path_db}".format( - user=args.get('user'), - password=args.get('password'), - db_host=args.get('db_host'), - db_port=args.get('db_port'), - db_name=args.get('db_name'), - backup_path_db=args.get('backup_path_db') - ) + if self.backup_includes: + args["include"] = " ".join(["--table='{0}'".format(table) for table in self.backup_includes]) + elif self.backup_excludes: + args["exclude"] = " ".join(["--exclude-table='{0}'".format(table) for table in self.backup_excludes]) - err, out = frappe.utils.execute_in_shell(cmd_string) + cmd_string = "pg_dump postgres://{user}:{password}@{db_host}:{db_port}/{db_name} {include} {exclude} | gzip > {backup_path_db}" + + else: + if self.backup_includes: + args["include"] = " ".join(["'{0}'".format(x) for x in self.backup_includes]) + + elif self.backup_excludes: + args["exclude"] = " ".join(["--ignore-table='{0}.{1}'".format(frappe.conf.db_name, table) for table in self.backup_excludes]) + + cmd_string = "mysqldump --single-transaction --quick --lock-tables=false -u {user} -p{password} {db_name} -h {db_host} -P {db_port} {include} {exclude} | gzip > {backup_path_db}" + + command = cmd_string.format( + user=args.get('user'), + password=args.get('password'), + db_host=args.get('db_host'), + db_port=args.get('db_port'), + db_name=args.get('db_name'), + backup_path_db=args.get('backup_path_db'), + exclude=args.get('exclude', ''), + include=args.get('include', '') + ) + + print(command) + + err, out = frappe.utils.execute_in_shell(command) def send_email(self): """ @@ -279,14 +332,14 @@ def fetch_latest_backups(): } -def scheduled_backup(older_than=6, ignore_files=False, backup_path_db=None, backup_path_files=None, backup_path_private_files=None, force=False, verbose=False): +def scheduled_backup(older_than=6, ignore_files=False, backup_path_db=None, backup_path_files=None, backup_path_private_files=None, force=False, verbose=False, ignore_conf=False, include_doctypes="", exclude_doctypes=""): """this function is called from scheduler deletes backups older than 7 days takes backup""" - odb = new_backup(older_than, ignore_files, backup_path_db=backup_path_db, backup_path_files=backup_path_files, force=force, verbose=verbose) + odb = new_backup(older_than, ignore_files, backup_path_db=backup_path_db, backup_path_files=backup_path_files, force=force, verbose=verbose, ignore_conf=ignore_conf, include_doctypes=include_doctypes, exclude_doctypes=exclude_doctypes) return odb -def new_backup(older_than=6, ignore_files=False, backup_path_db=None, backup_path_files=None, backup_path_private_files=None, force=False, verbose=False): +def new_backup(older_than=6, ignore_files=False, backup_path_db=None, backup_path_files=None, backup_path_private_files=None, force=False, verbose=False, ignore_conf=False, include_doctypes="", exclude_doctypes=""): delete_temp_backups(older_than = frappe.conf.keep_backups_for_hours or 24) odb = BackupGenerator(frappe.conf.db_name, frappe.conf.db_name,\ frappe.conf.db_password, @@ -295,6 +348,9 @@ def new_backup(older_than=6, ignore_files=False, backup_path_db=None, backup_pat db_host = frappe.db.host, db_port = frappe.db.port, db_type = frappe.conf.db_type, + ignore_conf=ignore_conf, + include_doctypes=include_doctypes, + exclude_doctypes=exclude_doctypes, verbose=verbose) odb.get_backup(older_than, ignore_files, force=force) return odb From bb2a1910e86c959286f461ededdf7678dffa6dfc Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 24 Sep 2020 11:34:43 +0530 Subject: [PATCH 004/197] fix: Handle excludes or includes explicitly * Take backup only if doctype exists * Show mysql command only if verbose is passed --- frappe/utils/backups.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index d3a063cd89..b8d1da299a 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -76,10 +76,11 @@ class BackupGenerator: def setup_backup_tables(self): """Sets self.backup_includes, self.backup_excludes based on passed args """ + existing_doctypes = set([x.name for x in frappe.get_all("DocType")]) def get_tables(doctypes): tables = [] for doctype in doctypes: - if doctype: + if doctype and doctype in existing_doctypes: if doctype.startswith("tab"): tables.append(doctype) else: @@ -98,7 +99,7 @@ class BackupGenerator: self.backup_includes = passed_tables["include"] self.backup_excludes = passed_tables["exclude"] - if not self.ignore_conf: + if not (self.backup_includes or self.backup_excludes) and not self.ignore_conf: self.backup_includes = self.backup_includes or conf_tables["include"] self.backup_excludes = self.backup_excludes or conf_tables["exclude"] @@ -239,7 +240,6 @@ class BackupGenerator: else: if self.backup_includes: args["include"] = " ".join(["'{0}'".format(x) for x in self.backup_includes]) - elif self.backup_excludes: args["exclude"] = " ".join(["--ignore-table='{0}.{1}'".format(frappe.conf.db_name, table) for table in self.backup_excludes]) @@ -256,7 +256,8 @@ class BackupGenerator: include=args.get('include', '') ) - print(command) + if self.verbose: + print(command) err, out = frappe.utils.execute_in_shell(command) From bde7adeb2f597ef59d7c173ad5265b4b1fab6c27 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 7 Oct 2020 14:06:28 +0530 Subject: [PATCH 005/197] style: Black + flake8 --- frappe/utils/backups.py | 445 ++++++++++++++++++++++++++++------------ 1 file changed, 314 insertions(+), 131 deletions(-) diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index 25147f2085..b59f5526e9 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -2,7 +2,6 @@ # MIT License. See license.txt # imports - standard imports -import json import os from calendar import timegm from datetime import datetime @@ -25,12 +24,31 @@ base_tables = ["__Auth", "__global_search", "__UserSettings"] class BackupGenerator: """ - This class contains methods to perform On Demand Backup + This class contains methods to perform On Demand Backup - To initialize, specify (db_name, user, password, db_file_name=None, db_host="localhost") - If specifying db_file_name, also append ".sql.gz" + To initialize, specify (db_name, user, password, db_file_name=None, db_host="localhost") + If specifying db_file_name, also append ".sql.gz" """ - def __init__(self, db_name, user, password, backup_path=None, backup_path_db=None, backup_path_files=None, backup_path_private_files=None, db_host="localhost", db_port=None, db_type='mariadb', backup_path_conf=None, ignore_conf=False, compress_files=False, include_doctypes="", exclude_doctypes="", verbose=False): + + def __init__( + self, + db_name, + user, + password, + backup_path=None, + backup_path_db=None, + backup_path_files=None, + backup_path_private_files=None, + db_host="localhost", + db_port=None, + db_type="mariadb", + backup_path_conf=None, + ignore_conf=False, + compress_files=False, + include_doctypes="", + exclude_doctypes="", + verbose=False, + ): global _verbose self.compress_files = compress_files or compress self.db_host = db_host @@ -49,22 +67,29 @@ class BackupGenerator: self.exclude_doctypes = exclude_doctypes if not self.db_type: - self.db_type = 'mariadb' + self.db_type = "mariadb" - if not self.db_port and self.db_type == 'mariadb': - self.db_port = 3306 - elif not self.db_port and self.db_type == 'postgres': - self.db_port = 5432 + if not self.db_port: + if self.db_type == "mariadb": + self.db_port = 3306 + if self.db_type == "postgres": + self.db_port = 5432 site = frappe.local.site or frappe.generate_hash(length=8) - self.site_slug = site.replace('.', '_') + self.site_slug = site.replace(".", "_") self.verbose = verbose self.setup_backup_directory() self.setup_backup_tables() _verbose = verbose def setup_backup_directory(self): - specified = self.backup_path or self.backup_path_db or self.backup_path_files or self.backup_path_private_files or self.backup_path_conf + specified = ( + self.backup_path + or self.backup_path_db + or self.backup_path_files + or self.backup_path_private_files + or self.backup_path_conf + ) if not specified: backups_folder = get_backup_path() @@ -74,15 +99,22 @@ class BackupGenerator: if self.backup_path: os.makedirs(self.backup_path, exist_ok=True) - for file_path in set([self.backup_path_files, self.backup_path_db, self.backup_path_private_files, self.backup_path_conf]): + for file_path in set( + [ + self.backup_path_files, + self.backup_path_db, + self.backup_path_private_files, + self.backup_path_conf, + ] + ): if file_path: dir = os.path.dirname(file_path) os.makedirs(dir, exist_ok=True) def setup_backup_tables(self): - """Sets self.backup_includes, self.backup_excludes based on passed args - """ + """Sets self.backup_includes, self.backup_excludes based on passed args""" existing_doctypes = set([x.name for x in frappe.get_all("DocType")]) + def get_tables(doctypes): tables = [] for doctype in doctypes: @@ -95,11 +127,11 @@ class BackupGenerator: passed_tables = { "include": get_tables(self.include_doctypes.strip().split(",")), - "exclude": get_tables(self.exclude_doctypes.strip().split(",")) + "exclude": get_tables(self.exclude_doctypes.strip().split(",")), } conf_tables = { "include": get_tables(frappe.conf.backup.get("includes", [])) + base_tables, - "exclude": get_tables(frappe.conf.backup.get("excludes", [])) + "exclude": get_tables(frappe.conf.backup.get("excludes", [])), } self.backup_includes = passed_tables["include"] @@ -112,24 +144,43 @@ class BackupGenerator: @property def site_config_backup_path(self): # For backwards compatibility - click.secho("BackupGenerator.site_config_backup_path has been deprecated in favour of BackupGenerator.backup_path_conf", fg="yellow") + click.secho( + "BackupGenerator.site_config_backup_path has been deprecated in favour of" + " BackupGenerator.backup_path_conf", + fg="yellow", + ) return getattr(self, "backup_path_conf", None) def get_backup(self, older_than=24, ignore_files=False, force=False): """ - Takes a new dump if existing file is old - and sends the link to the file as email + Takes a new dump if existing file is old + and sends the link to the file as email """ - #Check if file exists and is less than a day old - #If not Take Dump + # Check if file exists and is less than a day old + # If not Take Dump if not force: - last_db, last_file, last_private_file, site_config_backup_path = self.get_recent_backup(older_than) + ( + last_db, + last_file, + last_private_file, + site_config_backup_path, + ) = self.get_recent_backup(older_than) else: - last_db, last_file, last_private_file, site_config_backup_path = False, False, False, False + last_db, last_file, last_private_file, site_config_backup_path = ( + False, + False, + False, + False, + ) - self.todays_date = now_datetime().strftime('%Y%m%d_%H%M%S') + self.todays_date = now_datetime().strftime("%Y%m%d_%H%M%S") - if not (self.backup_path_conf and self.backup_path_db and self.backup_path_files and self.backup_path_private_files): + if not ( + self.backup_path_conf + and self.backup_path_db + and self.backup_path_files + and self.backup_path_private_files + ): self.set_backup_file_name() if not (last_db and last_file and last_private_file and site_config_backup_path): @@ -145,7 +196,7 @@ class BackupGenerator: self.backup_path_conf = site_config_backup_path def set_backup_file_name(self): - #Generate a random name using today's date and a 8 digit random number + # Generate a random name using today's date and a 8 digit random number for_conf = self.todays_date + "-" + self.site_slug + "-site_config_backup.json" for_db = self.todays_date + "-" + self.site_slug + "-database.sql.gz" ext = "tgz" if self.compress_files else "tar" @@ -191,8 +242,7 @@ class BackupGenerator: return file_path latest_backups = { - file_type: get_latest(pattern) - for file_type, pattern in file_type_slugs.items() + file_type: get_latest(pattern) for file_type, pattern in file_type_slugs.items() } recent_backups = { @@ -208,32 +258,40 @@ class BackupGenerator: def zip_files(self): # For backwards compatibility - pre v13 - click.secho("BackupGenerator.zip_files has been deprecated in favour of BackupGenerator.backup_files", fg="yellow") + click.secho( + "BackupGenerator.zip_files has been deprecated in favour of" + " BackupGenerator.backup_files", + fg="yellow", + ) return self.backup_files() def get_summary(self): summary = { "config": { "path": self.backup_path_conf, - "size": get_file_size(self.backup_path_conf, format=True) + "size": get_file_size(self.backup_path_conf, format=True), }, "database": { "path": self.backup_path_db, - "size": get_file_size(self.backup_path_db, format=True) - } + "size": get_file_size(self.backup_path_db, format=True), + }, } - if os.path.exists(self.backup_path_files) and os.path.exists(self.backup_path_private_files): - summary.update({ - "public": { - "path": self.backup_path_files, - "size": get_file_size(self.backup_path_files, format=True) - }, - "private": { - "path": self.backup_path_private_files, - "size": get_file_size(self.backup_path_private_files, format=True) + if os.path.exists(self.backup_path_files) and os.path.exists( + self.backup_path_private_files + ): + summary.update( + { + "public": { + "path": self.backup_path_files, + "size": get_file_size(self.backup_path_files, format=True), + }, + "private": { + "path": self.backup_path_private_files, + "size": get_file_size(self.backup_path_private_files, format=True), + }, } - }) + ) return summary @@ -249,13 +307,17 @@ class BackupGenerator: for folder in ("public", "private"): files_path = frappe.get_site_path(folder, "files") - backup_path = self.backup_path_files if folder=="public" else self.backup_path_private_files + backup_path = ( + self.backup_path_files if folder == "public" else self.backup_path_private_files + ) if self.compress_files: cmd_string = "tar cf - {1} | gzip > {0}" else: cmd_string = "tar -cf {0} {1}" - output = subprocess.check_output(cmd_string.format(backup_path, files_path), shell=True) + output = subprocess.check_output( + cmd_string.format(backup_path, files_path), shell=True + ) if self.verbose and output: print(output.decode("utf8")) @@ -271,39 +333,57 @@ class BackupGenerator: import frappe.utils # escape reserved characters - args = dict([item[0], frappe.utils.esc(str(item[1]), '$ ')] - for item in self.__dict__.copy().items()) + args = dict( + [item[0], frappe.utils.esc(str(item[1]), "$ ")] + for item in self.__dict__.copy().items() + ) if self.backup_includes: print("Backing Up Tables: {0}\n".format(", ".join(self.backup_includes))) elif self.backup_excludes: print("Skipping Tables: {0}\n".format(", ".join(self.backup_excludes))) - if self.db_type == 'postgres': + if self.db_type == "postgres": if self.backup_includes: - args["include"] = " ".join(["--table='{0}'".format(table) for table in self.backup_includes]) + args["include"] = " ".join( + ["--table='{0}'".format(table) for table in self.backup_includes] + ) elif self.backup_excludes: - args["exclude"] = " ".join(["--exclude-table='{0}'".format(table) for table in self.backup_excludes]) + args["exclude"] = " ".join( + ["--exclude-table='{0}'".format(table) for table in self.backup_excludes] + ) - cmd_string = "pg_dump postgres://{user}:{password}@{db_host}:{db_port}/{db_name} {include} {exclude} | gzip > {backup_path_db}" + cmd_string = ( + "pg_dump postgres://{user}:{password}@{db_host}:{db_port}/{db_name}" + " {include} {exclude} | gzip > {backup_path_db}" + ) else: if self.backup_includes: args["include"] = " ".join(["'{0}'".format(x) for x in self.backup_includes]) elif self.backup_excludes: - args["exclude"] = " ".join(["--ignore-table='{0}.{1}'".format(frappe.conf.db_name, table) for table in self.backup_excludes]) + args["exclude"] = " ".join( + [ + "--ignore-table='{0}.{1}'".format(frappe.conf.db_name, table) + for table in self.backup_excludes + ] + ) - cmd_string = "mysqldump --single-transaction --quick --lock-tables=false -u {user} -p{password} {db_name} -h {db_host} -P {db_port} {include} {exclude} | gzip > {backup_path_db}" + cmd_string = ( + "mysqldump --single-transaction --quick --lock-tables=false -u {user}" + " -p{password} {db_name} -h {db_host} -P {db_port} {include} {exclude}" + " | gzip > {backup_path_db}" + ) command = cmd_string.format( - user=args.get('user'), - password=args.get('password'), - db_host=args.get('db_host'), - db_port=args.get('db_port'), - db_name=args.get('db_name'), - backup_path_db=args.get('backup_path_db'), - exclude=args.get('exclude', ''), - include=args.get('include', '') + user=args.get("user"), + password=args.get("password"), + db_host=args.get("db_host"), + db_port=args.get("db_port"), + db_name=args.get("db_name"), + backup_path_db=args.get("backup_path_db"), + exclude=args.get("exclude", ""), + include=args.get("include", ""), ) if self.verbose: @@ -313,13 +393,17 @@ class BackupGenerator: def send_email(self): """ - Sends the link to backup file located at erpnext/backups + Sends the link to backup file located at erpnext/backups """ from frappe.email import get_system_managers recipient_list = get_system_managers() - db_backup_url = get_url(os.path.join('backups', os.path.basename(self.backup_path_db))) - files_backup_url = get_url(os.path.join('backups', os.path.basename(self.backup_path_files))) + db_backup_url = get_url( + os.path.join("backups", os.path.basename(self.backup_path_db)) + ) + files_backup_url = get_url( + os.path.join("backups", os.path.basename(self.backup_path_files)) + ) msg = """Hello, @@ -331,11 +415,13 @@ Your backups are ready to be downloaded. This link will be valid for 24 hours. A new backup will be available for download only after 24 hours.""" % { "db_backup_url": db_backup_url, - "files_backup_url": files_backup_url + "files_backup_url": files_backup_url, } datetime_str = datetime.fromtimestamp(os.stat(self.backup_path_db).st_ctime) - subject = datetime_str.strftime("%d/%m/%Y %H:%M:%S") + """ - Backup ready to be downloaded""" + subject = ( + datetime_str.strftime("%d/%m/%Y %H:%M:%S") + """ - Backup ready to be downloaded""" + ) frappe.sendmail(recipients=recipient_list, msg=msg, subject=subject) return recipient_list @@ -344,16 +430,25 @@ download only after 24 hours.""" % { @frappe.whitelist() def get_backup(): """ - This function is executed when the user clicks on - Toos > Download Backup + This function is executed when the user clicks on + Toos > Download Backup """ delete_temp_backups() - odb = BackupGenerator(frappe.conf.db_name, frappe.conf.db_name,\ - frappe.conf.db_password, db_host = frappe.db.host,\ - db_type=frappe.conf.db_type, db_port=frappe.conf.db_port) + odb = BackupGenerator( + frappe.conf.db_name, + frappe.conf.db_name, + frappe.conf.db_password, + db_host=frappe.db.host, + db_type=frappe.conf.db_type, + db_port=frappe.conf.db_port, + ) odb.get_backup() recipient_list = odb.send_email() - frappe.msgprint(_("Download link for your backup will be emailed on the following email address: {0}").format(', '.join(recipient_list))) + frappe.msgprint( + _( + "Download link for your backup will be emailed on the following email address: {0}" + ).format(", ".join(recipient_list)) + ) @frappe.whitelist() @@ -375,44 +470,86 @@ def fetch_latest_backups(): ) database, public, private, config = odb.get_recent_backup(older_than=24 * 30) - return { - "database": database, - "public": public, - "private": private, - "config": config - } + return {"database": database, "public": public, "private": private, "config": config} -def scheduled_backup(older_than=6, ignore_files=False, backup_path=None, backup_path_db=None, backup_path_files=None, backup_path_private_files=None, backup_path_conf=None, ignore_conf=False, include_doctypes="", exclude_doctypes="", compress=False, force=False, verbose=False): +def scheduled_backup( + older_than=6, + ignore_files=False, + backup_path=None, + backup_path_db=None, + backup_path_files=None, + backup_path_private_files=None, + backup_path_conf=None, + ignore_conf=False, + include_doctypes="", + exclude_doctypes="", + compress=False, + force=False, + verbose=False, +): """this function is called from scheduler - deletes backups older than 7 days - takes backup""" - odb = new_backup(older_than=older_than, ignore_files=ignore_files, backup_path=None, 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_conf, include_doctypes=include_doctypes, exclude_doctypes=exclude_doctypes, compress=compress, force=force, verbose=verbose) + deletes backups older than 7 days + takes backup""" + odb = new_backup( + older_than=older_than, + ignore_files=ignore_files, + backup_path=None, + 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_conf, + include_doctypes=include_doctypes, + exclude_doctypes=exclude_doctypes, + compress=compress, + force=force, + verbose=verbose, + ) return odb -def new_backup(older_than=6, ignore_files=False, backup_path=None, backup_path_db=None, backup_path_files=None, backup_path_private_files=None, backup_path_conf=None, ignore_conf=False, include_doctypes="", exclude_doctypes="", compress=False, force=False, verbose=False): - delete_temp_backups(older_than = frappe.conf.keep_backups_for_hours or 24) - odb = BackupGenerator(frappe.conf.db_name, frappe.conf.db_name,\ - frappe.conf.db_password, - db_host = frappe.db.host, - db_port = frappe.db.port, - db_type = frappe.conf.db_type, - 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_conf, - include_doctypes=include_doctypes, - exclude_doctypes=exclude_doctypes, - verbose=verbose, - compress_files=compress) + +def new_backup( + older_than=6, + ignore_files=False, + backup_path=None, + backup_path_db=None, + backup_path_files=None, + backup_path_private_files=None, + backup_path_conf=None, + ignore_conf=False, + include_doctypes="", + exclude_doctypes="", + compress=False, + force=False, + verbose=False, +): + delete_temp_backups(older_than=frappe.conf.keep_backups_for_hours or 24) + odb = BackupGenerator( + frappe.conf.db_name, + frappe.conf.db_name, + frappe.conf.db_password, + db_host=frappe.db.host, + db_port=frappe.db.port, + db_type=frappe.conf.db_type, + 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_conf, + include_doctypes=include_doctypes, + exclude_doctypes=exclude_doctypes, + verbose=verbose, + compress_files=compress, + ) odb.get_backup(older_than, ignore_files, force=force) return odb + def delete_temp_backups(older_than=24): """ - Cleans up the backup_link_path directory by deleting files older than 24 hours + Cleans up the backup_link_path directory by deleting files older than 24 hours """ backup_path = get_backup_path() if os.path.exists(backup_path): @@ -422,54 +559,72 @@ def delete_temp_backups(older_than=24): if is_file_old(this_file_path, older_than): os.remove(this_file_path) + def is_file_old(db_file_name, older_than=24): - """ - Checks if file exists and is older than specified hours - Returns -> - True: file does not exist or file is old - False: file is new - """ - if os.path.isfile(db_file_name): - from datetime import timedelta - #Get timestamp of the file - file_datetime = datetime.fromtimestamp\ - (os.stat(db_file_name).st_ctime) - if datetime.today() - file_datetime >= timedelta(hours = older_than): - if _verbose: - print("File is old") - return True - else: - if _verbose: - print("File is recent") - return False + """ + Checks if file exists and is older than specified hours + Returns -> + True: file does not exist or file is old + False: file is new + """ + if os.path.isfile(db_file_name): + from datetime import timedelta + + # Get timestamp of the file + file_datetime = datetime.fromtimestamp(os.stat(db_file_name).st_ctime) + if datetime.today() - file_datetime >= timedelta(hours=older_than): + if _verbose: + print("File is old") + return True else: if _verbose: - print("File does not exist") - return True + print("File is recent") + return False + else: + if _verbose: + print("File does not exist") + return True + def get_backup_path(): backup_path = frappe.utils.get_site_path(conf.get("backup_path", "private/backups")) return backup_path -def backup(with_files=False, backup_path_db=None, backup_path_files=None, backup_path_private_files=None, backup_path_conf=None, quiet=False): + +def backup( + with_files=False, + backup_path_db=None, + backup_path_files=None, + backup_path_private_files=None, + backup_path_conf=None, + quiet=False, +): "Backup" - odb = scheduled_backup(ignore_files=not with_files, 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) + odb = scheduled_backup( + ignore_files=not with_files, + 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, + ) return { "backup_path_db": odb.backup_path_db, "backup_path_files": odb.backup_path_files, - "backup_path_private_files": odb.backup_path_private_files + "backup_path_private_files": odb.backup_path_private_files, } if __name__ == "__main__": """ - is_file_old db_name user password db_host db_type db_port - get_backup db_name user password db_host db_type db_port + is_file_old db_name user password db_host db_type db_port + get_backup db_name user password db_host db_type db_port """ import sys + cmd = sys.argv[1] - db_type = 'mariadb' + db_type = "mariadb" try: db_type = sys.argv[6] except IndexError: @@ -482,19 +637,47 @@ if __name__ == "__main__": pass if cmd == "is_file_old": - odb = BackupGenerator(sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5] or "localhost", db_type=db_type, db_port=db_port) + odb = BackupGenerator( + sys.argv[2], + sys.argv[3], + sys.argv[4], + sys.argv[5] or "localhost", + db_type=db_type, + db_port=db_port, + ) is_file_old(odb.db_file_name) if cmd == "get_backup": - odb = BackupGenerator(sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5] or "localhost", db_type=db_type, db_port=db_port) + odb = BackupGenerator( + sys.argv[2], + sys.argv[3], + sys.argv[4], + sys.argv[5] or "localhost", + db_type=db_type, + db_port=db_port, + ) odb.get_backup() if cmd == "take_dump": - odb = BackupGenerator(sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5] or "localhost", db_type=db_type, db_port=db_port) + odb = BackupGenerator( + sys.argv[2], + sys.argv[3], + sys.argv[4], + sys.argv[5] or "localhost", + db_type=db_type, + db_port=db_port, + ) odb.take_dump() if cmd == "send_email": - odb = BackupGenerator(sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5] or "localhost", db_type=db_type, db_port=db_port) + odb = BackupGenerator( + sys.argv[2], + sys.argv[3], + sys.argv[4], + sys.argv[5] or "localhost", + db_type=db_type, + db_port=db_port, + ) odb.send_email("abc.sql.gz") if cmd == "delete_temp_backups": From bd6f52305eb1e48f370217de89608a16976e207c Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 7 Oct 2020 14:32:18 +0530 Subject: [PATCH 006/197] fix: Show traceback if verbose --- frappe/commands/site.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frappe/commands/site.py b/frappe/commands/site.py index dc5d8b838e..446166d44c 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -422,6 +422,8 @@ def backup(context, with_files=False, backup_path=None, backup_path_db=None, bac ) 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 From 7519ba5e1c81a721bc248762b28f03657b12210a Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 7 Oct 2020 14:32:40 +0530 Subject: [PATCH 007/197] fix: Backup tables correctly ? --- frappe/utils/backups.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index b59f5526e9..5a91ad656e 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -129,9 +129,12 @@ class BackupGenerator: "include": get_tables(self.include_doctypes.strip().split(",")), "exclude": get_tables(self.exclude_doctypes.strip().split(",")), } + specified_tables = get_tables(frappe.conf.get("backup", {}).get("includes", [])) + include_tables = (specified_tables + base_tables) if specified_tables else [] + conf_tables = { - "include": get_tables(frappe.conf.backup.get("includes", [])) + base_tables, - "exclude": get_tables(frappe.conf.backup.get("excludes", [])), + "include": include_tables, + "exclude": get_tables(frappe.conf.get("backup", {}).get("excludes", [])), } self.backup_includes = passed_tables["include"] @@ -387,7 +390,7 @@ class BackupGenerator: ) if self.verbose: - print(command) + print(command + "\n") err, out = frappe.utils.execute_in_shell(command) From 7d638d298c098036fcf509d3a6086418644e4b57 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 7 Oct 2020 14:33:02 +0530 Subject: [PATCH 008/197] chore: remove old comment that lied --- frappe/utils/backups.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index 5a91ad656e..5b1f6b0616 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -619,10 +619,6 @@ def backup( if __name__ == "__main__": - """ - is_file_old db_name user password db_host db_type db_port - get_backup db_name user password db_host db_type db_port - """ import sys cmd = sys.argv[1] From 26387401b39517f61fd50f965a5bf84c99e9817a Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 7 Oct 2020 21:06:23 +0530 Subject: [PATCH 009/197] test: Added tests for bench backup skip tables * Added utils for coloured outputs * Fixed bug during last branch update --- frappe/tests/test_commands.py | 112 +++++++++++++++++++++++++++++++++- frappe/utils/backups.py | 2 +- 2 files changed, 110 insertions(+), 4 deletions(-) diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index e7e639c775..39d76aaba5 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -1,10 +1,13 @@ # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors # imports - standard imports +import json import os import shlex import subprocess +import sys import unittest +import gzip from glob import glob # imports - module imports @@ -12,12 +15,66 @@ import frappe from frappe.utils.backups import fetch_latest_backups +# TODO: check frappe.cli.coloured_output to set coloured output! + +def supports_color(): + """ + Returns True if the running system's terminal supports color, and False + otherwise. + """ + plat = sys.platform + supported_platform = plat != 'Pocket PC' and (plat != 'win32' or 'ANSICON' in os.environ) + # isatty is not always implemented, #6223. + is_a_tty = hasattr(sys.stdout, 'isatty') and sys.stdout.isatty() + return supported_platform and is_a_tty + + +class color(dict): + nc = '\033[0m' + blue = '\033[94m' + green = '\033[92m' + yellow = '\033[93m' + red = '\033[91m' + silver = '\033[90m' + + def __getattr__(self, key): + if supports_color(): + ret = self.get(key) + else: + ret = "" + return ret + + def clean(value): - if isinstance(value, (bytes, str)): - value = value.decode().strip() + """Strips and converts bytes to str + + Args: + value ([type]): [description] + + Returns: + [type]: [description] + """ + if isinstance(value, bytes): + value = value.decode() + if isinstance(value, str): + value = value.strip() return value +def exists_in_backup(doctypes, file): + """Checks if the list of doctypes exist in the database.sql.gz file supplied + + Args: + doctypes (list): List of DocTypes to be checked + file (str): Path of the database file + + Returns: + bool: True if all tables exist + """ + with gzip.open(file, 'rb') as f: + content = f.read().decode("utf8") + return all(["CREATE TABLE `tab{}`".format(doctype).lower() in content.lower() for doctype in doctypes]) + class BaseTestCommands(unittest.TestCase): def execute(self, command, kwargs=None): site = {"site": frappe.local.site} @@ -25,7 +82,8 @@ class BaseTestCommands(unittest.TestCase): kwargs.update(site) else: kwargs = site - command = command.replace("\n", " ").format(**kwargs) + command = " ".join(command.split()).format(**kwargs) + print("{0}$ {1}{2}".format(color.silver, command, color.nc)) command = shlex.split(command) self._proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) self.stdout = clean(self._proc.stdout) @@ -54,6 +112,21 @@ class TestCommands(BaseTestCommands): self.assertEquals(self.stdout[1:-1], frappe.bold(text='DocType')) def test_backup(self): + backup = { + "includes": { + "includes": [ + "ToDo", + "Note", + ] + }, + "excludes": { + "excludes": [ + "Activity Log", + "Access Log", + "Error Log" + ] + } + } home = os.path.expanduser("~") site_backup_path = frappe.utils.get_site_path("private", "backups") @@ -119,3 +192,36 @@ class TestCommands(BaseTestCommands): # test 6: take a backup with --verbose self.execute("bench --site {site} backup --verbose") self.assertEquals(self.returncode, 0) + + # test 7: take a backup with frappe.conf.backup.includes + self.execute("bench --site {site} set-config backup '{includes}' --as-dict", {"includes": json.dumps(backup["includes"])}) + self.execute("bench --site {site} backup --verbose") + self.assertEquals(self.returncode, 0) + database = fetch_latest_backups()["database"] + self.assertTrue(exists_in_backup(backup["includes"]["includes"], database)) + + # test 8: take a backup with frappe.conf.backup.excludes + self.execute("bench --site {site} set-config backup '{excludes}' --as-dict", {"excludes": json.dumps(backup["excludes"])}) + self.execute("bench --site {site} backup --verbose") + self.assertEquals(self.returncode, 0) + database = fetch_latest_backups()["database"] + self.assertFalse(exists_in_backup(backup["excludes"]["excludes"], database)) + self.assertTrue(exists_in_backup(backup["includes"]["includes"], database)) + + # test 9: take a backup with --include (with frappe.conf.excludes still set) + self.execute("bench --site {site} backup --include '{include}'", {"include": ",".join(backup["includes"]["includes"])}) + self.assertEquals(self.returncode, 0) + database = fetch_latest_backups()["database"] + self.assertTrue(exists_in_backup(backup["includes"]["includes"], database)) + + # test 10: take a backup with --exclude + self.execute("bench --site {site} backup --exclude '{exclude}'", {"exclude": ",".join(backup["excludes"]["excludes"])}) + self.assertEquals(self.returncode, 0) + database = fetch_latest_backups()["database"] + self.assertFalse(exists_in_backup(backup["excludes"]["excludes"], database)) + + # test 11: take a backup with --ignore-backup-conf + self.execute("bench --site {site} backup --ignore-backup-conf") + self.assertEquals(self.returncode, 0) + database = fetch_latest_backups()["database"] + self.assertTrue(exists_in_backup(backup["excludes"]["excludes"], database)) diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index 5b1f6b0616..43dd7c17f1 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -497,7 +497,7 @@ def scheduled_backup( odb = new_backup( older_than=older_than, ignore_files=ignore_files, - backup_path=None, + backup_path=backup_path, backup_path_db=backup_path_db, backup_path_files=backup_path_files, backup_path_private_files=backup_path_private_files, From ff1f1e755da30f028ff52e7b878a3ee48905e4ca Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 8 Oct 2020 15:45:15 +0530 Subject: [PATCH 010/197] fix: Dynamic summary spacing --- frappe/utils/backups.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index 43dd7c17f1..1ae976807a 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -302,8 +302,12 @@ class BackupGenerator: backup_summary = self.get_summary() print("Backup Summary for {0} at {1}".format(frappe.local.site, now())) + title = max([len(x) for x in backup_summary]) + path = max([len(x["path"]) for x in backup_summary.values()]) + for _type, info in backup_summary.items(): - print("{0:8}: {1:85} {2}".format(_type.title(), info["path"], info["size"])) + template = "{{0:{0}}}: {{1:{1}}} {{2}}".format(title, path) + print(template.format(_type.title(), info["path"], info["size"])) def backup_files(self): import subprocess From 104186906f3ecf8abe130c35e34c4c4ba9b21e36 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 8 Oct 2020 16:23:14 +0530 Subject: [PATCH 011/197] feat: Show command execution summary in message format --- frappe/tests/test_commands.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index 39d76aaba5..b63a886a2f 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -82,14 +82,25 @@ class BaseTestCommands(unittest.TestCase): kwargs.update(site) else: kwargs = site - command = " ".join(command.split()).format(**kwargs) - print("{0}$ {1}{2}".format(color.silver, command, color.nc)) - command = shlex.split(command) + self.command = " ".join(command.split()).format(**kwargs) + print("{0}$ {1}{2}".format(color.silver, self.command, color.nc)) + command = shlex.split(self.command) self._proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) self.stdout = clean(self._proc.stdout) self.stderr = clean(self._proc.stderr) self.returncode = clean(self._proc.returncode) + def _formatMessage(self, msg, standardMsg): + output = super(BaseTestCommands, self)._formatMessage(msg, standardMsg) + cmd_execution_summary = "\n".join([ + "-" * 70, + "Last Command Execution Summary:", + "Command: {}".format(self.command) if self.command else "", + "Standard Output: {}".format(self.stdout) if self.stdout else "", + "Standard Error: {}".format(self.stderr) if self.stderr else "", + "Return Code: {}".format(self.returncode) if self.returncode else "", + ]).strip() + return "{}\n\n{}".format(output, cmd_execution_summary) class TestCommands(BaseTestCommands): def test_execute(self): From 44a09191b38f13682ef6f8a0ce21058ce3c25756 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 8 Oct 2020 18:56:29 +0530 Subject: [PATCH 012/197] fix: Use public schema for postgres site tx @surajshetty3416 --- frappe/tests/test_commands.py | 9 +++++++-- frappe/utils/backups.py | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index b63a886a2f..e7cb9f5d40 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -71,9 +71,14 @@ def exists_in_backup(doctypes, file): Returns: bool: True if all tables exist """ - with gzip.open(file, 'rb') as f: + predicate = ( + 'CREATE TABLE public."tab{}"' + if frappe.conf.db_type == "postgres" + else "CREATE TABLE `tab{}`" + ) + with gzip.open(file, "rb") as f: content = f.read().decode("utf8") - return all(["CREATE TABLE `tab{}`".format(doctype).lower() in content.lower() for doctype in doctypes]) + return all([predicate.format(doctype).lower() in content.lower() for doctype in doctypes]) class BaseTestCommands(unittest.TestCase): def execute(self, command, kwargs=None): diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index 1ae976807a..ab5783006c 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -353,7 +353,7 @@ class BackupGenerator: if self.db_type == "postgres": if self.backup_includes: args["include"] = " ".join( - ["--table='{0}'".format(table) for table in self.backup_includes] + ["--table='public.\"{0}\"'".format(table) for table in self.backup_includes] ) elif self.backup_excludes: args["exclude"] = " ".join( From 368a031da3b389bb65b859a3165cc9640bf56e0b Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 9 Oct 2020 11:19:16 +0530 Subject: [PATCH 013/197] fix: Update postgres exlcude table data --- frappe/tests/test_commands.py | 2 +- frappe/utils/backups.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index e7cb9f5d40..19fd90e99c 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -72,7 +72,7 @@ def exists_in_backup(doctypes, file): bool: True if all tables exist """ predicate = ( - 'CREATE TABLE public."tab{}"' + 'COPY public."tab{}"' if frappe.conf.db_type == "postgres" else "CREATE TABLE `tab{}`" ) diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index ab5783006c..552debbaa1 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -357,7 +357,7 @@ class BackupGenerator: ) elif self.backup_excludes: args["exclude"] = " ".join( - ["--exclude-table='{0}'".format(table) for table in self.backup_excludes] + ["--exclude-table-data='public.\"{0}\"'".format(table) for table in self.backup_excludes] ) cmd_string = ( From d19973a357bdb942caf523cdfadefc4510bbe3dc Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 9 Oct 2020 11:27:18 +0530 Subject: [PATCH 014/197] fix: Support for multi-site list-apps summary --- frappe/commands/site.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/frappe/commands/site.py b/frappe/commands/site.py index 1f4642658f..d4fcaba3b5 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -226,11 +226,26 @@ def install_app(context, 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() + import click + titled = False + + if len(context.sites) > 1: + titled = True + + for site in context.sites: + frappe.init(site=site) + frappe.connect() + apps = sorted(frappe.get_installed_apps()) + + if titled: + summary = "{}{}".format(click.style(site + ": ", fg="green"), ", ".join(apps)) + else: + summary = "\n".join(apps) + + if apps and summary.strip(): + print(summary) + + frappe.destroy() @click.command('add-system-manager') @click.argument('email') From 5c5703b8f68c2a76e000f4712a5e60fa501fba57 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 9 Oct 2020 11:31:18 +0530 Subject: [PATCH 015/197] feat: Show apps excluding frappe using --only-apps --- frappe/commands/site.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frappe/commands/site.py b/frappe/commands/site.py index d4fcaba3b5..189d6eedd4 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -223,8 +223,9 @@ def install_app(context, apps): @click.command('list-apps') +@click.option('--only-apps', is_flag=True) @pass_context -def list_apps(context): +def list_apps(context, only_apps): "List apps in site" import click titled = False @@ -237,6 +238,9 @@ def list_apps(context): frappe.connect() apps = sorted(frappe.get_installed_apps()) + if only_apps: + apps.remove("frappe") + if titled: summary = "{}{}".format(click.style(site + ": ", fg="green"), ", ".join(apps)) else: From 9219db4c2a4dbb0709cc0f893622262cc4defc2e Mon Sep 17 00:00:00 2001 From: Saurabh Date: Fri, 16 Oct 2020 14:09:35 +0530 Subject: [PATCH 016/197] fix: validate email id before passing to formataddr --- frappe/utils/__init__.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py index d3bf1dd10c..9640bcd394 100644 --- a/frappe/utils/__init__.py +++ b/frappe/utils/__init__.py @@ -66,9 +66,14 @@ def get_email_address(user=None): def get_formatted_email(user, mail=None): """get Email Address of user formatted as: `John Doe `""" fullname = get_fullname(user) + if not mail: - mail = get_email_address(user) - return cstr(make_header(decode_header(formataddr((fullname, mail))))) + mail = get_email_address(user) or validate_email_address(user) + + if not mail: + return '' + else: + return cstr(make_header(decode_header(formataddr((fullname, mail))))) def extract_email_id(email): """fetch only the email part of the Email Address""" From 59d35cb5aeadd161701c1b5651cf0aad21facc7e Mon Sep 17 00:00:00 2001 From: marination Date: Tue, 20 Oct 2020 20:21:07 +0530 Subject: [PATCH 017/197] fix: Conditionally set parent field only on DocType rename --- frappe/model/rename_doc.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frappe/model/rename_doc.py b/frappe/model/rename_doc.py index 7a2129e76e..e04d59ab6a 100644 --- a/frappe/model/rename_doc.py +++ b/frappe/model/rename_doc.py @@ -249,8 +249,11 @@ def update_link_field_values(link_fields, old, new, doctype): # or no longer exists pass else: + parent = field['parent'] + # because the table hasn't been renamed yet! - parent = field['parent'] if field['parent']!=new else old + if field['parent'] == new and doctype == "DocType": + parent = old frappe.db.sql(""" update `tab{table_name}` set `{fieldname}`=%s From 969aa86e68c0726d202a73bc1d488f9f89e72bc2 Mon Sep 17 00:00:00 2001 From: prssanna Date: Tue, 27 Oct 2020 17:46:21 +0530 Subject: [PATCH 018/197] fix: calculate chart data from beginning of period - show period as label --- .../dashboard_chart/dashboard_chart.py | 24 +++++++++++++++---- frappe/utils/data.py | 11 +++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py index 7e2d952928..c9b54561ea 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py @@ -8,8 +8,7 @@ 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 import * from frappe.model.naming import append_number_if_name_exists from frappe.boot import get_allowed_reports from frappe.model.document import Document @@ -156,6 +155,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() @@ -163,7 +163,6 @@ def get_chart_config(chart, filters, timespan, timegrain, from_date, to_date): datefield = chart.based_on aggregate_function = get_aggregate_function(chart.chart_type) value_field = chart.value_based_on or '1' - from_date = from_date.strftime('%Y-%m-%d') to_date = to_date filters.append([doctype, datefield, '>=', from_date, False]) @@ -185,7 +184,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] @@ -282,7 +281,7 @@ def get_result(data, timegrain, from_date, to_date): start_date = getdate(from_date) end_date = getdate(to_date) - result = [[start_date, 0.0]] + result = [] while start_date < end_date: next_date = get_next_expected_date(start_date, timegrain) @@ -304,6 +303,21 @@ def get_next_expected_date(date, timegrain): next_date = get_period_ending(add_to_date(date, days=1), timegrain) return getdate(next_date) +def get_period_beginning(date, timegrain): + as_str = True + if timegrain == 'Daily': + pass + elif timegrain == 'Weekly': + date = get_first_day_of_week(date, as_str=as_str) + elif timegrain == 'Monthly': + date = get_first_day(date, as_str=as_str) + elif timegrain == 'Quarterly': + date = get_quarter_start(date, as_str=as_str) + elif timegrain == 'Yearly': + date = get_year_start(date, as_str=as_str) + + return date + def get_period_ending(date, timegrain): date = getdate(date) if timegrain == 'Daily': diff --git a/frappe/utils/data.py b/frappe/utils/data.py index 41f247da45..f189b6aa53 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -441,6 +441,17 @@ def get_timespan_date_range(timespan): return date_range_map.get(timespan) +def get_period(date, interval='Monthly'): + date = getdate(date) + months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + return { + 'Daily': date.strftime('%d-%m-%y'), + 'Weekly': date.strftime('%d-%m-%y'), + 'Monthly': str(months[date.month - 1]) + ' ' + str(date.year), + 'Quarterly': 'Quarter ' + str(((date.month-1)//3)+1) + ' ' + str(date.year), + 'Yearly': str(date.year) + }[interval] + def global_date_format(date, format="long"): """returns localized date in the form of January 1, 2012""" date = getdate(date) From 7f43169c4a7f195c9b462cb076d3c7f6ef947884 Mon Sep 17 00:00:00 2001 From: prssanna Date: Wed, 28 Oct 2020 00:11:43 +0530 Subject: [PATCH 019/197] refactor: reorganise date functions indashboard_chart.py --- .../dashboard_chart/dashboard_chart.py | 75 ++----------------- frappe/utils/dashboard.py | 15 ---- frappe/utils/data.py | 32 +++++--- frappe/utils/dateutils.py | 62 ++++++++++++++- 4 files changed, 85 insertions(+), 99 deletions(-) diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py index c9b54561ea..587f3c02b0 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py @@ -7,8 +7,11 @@ 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 * +from frappe.utils.dashboard import cache_source +from frappe.utils import nowdate, add_to_date, getdate, formatdate,\ + get_datetime, cint, now_datetime +from frappe.utils.dateutils import\ + get_period, get_period_beginning, get_period_ending, get_from_date_from_timespan from frappe.model.naming import append_number_if_name_exists from frappe.boot import get_allowed_reports from frappe.model.document import Document @@ -303,74 +306,6 @@ def get_next_expected_date(date, timegrain): next_date = get_period_ending(add_to_date(date, days=1), timegrain) return getdate(next_date) -def get_period_beginning(date, timegrain): - as_str = True - if timegrain == 'Daily': - pass - elif timegrain == 'Weekly': - date = get_first_day_of_week(date, as_str=as_str) - elif timegrain == 'Monthly': - date = get_first_day(date, as_str=as_str) - elif timegrain == 'Quarterly': - date = get_quarter_start(date, as_str=as_str) - elif timegrain == 'Yearly': - date = get_year_start(date, as_str=as_str) - - return 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/utils/dashboard.py b/frappe/utils/dashboard.py index 7eaf470767..e386dcd881 100644 --- a/frappe/utils/dashboard.py +++ b/frappe/utils/dashboard.py @@ -61,21 +61,6 @@ def generate_and_cache_results(args, function, cache_key, chart): frappe.db.set_value("Dashboard Chart", args.chart_name, "last_synced_on", frappe.utils.now(), update_modified = False) return results -def get_from_date_from_timespan(to_date, timespan): - days = months = years = 0 - if timespan == "Last Week": - days = -7 - if timespan == "Last Month": - months = -1 - elif timespan == "Last Quarter": - months = -3 - elif timespan == "Last Year": - years = -1 - elif timespan == "All Time": - years = -50 - return add_to_date(to_date, years=years, months=months, days=days, - as_datetime=True) - def get_dashboards_with_link(docname, doctype): dashboards = [] links = [] diff --git a/frappe/utils/data.py b/frappe/utils/data.py index f189b6aa53..34659e1cac 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -221,6 +221,27 @@ def get_last_day(dt): """ return get_first_day(dt, 0, 1) + datetime.timedelta(-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) def get_time(time_str): if isinstance(time_str, datetime.datetime): @@ -441,17 +462,6 @@ def get_timespan_date_range(timespan): return date_range_map.get(timespan) -def get_period(date, interval='Monthly'): - date = getdate(date) - months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] - return { - 'Daily': date.strftime('%d-%m-%y'), - 'Weekly': date.strftime('%d-%m-%y'), - 'Monthly': str(months[date.month - 1]) + ' ' + str(date.year), - 'Quarterly': 'Quarter ' + str(((date.month-1)//3)+1) + ' ' + str(date.year), - 'Yearly': str(date.year) - }[interval] - def global_date_format(date, format="long"): """returns localized date in the form of January 1, 2012""" date = getdate(date) diff --git a/frappe/utils/dateutils.py b/frappe/utils/dateutils.py index 90abdeb6cd..2895eb0568 100644 --- a/frappe/utils/dateutils.py +++ b/frappe/utils/dateutils.py @@ -7,8 +7,8 @@ import frappe.defaults import datetime from frappe.utils import get_datetime from frappe.utils import add_to_date, getdate -from frappe.utils.data import get_last_day_of_week -from frappe.desk.doctype.dashboard_chart.dashboard_chart import get_period_ending +from frappe.utils.data import get_first_day, get_first_day_of_week, get_quarter_start, get_year_start,\ + get_last_day, get_last_day_of_week, get_quarter_ending, get_year_ending from six import string_types # global values -- used for caching @@ -102,4 +102,60 @@ def get_dates_from_timegrain(from_date, to_date, timegrain="Daily"): else: date = get_period_ending(add_to_date(dates[-1], years=years, months=months, days=days), timegrain) dates.append(date) - return dates \ No newline at end of file + return dates + +def get_from_date_from_timespan(to_date, timespan): + days = months = years = 0 + if timespan == "Last Week": + days = -7 + if timespan == "Last Month": + months = -1 + elif timespan == "Last Quarter": + months = -3 + elif timespan == "Last Year": + years = -1 + elif timespan == "All Time": + years = -50 + return add_to_date(to_date, years=years, months=months, days=days, + as_datetime=True) + +def get_period(date, interval='Monthly'): + date = getdate(date) + months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + return { + 'Daily': date.strftime('%d-%m-%y'), + 'Weekly': date.strftime('%d-%m-%y'), + 'Monthly': str(months[date.month - 1]) + ' ' + str(date.year), + 'Quarterly': 'Quarter ' + str(((date.month-1)//3)+1) + ' ' + str(date.year), + 'Yearly': str(date.year) + }[interval] + +def get_period_beginning(date, timegrain): + as_str = True + if timegrain == 'Daily': + pass + elif timegrain == 'Weekly': + date = get_first_day_of_week(date, as_str=as_str) + elif timegrain == 'Monthly': + date = get_first_day(date, as_str=as_str) + elif timegrain == 'Quarterly': + date = get_quarter_start(date, as_str=as_str) + elif timegrain == 'Yearly': + date = get_year_start(date, as_str=as_str) + + return date + +def get_period_ending(date, timegrain): + date = getdate(date) + if timegrain == 'Daily': + pass + elif timegrain == 'Weekly': + date = get_last_day_of_week(date) + elif timegrain == 'Monthly': + date = get_last_day(date) + elif timegrain == 'Quarterly': + date = get_quarter_ending(date) + elif timegrain == 'Yearly': + date = get_year_ending(date) + + return getdate(date) From 7302c85f55fe5cca5f222468235913057d03e03e Mon Sep 17 00:00:00 2001 From: prssanna Date: Wed, 28 Oct 2020 00:37:34 +0530 Subject: [PATCH 020/197] fix: dashboard chart tests --- .../dashboard_chart/test_dashboard_chart.py | 30 ++++--------- frappe/utils/dateutils.py | 45 ++++++++----------- 2 files changed, 27 insertions(+), 48 deletions(-) diff --git a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py index 5e39998e62..13fea8282d 100644 --- a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py @@ -4,9 +4,9 @@ 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 import getdate, formatdate +from frappe.utils.dateutils import get_period_ending, get_period_beginning, 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) + self.assertEqual(result.get('labels')[0], get_period(cur_date)) for idx in range(1, 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) + self.assertEqual(result.get('labels')[0], get_period(cur_date)) for idx in range(1, 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) + self.assertEqual(result.get('labels')[0], get_period(cur_date)) for idx in range(1, 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 diff --git a/frappe/utils/dateutils.py b/frappe/utils/dateutils.py index 2895eb0568..06b434a512 100644 --- a/frappe/utils/dateutils.py +++ b/frappe/utils/dateutils.py @@ -5,8 +5,7 @@ from __future__ import unicode_literals import frappe import frappe.defaults import datetime -from frappe.utils import get_datetime -from frappe.utils import add_to_date, getdate +from frappe.utils import get_datetime, add_to_date, getdate from frappe.utils.data import get_first_day, get_first_day_of_week, get_quarter_start, get_year_start,\ get_last_day, get_last_day_of_week, get_quarter_ending, get_year_ending from six import string_types @@ -130,32 +129,24 @@ def get_period(date, interval='Monthly'): 'Yearly': str(date.year) }[interval] -def get_period_beginning(date, timegrain): - as_str = True - if timegrain == 'Daily': - pass - elif timegrain == 'Weekly': - date = get_first_day_of_week(date, as_str=as_str) - elif timegrain == 'Monthly': - date = get_first_day(date, as_str=as_str) - elif timegrain == 'Quarterly': - date = get_quarter_start(date, as_str=as_str) - elif timegrain == 'Yearly': - date = get_year_start(date, as_str=as_str) - - return date +def get_period_beginning(date, timegrain, as_str=True): + return getdate({ + 'Daily': date, + 'Weekly': get_first_day_of_week(date), + 'Monthly': get_first_day(date), + 'Quarterly': get_quarter_start(date), + 'Yearly': get_year_start(date) + }[timegrain]) def get_period_ending(date, timegrain): date = getdate(date) if timegrain == 'Daily': - pass - elif timegrain == 'Weekly': - date = get_last_day_of_week(date) - elif timegrain == 'Monthly': - date = get_last_day(date) - elif timegrain == 'Quarterly': - date = get_quarter_ending(date) - elif timegrain == 'Yearly': - date = get_year_ending(date) - - return getdate(date) + return date + else: + return getdate({ + 'Daily': date, + 'Weekly': get_last_day_of_week(date), + 'Monthly': get_last_day(date), + 'Quarterly': get_quarter_ending(date), + 'Yearly': get_year_ending(date) + }[timegrain]) From 04b1e40023c3709d4788aa0728082700e0d22185 Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Wed, 28 Oct 2020 10:59:32 +0530 Subject: [PATCH 021/197] chore: remove twilio integration --- frappe/core/doctype/role/role.py | 4 +-- .../doctype/notification/notification.js | 15 +++----- .../doctype/notification/notification.json | 20 +++-------- .../doctype/notification/notification.py | 21 ++--------- .../desk_page/integrations/integrations.json | 4 +-- .../doctype/twilio_number_group/__init__.py | 0 .../twilio_number_group.json | 36 ------------------- .../twilio_number_group.py | 10 ------ .../doctype/twilio_settings/__init__.py | 0 .../twilio_settings/test_twilio_settings.py | 10 ------ .../twilio_settings/twilio_settings.js | 8 ----- 11 files changed, 16 insertions(+), 112 deletions(-) delete mode 100644 frappe/integrations/doctype/twilio_number_group/__init__.py delete mode 100644 frappe/integrations/doctype/twilio_number_group/twilio_number_group.json delete mode 100644 frappe/integrations/doctype/twilio_number_group/twilio_number_group.py delete mode 100644 frappe/integrations/doctype/twilio_settings/__init__.py delete mode 100644 frappe/integrations/doctype/twilio_settings/test_twilio_settings.py delete mode 100644 frappe/integrations/doctype/twilio_settings/twilio_settings.js 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/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..73a84e1d3e 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
{{ doc.name }} Delivered
", "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:in_list(['Email', 'SMS'], doc.channel)", "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-10-28 11:04:54.955567", "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..75281d427e 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) @@ -230,13 +220,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 +285,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/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/twilio_number_group/__init__.py b/frappe/integrations/doctype/twilio_number_group/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 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_number_group/twilio_number_group.py b/frappe/integrations/doctype/twilio_number_group/twilio_number_group.py deleted file mode 100644 index 04cb9ae146..0000000000 --- a/frappe/integrations/doctype/twilio_number_group/twilio_number_group.py +++ /dev/null @@ -1,10 +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 - -class TwilioNumberGroup(Document): - pass 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')}`])); - } -}); From 2dd9ae212755d24afd464d9167310adc90ac7f49 Mon Sep 17 00:00:00 2001 From: prssanna Date: Wed, 28 Oct 2020 11:10:58 +0530 Subject: [PATCH 022/197] fix: remove unused imports --- frappe/desk/doctype/dashboard_chart/dashboard_chart.py | 2 +- frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py index 587f3c02b0..c2e0f78624 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py @@ -8,7 +8,7 @@ from frappe import _ import datetime import json from frappe.utils.dashboard import cache_source -from frappe.utils import nowdate, add_to_date, getdate, formatdate,\ +from frappe.utils import nowdate, add_to_date, getdate,\ get_datetime, cint, now_datetime from frappe.utils.dateutils import\ get_period, get_period_beginning, get_period_ending, get_from_date_from_timespan diff --git a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py index 13fea8282d..d723171337 100644 --- a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals import unittest, frappe from frappe.utils import getdate, formatdate -from frappe.utils.dateutils import get_period_ending, get_period_beginning, get_period +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 99a260e32acda6e680b37c71c6d552f8e1e72278 Mon Sep 17 00:00:00 2001 From: prssanna Date: Wed, 28 Oct 2020 11:56:15 +0530 Subject: [PATCH 023/197] fix: chart tests --- .../dashboard_chart/dashboard_chart.py | 4 ++-- .../dashboard_chart/test_dashboard_chart.py | 19 ++++++++----------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py index c2e0f78624..184fef7634 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py @@ -166,6 +166,7 @@ def get_chart_config(chart, filters, timespan, timegrain, from_date, to_date): datefield = chart.based_on aggregate_function = get_aggregate_function(chart.chart_type) value_field = chart.value_based_on or '1' + from_date = from_date.strftime('%Y-%m-%d') to_date = to_date filters.append([doctype, datefield, '>=', from_date, False]) @@ -283,8 +284,7 @@ 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 = [] + result = [[start_date, 0.0]] if timegrain == 'Daily' else [] while start_date < end_date: next_date = get_next_expected_date(start_date, timegrain) diff --git a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py index d723171337..dcdebe6cd2 100644 --- a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py @@ -4,7 +4,7 @@ from __future__ import unicode_literals import unittest, frappe -from frappe.utils import getdate, formatdate +from frappe.utils import getdate, formatdate, get_last_day from frappe.utils.dateutils import get_period_ending, get_period from frappe.desk.doctype.dashboard_chart.dashboard_chart import get @@ -53,10 +53,9 @@ 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], get_period(cur_date)) - for idx in range(1, 13): - month = formatdate(month.strftime('%Y-%m-%d')) + for idx in range(13): + month = get_last_day(cur_date) self.assertEqual(result.get('labels')[idx], get_period(month)) cur_date += relativedelta(months=1) @@ -83,10 +82,9 @@ 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], get_period(cur_date)) - for idx in range(1, 13): - month = formatdate(month.strftime('%Y-%m-%d')) + for idx in range(13): + month = get_last_day(cur_date) self.assertEqual(result.get('labels')[idx], get_period(month)) cur_date += relativedelta(months=1) @@ -116,10 +114,9 @@ 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], get_period(cur_date)) - for idx in range(1, 13): - month = formatdate(month.strftime('%Y-%m-%d')) + for idx in range(13): + month = get_last_day(cur_date) self.assertEqual(result.get('labels')[idx], get_period(month)) cur_date += relativedelta(months=1) @@ -171,7 +168,7 @@ 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( From 044da8a5757be170cce29a7f986ef8a1af919fac Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Fri, 30 Oct 2020 14:04:03 +0530 Subject: [PATCH 024/197] fix: create auto_repeat field if docfield/custom field does not exist --- frappe/core/doctype/doctype/doctype.py | 3 ++- frappe/custom/doctype/customize_form/customize_form.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 8a9c130fbe..1d15de9ab5 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -570,7 +570,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) diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py index 61ecdd88b9..f3e67bba7b 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -76,8 +76,8 @@ class CustomizeForm(Document): def create_auto_repeat_custom_field_if_requried(self, meta): if self.allow_auto_repeat: - if not frappe.db.exists('Custom Field', {'fieldname': 'auto_repeat', - 'dt': self.doc_type}): + if not frappe.db.exists('Custom Field', {'fieldname': 'auto_repeat', 'dt': self.doc_type}) 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', From ea0af8d2e20d23536a903c8479829a5a48ae172e Mon Sep 17 00:00:00 2001 From: Mangesh-Khairnar Date: Mon, 2 Nov 2020 13:25:22 +0530 Subject: [PATCH 025/197] chore: remove twilio from requirements --- .../twilio_settings/twilio_settings.json | 67 ------------------- .../twilio_settings/twilio_settings.py | 63 ----------------- requirements.txt | 2 - 3 files changed, 132 deletions(-) delete mode 100644 frappe/integrations/doctype/twilio_settings/twilio_settings.json delete mode 100644 frappe/integrations/doctype/twilio_settings/twilio_settings.py 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/requirements.txt b/requirements.txt index 30f0220af7..de9e675a67 100644 --- a/requirements.txt +++ b/requirements.txt @@ -72,7 +72,5 @@ zxcvbn-python==4.4.24 pycryptodome==3.9.8 paytmchecksum==1.7.0 wrapt==1.10.11 -twilio==6.44.2 razorpay==1.2.0 - rsa>=4.1 # not directly required, pinned by Snyk to avoid a vulnerability \ No newline at end of file From 8fde766b8f3a0f4a8e87d7ea2a17d2d2a10a33f7 Mon Sep 17 00:00:00 2001 From: prssanna Date: Mon, 2 Nov 2020 18:47:14 +0530 Subject: [PATCH 026/197] fix: use get_dates_from_timegrain function --- .../dashboard_chart/dashboard_chart.py | 19 +++---------------- .../dashboard_chart/test_dashboard_chart.py | 3 +++ 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py index 184fef7634..c814f324f5 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py @@ -11,7 +11,7 @@ from frappe.utils.dashboard import cache_source from frappe.utils import nowdate, add_to_date, getdate,\ get_datetime, cint, now_datetime from frappe.utils.dateutils import\ - get_period, get_period_beginning, get_period_ending, get_from_date_from_timespan + get_period, get_period_beginning, get_period_ending, 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.model.document import Document @@ -282,15 +282,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]] if timegrain == 'Daily' else [] - - 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): @@ -300,12 +293,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) - @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 dcdebe6cd2..b9503ee167 100644 --- a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py @@ -56,6 +56,7 @@ class TestDashboardChart(unittest.TestCase): for idx in range(13): month = get_last_day(cur_date) + month = formatdate(month.strftime('%Y-%m-%d')) self.assertEqual(result.get('labels')[idx], get_period(month)) cur_date += relativedelta(months=1) @@ -85,6 +86,7 @@ class TestDashboardChart(unittest.TestCase): for idx in range(13): month = get_last_day(cur_date) + month = formatdate(month.strftime('%Y-%m-%d')) self.assertEqual(result.get('labels')[idx], get_period(month)) cur_date += relativedelta(months=1) @@ -117,6 +119,7 @@ class TestDashboardChart(unittest.TestCase): for idx in range(13): month = get_last_day(cur_date) + month = formatdate(month.strftime('%Y-%m-%d')) self.assertEqual(result.get('labels')[idx], get_period(month)) cur_date += relativedelta(months=1) From ba33aa1b78424cd45cc9b7bf26da599d5c895361 Mon Sep 17 00:00:00 2001 From: prssanna Date: Mon, 2 Nov 2020 19:06:42 +0530 Subject: [PATCH 027/197] fix: remove unused imports --- frappe/desk/doctype/dashboard_chart/dashboard_chart.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py index c814f324f5..9cfc0a04c8 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py @@ -8,10 +8,10 @@ from frappe import _ import datetime import json from frappe.utils.dashboard import cache_source -from frappe.utils import nowdate, add_to_date, getdate,\ +from frappe.utils import nowdate, getdate, get_datetime,\ get_datetime, cint, now_datetime from frappe.utils.dateutils import\ - get_period, get_period_beginning, get_period_ending, get_from_date_from_timespan, get_dates_from_timegrain + 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.model.document import Document From 875ba903b987bbdaffa4e838ad16f89174e3b1a4 Mon Sep 17 00:00:00 2001 From: prssanna Date: Mon, 2 Nov 2020 19:18:56 +0530 Subject: [PATCH 028/197] fix: chart test date format --- frappe/desk/doctype/dashboard_chart/dashboard_chart.py | 3 +-- .../desk/doctype/dashboard_chart/test_dashboard_chart.py | 8 +++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py index 9cfc0a04c8..3f8d7c3c79 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py @@ -8,8 +8,7 @@ from frappe import _ import datetime import json from frappe.utils.dashboard import cache_source -from frappe.utils import nowdate, getdate, get_datetime,\ - get_datetime, cint, now_datetime +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 diff --git a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py index b9503ee167..3c37ad4a09 100644 --- a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py @@ -176,8 +176,7 @@ class TestDashboardChart(unittest.TestCase): 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() @@ -206,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() From 841f2f4a36d984d9745146f656f2465abe183e1e Mon Sep 17 00:00:00 2001 From: marination Date: Tue, 3 Nov 2020 21:51:37 +0530 Subject: [PATCH 029/197] chore: Rename Doctype Test and more explicit comment - Better decription of why the fix is done, what case it handles - Test for Renaming Doctype and Record having same name as DocType --- frappe/model/rename_doc.py | 10 ++++++++-- frappe/tests/test_document.py | 37 ++++++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/frappe/model/rename_doc.py b/frappe/model/rename_doc.py index e04d59ab6a..789a7f51cf 100644 --- a/frappe/model/rename_doc.py +++ b/frappe/model/rename_doc.py @@ -251,8 +251,14 @@ def update_link_field_values(link_fields, old, new, doctype): else: parent = field['parent'] - # because the table hasn't been renamed yet! - if field['parent'] == new and doctype == "DocType": + # 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(""" diff --git a/frappe/tests/test_document.py b/frappe/tests/test_document.py index c96076cfba..4e9984e89a 100644 --- a/frappe/tests/test_document.py +++ b/frappe/tests/test_document.py @@ -288,4 +288,39 @@ class TestDocument(unittest.TestCase): self.assertEqual(merged_todo_doc.priority, second_todo_doc.priority) for docname in available_documents: - frappe.delete_doc(doctype, docname) \ No newline at end of file + frappe.delete_doc(doctype, docname) + + def test_rename_doctype(self): + from frappe.core.doctype.doctype.test_doctype import new_doctype + + fields =[{ + "label": "Linked To", + "fieldname": "linked_to_doctype", + "fieldtype": "Link", + "options": "DocType", + "unique": 0 + }] + if not frappe.db.exists("DocType", "Rename This"): + new_doctype("Rename This", unique=0, fields=fields).insert() + + to_rename_record = frappe.get_doc({ + "doctype": "Rename This", + "linked_to_doctype": "Rename This" + }) + to_rename_record.insert() + + # Rename doctype + self.assertEqual("Renamed Doc", frappe.rename_doc("DocType", "Rename This", "Renamed Doc", force=True)) + + # Test if Doctype value has changed in Link field + renamed_doctype_record = frappe.get_doc("Renamed Doc", to_rename_record.name) + self.assertEqual(renamed_doctype_record.linked_to_doctype, "Renamed Doc") + + # Test if there are conflicts between a record and a DocType + # having the same name + old_name = to_rename_record.name + new_name = "ToDo" + self.assertEqual(new_name, frappe.rename_doc("Renamed Doc", old_name, new_name, force=True)) + + frappe.delete_doc_if_exists("Renamed Doc", "ToDo") + frappe.delete_doc_if_exists("DocType", "Renamed Doc") \ No newline at end of file From a185d8866e4a6183c874406a05628d19c72ea2d4 Mon Sep 17 00:00:00 2001 From: Mohammad Hasnain Date: Wed, 4 Nov 2020 15:18:30 +0530 Subject: [PATCH 030/197] refactor: add namespaces to build_summary_item, generate_route, shorten_number, get_number_system --- frappe/public/js/frappe/utils/utils.js | 13 +++++-- .../js/frappe/views/reports/query_report.js | 4 +-- .../public/js/frappe/widgets/chart_widget.js | 5 +-- .../public/js/frappe/widgets/links_widget.js | 5 +-- .../js/frappe/widgets/number_card_widget.js | 7 ++-- .../js/frappe/widgets/onboarding_widget.js | 5 +-- .../js/frappe/widgets/shortcut_widget.js | 5 +-- frappe/public/js/frappe/widgets/utils.js | 34 ++++++++++++------- 8 files changed, 50 insertions(+), 28 deletions(-) diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js index b8eeefb046..7695ace85a 100644 --- a/frappe/public/js/frappe/utils/utils.js +++ b/frappe/public/js/frappe/utils/utils.js @@ -2,7 +2,9 @@ // MIT License. See license.txt import deep_equal from "fast-deep-equal"; -frappe.provide('frappe.utils'); +import { generate_route, shorten_number, get_number_system } from "../widgets/utils"; + +frappe.provide("frappe.utils"); Object.assign(frappe.utils, { get_random: function(len) { @@ -892,7 +894,14 @@ Object.assign(frappe.utils, { hide_seconds: docfield.hide_seconds }; return duration_options; - } + }, + + generate_route: generate_route, + + shorten_number: shorten_number, + + get_number_system: get_number_system, + }); // Array de duplicate diff --git a/frappe/public/js/frappe/views/reports/query_report.js b/frappe/public/js/frappe/views/reports/query_report.js index 53cee5b348..720261e306 100644 --- a/frappe/public/js/frappe/views/reports/query_report.js +++ b/frappe/public/js/frappe/views/reports/query_report.js @@ -1,8 +1,8 @@ // Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors // MIT License. See license.txt import DataTable from 'frappe-datatable'; -import { build_summary_item } from "../../widgets/utils"; +frappe.provide('frappe.widget.utils'); frappe.provide('frappe.views'); frappe.provide('frappe.query_reports'); @@ -631,7 +631,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { render_summary(data) { data.forEach((summary) => { - build_summary_item(summary).appendTo(this.$summary); + frappe.widget.utils.build_summary_item(summary).appendTo(this.$summary); }) this.$summary.show(); diff --git a/frappe/public/js/frappe/widgets/chart_widget.js b/frappe/public/js/frappe/widgets/chart_widget.js index c89ee96520..eee240444f 100644 --- a/frappe/public/js/frappe/widgets/chart_widget.js +++ b/frappe/public/js/frappe/widgets/chart_widget.js @@ -1,5 +1,6 @@ import Widget from "./base_widget.js"; -import { build_summary_item } from "./utils"; + +frappe.provide('frappe.widget.utils'); frappe.provide("frappe.dashboards"); frappe.provide("frappe.dashboards.chart_sources"); @@ -80,7 +81,7 @@ export default class ChartWidget extends Widget { } this.summary.forEach(summary => { - build_summary_item(summary).appendTo(this.$summary); + frappe.widget.utils.build_summary_item(summary).appendTo(this.$summary); }); this.summary.length && this.$summary.show(); } diff --git a/frappe/public/js/frappe/widgets/links_widget.js b/frappe/public/js/frappe/widgets/links_widget.js index f7bca23c47..39a7eb17b3 100644 --- a/frappe/public/js/frappe/widgets/links_widget.js +++ b/frappe/public/js/frappe/widgets/links_widget.js @@ -1,5 +1,6 @@ import Widget from "./base_widget.js"; -import { generate_route } from "./utils"; + +frappe.provide("frappe.utils"); export default class LinksWidget extends Widget { constructor(opts) { @@ -55,7 +56,7 @@ export default class LinksWidget extends Widget { return ` ${item.label ? item.label : item.name}`; - return ` + return ` ${item.label ? item.label : item.name}`; }; diff --git a/frappe/public/js/frappe/widgets/number_card_widget.js b/frappe/public/js/frappe/widgets/number_card_widget.js index 6b38412ebd..9667fe0721 100644 --- a/frappe/public/js/frappe/widgets/number_card_widget.js +++ b/frappe/public/js/frappe/widgets/number_card_widget.js @@ -1,5 +1,6 @@ import Widget from "./base_widget.js"; -import { generate_route, shorten_number } from "./utils"; + +frappe.provide("frappe.utils"); export default class NumberCardWidget extends Widget { constructor(opts) { @@ -74,7 +75,7 @@ export default class NumberCardWidget extends Widget { set_route() { const is_document_type = this.card_doc.type !== 'Report'; const name = is_document_type ? this.card_doc.document_type : this.card_doc.report_name; - const route = generate_route({ + const route = frappe.utils.generate_route({ name: name, type: is_document_type ? 'doctype' : 'report', is_query_report: !is_document_type, @@ -203,7 +204,7 @@ export default class NumberCardWidget extends Widget { get_formatted_number(df) { const default_country = frappe.sys_defaults.country; - const shortened_number = shorten_number(this.number, default_country); + const shortened_number = frappe.utils.shorten_number(this.number, default_country); let number_parts = shortened_number.split(' '); const symbol = number_parts[1] || ''; diff --git a/frappe/public/js/frappe/widgets/onboarding_widget.js b/frappe/public/js/frappe/widgets/onboarding_widget.js index 8c1d2cbb5b..e23aaf509f 100644 --- a/frappe/public/js/frappe/widgets/onboarding_widget.js +++ b/frappe/public/js/frappe/widgets/onboarding_widget.js @@ -1,5 +1,6 @@ import Widget from "./base_widget.js"; -import { generate_route } from "./utils"; + +frappe.provide("frappe.utils"); export default class OnboardingWidget extends Widget { constructor(opts) { @@ -92,7 +93,7 @@ export default class OnboardingWidget extends Widget { } open_report(step) { - let route = generate_route({ + let route = frappe.utils.generate_route({ name: step.reference_report, type: "report", is_query_report: ["Query Report", "Script Report"].includes( diff --git a/frappe/public/js/frappe/widgets/shortcut_widget.js b/frappe/public/js/frappe/widgets/shortcut_widget.js index 7174ba0d2a..734e29e2bc 100644 --- a/frappe/public/js/frappe/widgets/shortcut_widget.js +++ b/frappe/public/js/frappe/widgets/shortcut_widget.js @@ -1,5 +1,6 @@ import Widget from "./base_widget.js"; -import { generate_route } from "./utils"; + +frappe.provide("frappe.utils"); export default class ShortcutWidget extends Widget { constructor(opts) { @@ -25,7 +26,7 @@ export default class ShortcutWidget extends Widget { this.widget.click(() => { if (this.in_customize_mode) return; - let route = generate_route({ + let route = frappe.utils.generate_route({ route: this.route, name: this.link_to, type: this.type, diff --git a/frappe/public/js/frappe/widgets/utils.js b/frappe/public/js/frappe/widgets/utils.js index 9a255d0776..120e47ea59 100644 --- a/frappe/public/js/frappe/widgets/utils.js +++ b/frappe/public/js/frappe/widgets/utils.js @@ -1,3 +1,5 @@ +frappe.provide('frappe.widget.utils'); + function generate_route(item) { const type = item.type.toLowerCase() if (type === "doctype") { @@ -126,22 +128,28 @@ function generate_grid(data) { return grid_template_area } -const build_summary_item = (summary) => { - let df = { fieldtype: summary.datatype }; - let doc = null; +frappe.widget.utils = { + build_summary_item: function(summary) { + let df = { fieldtype: summary.datatype }; + let doc = null; - if (summary.datatype == "Currency") { - df.options = "currency"; - doc = { currency: summary.currency }; - } + if (summary.datatype == "Currency") { + df.options = "currency"; + doc = { currency: summary.currency }; + } - let value = frappe.format(summary.value, df, null, doc); - let indicator = summary.indicator ? `indicator ${summary.indicator.toLowerCase()}` : ''; + let value = frappe.format(summary.value, df, null, doc); + let indicator = summary.indicator + ? `indicator ${summary.indicator.toLowerCase()}` + : ""; - return $(`
- ${summary.label} -

${ value}

+ return $(`
+ ${ + summary.label + } +

${value}

`); + }, }; function shorten_number(number, country) { @@ -190,4 +198,4 @@ function get_number_system(country) { return number_system_map[country]; } -export { generate_route, generate_grid, build_summary_item, shorten_number }; +export { generate_route, generate_grid, shorten_number, get_number_system }; From 766d5fedf9269adf47117c0581ed93c1f956f285 Mon Sep 17 00:00:00 2001 From: Mohammad Hasnain Date: Wed, 4 Nov 2020 15:36:53 +0530 Subject: [PATCH 031/197] refactor: fix indentation --- frappe/public/js/frappe/widgets/utils.js | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/frappe/public/js/frappe/widgets/utils.js b/frappe/public/js/frappe/widgets/utils.js index 120e47ea59..787a8b1a21 100644 --- a/frappe/public/js/frappe/widgets/utils.js +++ b/frappe/public/js/frappe/widgets/utils.js @@ -143,12 +143,11 @@ frappe.widget.utils = { ? `indicator ${summary.indicator.toLowerCase()}` : ""; - return $(`
- ${ - summary.label - } -

${value}

-
`); + return $( + `
${ + summary.label + }

${value}

` + ); }, }; From 0245b0ff4fd03162f68ab040b4a80bd528e03a93 Mon Sep 17 00:00:00 2001 From: prssanna Date: Wed, 4 Nov 2020 16:22:16 +0530 Subject: [PATCH 032/197] fix: delete prepared reports in batches --- .../prepared_report/prepared_report.py | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/frappe/core/doctype/prepared_report/prepared_report.py b/frappe/core/doctype/prepared_report/prepared_report.py index 2c02d99dad..2c4d933440 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, 50) + 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 From 3523323c649fbfa753de51a4d76c57587ad63287 Mon Sep 17 00:00:00 2001 From: Anupam Date: Wed, 4 Nov 2020 19:09:41 +0530 Subject: [PATCH 033/197] fix: frappe.utils.formatdate not working in the jinja template --- frappe/utils/safe_exec.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frappe/utils/safe_exec.py b/frappe/utils/safe_exec.py index 12382e85cd..fee6b404ac 100644 --- a/frappe/utils/safe_exec.py +++ b/frappe/utils/safe_exec.py @@ -277,5 +277,6 @@ VALID_UTILS = ( "to_markdown", "md_to_html", "is_subset", -"generate_hash" +"generate_hash", +"formatdate" ) \ No newline at end of file From 94d17590414c94027f44166662f64947dec28917 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 5 Nov 2020 13:46:12 +0530 Subject: [PATCH 034/197] feat: Validate sql file before restoring site --- frappe/commands/site.py | 10 ++++++---- frappe/exceptions.py | 1 + frappe/installer.py | 27 +++++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/frappe/commands/site.py b/frappe/commands/site.py index 38e70534a5..6a2e665efd 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -103,11 +103,11 @@ 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_gzip, extract_files, is_downgrade, validate_database_sql force = context.force or force # Extract the gzip file if user has passed *.sql.gz file instead of *.sql file @@ -127,6 +127,7 @@ def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_pas else: decompressed_file_name = sql_file_path + validate_database_sql(decompressed_file_name, _raise=force) site = get_site(context) frappe.init(site=site) @@ -310,15 +311,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: diff --git a/frappe/exceptions.py b/frappe/exceptions.py index 88428b875c..23e5f0d1b6 100644 --- a/frappe/exceptions.py +++ b/frappe/exceptions.py @@ -109,3 +109,4 @@ class DocumentAlreadyRestored(Exception): pass class InvalidAuthorizationHeader(CSRFTokenError): pass class InvalidAuthorizationPrefix(CSRFTokenError): pass class InvalidAuthorizationToken(CSRFTokenError): pass +class InvalidDatabaseFile(ValidationError): pass \ No newline at end of file diff --git a/frappe/installer.py b/frappe/installer.py index 27fca9088a..6baa7bc589 100755 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -406,3 +406,30 @@ 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 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. + """ + _raise = False + error_message = "" + + if not os.path.getsize(path): + error_message = f"{path} is an empty file!" + _raise = True + + if not _raise: + with open(path, "r") as f: + for line in f: + if 'tabDefaultValue' in line: + error_message = "Table `tabDefaultValue` not found in file." + _raise = True + + if error_message and _raise: + import click + click.secho(error_message, fg="red") + raise frappe.InvalidDatabaseFile From 22d3824fee3ac7e4253edc677ba4c98834e88d37 Mon Sep 17 00:00:00 2001 From: Steffen Brennscheidt Date: Thu, 5 Nov 2020 11:32:06 +0000 Subject: [PATCH 035/197] fix: No redis dependency during tests and install Adding a user during after_install hook caused error during install of an app --- frappe/core/doctype/user/user.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 2c5865fb69..0cec7a511c 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -98,15 +98,16 @@ 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) def has_website_permission(self, ptype, user, verbose=False): """Returns true if current user is the session user""" From 476f21eb4dfdcb93a17582b76fd052ee6c3c3d3a Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 5 Nov 2020 18:26:36 +0530 Subject: [PATCH 036/197] fix: load doc from db in get_transitions --- frappe/model/workflow.py | 2 ++ 1 file changed, 2 insertions(+) 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() From 9e0d2abc6a9c333cae3106acf61ddae16caed5db Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 6 Nov 2020 14:04:52 +0530 Subject: [PATCH 037/197] fix: permission not applied on fetching goal chart --- frappe/utils/goal.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frappe/utils/goal.py b/frappe/utils/goal.py index e7cffc3ddf..4c63eb9fc4 100644 --- a/frappe/utils/goal.py +++ b/frappe/utils/goal.py @@ -57,6 +57,10 @@ def get_monthly_goal_graph_data(title, doctype, docname, goal_value_field, goal_ from frappe.utils.formatters import format_value import json + # should have atleast read perm + if not frappe.has_permission(goal_doctype): + return None + meta = frappe.get_meta(doctype) doc = frappe.get_doc(doctype, docname) From 9446da31d6d7dc5f7fcae78d7e463086aab0829c Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 6 Nov 2020 14:05:27 +0530 Subject: [PATCH 038/197] fix: dashboard show even if no data is present --- frappe/public/js/frappe/form/layout.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/form/layout.js b/frappe/public/js/frappe/form/layout.js index 2195568317..6ea21e6e63 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 }); }, From 5296403e0474cf80ab21d802847a1ce6900792ad Mon Sep 17 00:00:00 2001 From: Mohammad Hasnain Date: Fri, 6 Nov 2020 17:27:51 +0530 Subject: [PATCH 039/197] refactor: move standard utils to standard utils --- frappe/public/js/frappe/utils/utils.js | 124 +++++++++++++++++++++- frappe/public/js/frappe/widgets/utils.js | 126 +---------------------- 2 files changed, 122 insertions(+), 128 deletions(-) diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js index 7695ace85a..7d00a946e5 100644 --- a/frappe/public/js/frappe/utils/utils.js +++ b/frappe/public/js/frappe/utils/utils.js @@ -896,12 +896,130 @@ Object.assign(frappe.utils, { return duration_options; }, - generate_route: generate_route, + generate_route: function(item) { + const type = item.type.toLowerCase() + if (type === "doctype") { + item.doctype = item.name; + } + let route = ""; + if (!item.route) { + if (item.link) { + route = strip(item.link, "#"); + } else if (type === "doctype") { + if (frappe.model.is_single(item.doctype)) { + route = "Form/" + item.doctype; + } else { + if (!item.doc_view) { + if (frappe.model.is_tree(item.doctype)) { + item.doc_view = "Tree"; + } else { + item.doc_view = "List"; + } + } + switch (item.doc_view) { + case "List": + if (item.filters) { + frappe.route_options = item.filters; + } + route = "List/" + item.doctype; + break; + case "Tree": + route = "Tree/" + item.doctype; + break; + case "Report Builder": + route = "List/" + item.doctype + "/Report"; + break; + case "Dashboard": + route = "List/" + item.doctype + "/Dashboard"; + break; + case "New": + route = "Form/" + item.doctype + "/New " + item.doctype; + break; + case "Calendar": + route = "List/" + item.doctype + "/Calendar/Default"; + break; + default: + frappe.throw({ message: __("Not a valid DocType view:") + item.doc_view, title: __("Unknown View") }); + route = ""; + } + } + } else if (type === "report" && item.is_query_report) { + route = "query-report/" + item.name; + } else if (type === "report") { + route = "List/" + item.doctype + "/Report/" + item.name; + } else if (type === "page") { + route = item.name; + } else if (type === "dashboard") { + route = "dashboard/" + item.name; + } - shorten_number: shorten_number, + route = "#" + route; + } else { + route = item.route; + } - get_number_system: get_number_system, + if (item.route_options) { + route += + "?" + + $.map(item.route_options, function (value, key) { + return ( + encodeURIComponent(key) + "=" + encodeURIComponent(value) + ); + }).join("&"); + } + // if(type==="page" || type==="help" || type==="report" || + // (item.doctype && frappe.model.can_read(item.doctype))) { + // item.shown = true; + // } + return route; + }, + + shorten_number: function (number, country) { + country = (country == 'India') ? country : ''; + const number_system = this.get_number_system(country); + let x = Math.abs(Math.round(number)); + for (const map of number_system) { + const condition = map.condition ? map.condition(x) : x >= map.divisor; + if (condition) { + return (number/map.divisor).toFixed(2) + ' ' + map.symbol; + } + } + return number.toFixed(); + }, + + get_number_system: function (country) { + let number_system_map = { + 'India': + [{ + divisor: 1.0e+7, + symbol: 'Cr' + }, + { + divisor: 1.0e+5, + symbol: 'Lakh' + }], + '': + [{ + divisor: 1.0e+12, + symbol: 'T' + }, + { + divisor: 1.0e+9, + symbol: 'B' + }, + { + divisor: 1.0e+6, + symbol: 'M' + }, + { + divisor: 1.0e+3, + symbol: 'K', + condition: (num) => num.toFixed().length > 5 + }] + }; + return number_system_map[country]; + }, }); // Array de duplicate diff --git a/frappe/public/js/frappe/widgets/utils.js b/frappe/public/js/frappe/widgets/utils.js index 787a8b1a21..d85a6fc53f 100644 --- a/frappe/public/js/frappe/widgets/utils.js +++ b/frappe/public/js/frappe/widgets/utils.js @@ -1,84 +1,5 @@ frappe.provide('frappe.widget.utils'); -function generate_route(item) { - const type = item.type.toLowerCase() - if (type === "doctype") { - item.doctype = item.name; - } - let route = ""; - if (!item.route) { - if (item.link) { - route = strip(item.link, "#"); - } else if (type === "doctype") { - if (frappe.model.is_single(item.doctype)) { - route = "Form/" + item.doctype; - } else { - if (!item.doc_view) { - if (frappe.model.is_tree(item.doctype)) { - item.doc_view = "Tree"; - } else { - item.doc_view = "List"; - } - } - switch (item.doc_view) { - case "List": - if (item.filters) { - frappe.route_options = item.filters; - } - route = "List/" + item.doctype; - break; - case "Tree": - route = "Tree/" + item.doctype; - break; - case "Report Builder": - route = "List/" + item.doctype + "/Report"; - break; - case "Dashboard": - route = "List/" + item.doctype + "/Dashboard"; - break; - case "New": - route = "Form/" + item.doctype + "/New " + item.doctype; - break; - case "Calendar": - route = "List/" + item.doctype + "/Calendar/Default"; - break; - default: - frappe.throw({ message: __("Not a valid DocType view:") + item.doc_view, title: __("Unknown View") }); - route = ""; - } - } - } else if (type === "report" && item.is_query_report) { - route = "query-report/" + item.name; - } else if (type === "report") { - route = "List/" + item.doctype + "/Report/" + item.name; - } else if (type === "page") { - route = item.name; - } else if (type === "dashboard") { - route = "dashboard/" + item.name; - } - - route = "#" + route; - } else { - route = item.route; - } - - if (item.route_options) { - route += - "?" + - $.map(item.route_options, function (value, key) { - return ( - encodeURIComponent(key) + "=" + encodeURIComponent(value) - ); - }).join("&"); - } - - // if(type==="page" || type==="help" || type==="report" || - // (item.doctype && frappe.model.can_read(item.doctype))) { - // item.shown = true; - // } - return route; -} - function generate_grid(data) { function add(a, b) { return a + b; @@ -151,50 +72,5 @@ frappe.widget.utils = { }, }; -function shorten_number(number, country) { - country = (country == 'India') ? country : ''; - const number_system = get_number_system(country); - let x = Math.abs(Math.round(number)); - for (const map of number_system) { - const condition = map.condition ? map.condition(x) : x >= map.divisor; - if (condition) { - return (number/map.divisor).toFixed(2) + ' ' + map.symbol; - } - } - return number.toFixed(); -} -function get_number_system(country) { - let number_system_map = { - 'India': - [{ - divisor: 1.0e+7, - symbol: 'Cr' - }, - { - divisor: 1.0e+5, - symbol: 'Lakh' - }], - '': - [{ - divisor: 1.0e+12, - symbol: 'T' - }, - { - divisor: 1.0e+9, - symbol: 'B' - }, - { - divisor: 1.0e+6, - symbol: 'M' - }, - { - divisor: 1.0e+3, - symbol: 'K', - condition: (num) => num.toFixed().length > 5 - }] - }; - return number_system_map[country]; -} - -export { generate_route, generate_grid, shorten_number, get_number_system }; +export { generate_grid }; From aaa27e5587a4a76aaca65604eaf6bd4ffe9c0907 Mon Sep 17 00:00:00 2001 From: Mohammad Hasnain Date: Fri, 6 Nov 2020 17:41:44 +0530 Subject: [PATCH 040/197] refactor: solve sider issues --- frappe/public/js/frappe/utils/utils.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js index 5d20bd3aec..4bf9c5bbd8 100644 --- a/frappe/public/js/frappe/utils/utils.js +++ b/frappe/public/js/frappe/utils/utils.js @@ -2,7 +2,6 @@ // MIT License. See license.txt import deep_equal from "fast-deep-equal"; -import { generate_route, shorten_number, get_number_system } from "../widgets/utils"; frappe.provide("frappe.utils"); @@ -902,7 +901,7 @@ Object.assign(frappe.utils, { }, generate_route: function(item) { - const type = item.type.toLowerCase() + const type = item.type.toLowerCase(); if (type === "doctype") { item.doctype = item.name; } From 19a14d09e0dc872594fc5ae0f0be9505ee6e76d3 Mon Sep 17 00:00:00 2001 From: Glen Whitney Date: Sun, 8 Nov 2020 17:58:10 +0000 Subject: [PATCH 041/197] fix(quick entry): make sure init_callback is always called Prior to this PR, as noted in issue #7638, it is not possible with frappe.new_doc to initialize certain fields of the new document, such as the description of a Task or the posting_date of a Journal Entry (in ERPNext). The reason this occurs is that currently the route_options which can be set in the second argument to frappe.new_doc() are only allowed to set certain field types of a document (namely, Link, Select, Data, and Dynamic Link). Although it turns out that it would not work to allow any field type to be set in the route options (in particular, attempting to allow one to set Table field types in this way is non-functional), it would be reasonable simply to try setting other fields that cannot be set in the route_options via the callback allowed as the third argument of frappe.new_doc. And indeed, this approach works for those DocTypes that have a Quick Entry Form. For those DocTypes that do not, however, the callback is never called. This PR modifies frappe.ui.form.make_quick_entry() -- which frappe.new_doc calls to do most of its work -- so that the callback is called regardless of whether the DocType has a Quick Entry Form or not. The only slight awkwardness in this is that if there is a Quick Entry, the callback is passed the dialog object of that Quick Entry, whereas if there is no Quick Entry, the callback is only passed the doc object that is about to be edited in the standard Form interface for a new document. Nevertheless, in any case, it is now possible to write a callback which will initialize any field in the new document being created. Resolves #7638. --- frappe/public/js/frappe/form/quick_entry.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/form/quick_entry.js b/frappe/public/js/frappe/form/quick_entry.js index 2da7b8f236..0a489e26d6 100644 --- a/frappe/public/js/frappe/form/quick_entry.js +++ b/frappe/public/js/frappe/form/quick_entry.js @@ -35,7 +35,11 @@ frappe.ui.form.QuickEntryForm = Class.extend({ if (this.is_quick_entry() || this.force) { this.render_dialog(); resolve(this); - } else { + } else { // No quick entry, use full Form + // but still give callback a shot at the doc + if (this.init_callback) { + this.init_callback(this.doc); + } frappe.quick_entry = null; frappe.set_route('Form', this.doctype, this.doc.name) .then(() => resolve(this)); From 9cedd7616d10eb41b45b3d53df3fc948bf19980b Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 9 Nov 2020 12:48:54 +0530 Subject: [PATCH 042/197] refactor: Show versions from Installed Applications to show "real" versions synced with the site database --- frappe/commands/site.py | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/frappe/commands/site.py b/frappe/commands/site.py index 142cb9f90f..033165e760 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -223,30 +223,31 @@ def install_app(context, apps): @click.command('list-apps') -@click.option('--only-apps', is_flag=True) @pass_context -def list_apps(context, only_apps): +def list_apps(context): "List apps in site" - import click - titled = False - - if len(context.sites) > 1: - titled = True for site in context.sites: frappe.init(site=site) frappe.connect() - apps = sorted(frappe.get_installed_apps()) + site_title = click.style(f"{site}", fg="green") if len(context.sites) > 1 else "" + apps = frappe.get_single("Installed Applications").installed_applications - if only_apps: - apps.remove("frappe") + if apps: + name_len, ver_len, branch_len = [ + max([len(x.get(y)) for x in apps]) for y in ["app_name", "app_version", "git_branch"] + ] + template = "{{0:{0}}} {{1:{1}}} {{2}}".format(name_len, ver_len, branch_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"\n{site_title}\n{applications_summary}".strip() - if titled: - summary = "{}{}".format(click.style(site + ": ", fg="green"), ", ".join(apps)) else: - summary = "\n".join(apps) + applications_summary = "\n".join(frappe.get_installed_apps()) + summary = f"\n{site_title}\n{applications_summary}".strip() - if apps and summary.strip(): + if applications_summary and summary: print(summary) frappe.destroy() From 6c28f0cffef3ab73631f4fda949e4c28d8a6aa35 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 9 Nov 2020 13:20:38 +0530 Subject: [PATCH 043/197] style: Sider + Black --- frappe/commands/site.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/frappe/commands/site.py b/frappe/commands/site.py index 033165e760..2c52bddf8f 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -222,7 +222,7 @@ 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" @@ -230,16 +230,22 @@ def list_apps(context): 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 "" + 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, branch_len = [ - max([len(x.get(y)) for x in apps]) for y in ["app_name", "app_version", "git_branch"] + 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, branch_len) + 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] + 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"\n{site_title}\n{applications_summary}".strip() @@ -252,6 +258,7 @@ def list_apps(context): frappe.destroy() + @click.command('add-system-manager') @click.argument('email') @click.option('--first-name') From 61b0ffc14dd1012f9b5be1f96a7031c774027684 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 9 Nov 2020 13:48:06 +0530 Subject: [PATCH 044/197] fix: Add new-lines between sites' summaries --- frappe/commands/site.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/frappe/commands/site.py b/frappe/commands/site.py index 2c52bddf8f..b08030e56a 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -227,6 +227,13 @@ def install_app(context, apps): def list_apps(context): "List apps in site" + 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() @@ -247,11 +254,13 @@ def list_apps(context): for app in apps ] applications_summary = "\n".join(installed_applications) - summary = f"\n{site_title}\n{applications_summary}".strip() + summary = f"{site_title}\n{applications_summary}\n" else: applications_summary = "\n".join(frappe.get_installed_apps()) - summary = f"\n{site_title}\n{applications_summary}".strip() + summary = f"{site_title}\n{applications_summary}\n" + + summary = fix_whitespaces(summary) if applications_summary and summary: print(summary) From 01312889f8834facfa7ab8f111a4334db6c2b47b Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Mon, 9 Nov 2020 15:31:49 +0530 Subject: [PATCH 045/197] refactor: Move get_build_version to utils.py --- frappe/utils/__init__.py | 8 ++++++++ frappe/www/desk.py | 10 +--------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py index d3bf1dd10c..b8866b53eb 100644 --- a/frappe/utils/__init__.py +++ b/frappe/utils/__init__.py @@ -721,3 +721,11 @@ def get_file_size(path, format=False): num /= 1024 return "{0:.1f}{1}{2}".format(num, 'Yi', suffix) + +def get_build_version(): + try: + return str(os.path.getmtime(os.path.join(frappe.local.sites_path, '.build'))) + except OSError: + # .build can sometimes not exist + # this is not a major problem so send fallback + return frappe.utils.random_string(8) \ No newline at end of file diff --git a/frappe/www/desk.py b/frappe/www/desk.py index c6bce850a5..e5c3c6af5c 100644 --- a/frappe/www/desk.py +++ b/frappe/www/desk.py @@ -36,7 +36,7 @@ def get_context(context): context.update({ "no_cache": 1, - "build_version": get_build_version(), + "build_version": frappe.utils.get_build_version(), "include_js": hooks["app_include_js"], "include_css": hooks["app_include_css"], "sounds": hooks["sounds"], @@ -82,11 +82,3 @@ def get_desk_assets(build_version): "boot": data["boot"], "assets": assets } - -def get_build_version(): - try: - return str(os.path.getmtime(os.path.join(frappe.local.sites_path, '.build'))) - except OSError: - # .build can sometimes not exist - # this is not a major problem so send fallback - return frappe.utils.random_string(8) From 13175a8bc4615ec8eab31045413c2fe5c4052a04 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Mon, 9 Nov 2020 15:54:17 +0530 Subject: [PATCH 046/197] fix(website): Bust cache by passing build_version to link and script sources --- frappe/templates/base.html | 8 ++++---- frappe/utils/__init__.py | 2 +- frappe/website/router.py | 3 +-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/frappe/templates/base.html b/frappe/templates/base.html index aaed0035b9..dc87ab72dd 100644 --- a/frappe/templates/base.html +++ b/frappe/templates/base.html @@ -30,11 +30,11 @@ {%- if theme.name != 'Standard' -%} {%- else -%} - + {%- endif -%} {%- for link in web_include_css %} - + {%- endfor -%} {%- endblock -%} @@ -94,12 +94,12 @@ {% block base_scripts %} - + {% endblock %} {%- for link in web_include_js %} - + {%- endfor -%} {%- block script %} diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py index b8866b53eb..c209ee13c9 100644 --- a/frappe/utils/__init__.py +++ b/frappe/utils/__init__.py @@ -728,4 +728,4 @@ def get_build_version(): except OSError: # .build can sometimes not exist # this is not a major problem so send fallback - return frappe.utils.random_string(8) \ No newline at end of file + return frappe.utils.random_string(8) diff --git a/frappe/website/router.py b/frappe/website/router.py index 22d186790b..5244c57ba8 100644 --- a/frappe/website/router.py +++ b/frappe/website/router.py @@ -275,8 +275,7 @@ def get_page_info(path, app, start, basepath=None, app_path=None, fname=None): # extract properties from controller attributes load_properties_from_controller(page_info) - # if not page_info.title: - # print('no-title-for', page_info.route) + page_info.build_version = frappe.utils.get_build_version() return page_info From 7394427df001bc8ca1712cd58d12372e737dcb70 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 9 Nov 2020 15:14:33 +0530 Subject: [PATCH 047/197] test: Add tests for bench list-apps --- frappe/tests/test_commands.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index 4e02de795a..9757a823a6 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -143,5 +143,24 @@ class TestCommands(BaseTestCommands): # test 1: remove app from installed_apps global default self.execute("bench --site {site} remove-from-installed-apps {app}", {"app": app}) + self.assertEquals(self.returncode, 0) self.execute("bench --site {site} list-apps") self.assertNotIn(app, self.stdout) + + def test_list_apps(self): + # test 1: sanity check for command + self.execute("bench --site all list-apps") + self.assertEquals(self.returncode, 0) + + # test 2: bare functionality for single site + self.execute("bench --site {site} list-apps") + self.assertEquals(self.returncode, 0) + list_apps = set([ + _x.split()[0] for _x in self.stdout.split("\n") + ]) + doctype = frappe.get_single("Installed Applications").installed_applications + if doctype: + installed_apps = set([x.app_name for x in doctype]) + else: + installed_apps = set(frappe.get_installed_apps()) + self.assertSetEqual(list_apps, installed_apps) From b86e6ac674a614d57bf02b317fed8343d7effb82 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 9 Nov 2020 17:39:45 +0530 Subject: [PATCH 048/197] fix: validate links table data (#11884) Co-authored-by: Faris Ansari --- frappe/core/doctype/doctype/doctype.py | 17 ++++++++++++- frappe/core/doctype/doctype/test_doctype.py | 27 +++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 8a9c130fbe..fd0cb1917d 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) @@ -656,6 +658,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", From 98b633a14ed98c8493a1d394757241244b9b56f5 Mon Sep 17 00:00:00 2001 From: Mohammad Hasnain Date: Mon, 9 Nov 2020 18:42:10 +0530 Subject: [PATCH 049/197] fix: add widgets/utils.js to build.json --- frappe/public/build.json | 4 +- frappe/public/js/frappe/widgets/utils.js | 51 ------------------------ 2 files changed, 3 insertions(+), 52 deletions(-) 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/widgets/utils.js b/frappe/public/js/frappe/widgets/utils.js index 88684127e0..ade35dae35 100644 --- a/frappe/public/js/frappe/widgets/utils.js +++ b/frappe/public/js/frappe/widgets/utils.js @@ -1,54 +1,5 @@ frappe.provide('frappe.widget.utils'); -function generate_grid(data) { - function add(a, b) { - return a + b; - } - - const grid_max_cols = 6 - - // Split the data into multiple arrays - // Each array will contain grid elements of one row - let processed = [] - let temp = [] - let init = 0 - data.forEach((data) => { - init = init + data.columns; - if (init > grid_max_cols) { - init = data.columns - processed.push(temp) - temp = [] - } - temp.push(data) - }) - - processed.push(temp) - - let grid_template = []; - - processed.forEach((data, index) => { - let aa = data.map(dd => { - return Array.apply(null, Array(dd.columns)).map(String.prototype.valueOf, dd.name) - }).flat() - - if (aa.length < grid_max_cols) { - let diff = grid_max_cols - aa.length; - for (let ii = 0; ii < diff; ii++) { - aa.push(`grid-${index}-${ii}`) - } - } - - grid_template.push(aa.join(" ")) - }) - let grid_template_area = "" - - grid_template.forEach(temp => { - grid_template_area += `"${temp}" ` - }) - - return grid_template_area -} - frappe.widget.utils = { build_summary_item: function(summary) { let df = { fieldtype: summary.datatype }; @@ -71,5 +22,3 @@ frappe.widget.utils = { ); }, }; - -export { generate_grid }; From af1ed2f0bcd964fb60381fc802092b9466ce5a20 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 9 Nov 2020 18:50:03 +0530 Subject: [PATCH 050/197] refactor: Move _new_site and extract_sql_from_archive from frappe.commands.site module to frappe.installer --- frappe/commands/site.py | 73 ++----------------------- frappe/installer.py | 115 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 117 insertions(+), 71 deletions(-) diff --git a/frappe/commands/site.py b/frappe/commands/site.py index 8af0b422ba..5305502b17 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') @@ -107,25 +56,9 @@ def _new_site(db_name, site, mariadb_root_username=None, mariadb_root_password=N @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 force = context.force or force - - # 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 = '.' - - 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 + decompressed_file_name = extract_sql_from_archive(sql_file_path) site = get_site(context) frappe.init(site=site) diff --git a/frappe/installer.py b/frappe/installer.py index df767a3294..b1420e5d9f 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, @@ -331,6 +413,37 @@ 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 + """ + # 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 = '.' + + 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 + + return decompressed_file_name + + def extract_sql_gzip(sql_gz_path): import subprocess From 7b1fa59a29ec59535976a53bd10528fe80f6e18c Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 9 Nov 2020 18:51:33 +0530 Subject: [PATCH 051/197] feat: Restore partial backups via bench partial-restore --- frappe/commands/site.py | 17 ++++++++++++++++- frappe/database/db_manager.py | 1 - frappe/installer.py | 23 +++++++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/frappe/commands/site.py b/frappe/commands/site.py index 5305502b17..fd247b4182 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -91,6 +91,20 @@ def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_pas 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') @@ -650,5 +664,6 @@ commands = [ stop_recording, add_to_hosts, start_ngrok, - build_search_index + build_search_index, + partial_restore ] diff --git a/frappe/database/db_manager.py b/frappe/database/db_manager.py index 3345fce735..da1f584f57 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. diff --git a/frappe/installer.py b/frappe/installer.py index b1420e5d9f..2bbf0421e3 100755 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -519,3 +519,26 @@ 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 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) From ce45343355a4f429e772c83636fe7f2c438345e1 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 9 Nov 2020 18:53:14 +0530 Subject: [PATCH 052/197] feat: Add aliases for bench backup * --include is the same as --only, -i * --exclude is the same as -e --- frappe/commands/site.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/commands/site.py b/frappe/commands/site.py index fd247b4182..646984dc8b 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -325,8 +325,8 @@ 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', default="", type=str, help="Specify the DocTypes to backup seperated by commas") -@click.option('--exclude', default="", type=str, help="Specify the DocTypes to not backup seperated by commas") +@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") From b371c7005359e202c68adc4a2abd09030f5366e2 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 10 Nov 2020 11:11:19 +0530 Subject: [PATCH 053/197] refactor: Meaningful variable names --- frappe/database/db_manager.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frappe/database/db_manager.py b/frappe/database/db_manager.py index da1f584f57..b8ffae519b 100644 --- a/frappe/database/db_manager.py +++ b/frappe/database/db_manager.py @@ -65,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 = '' @@ -77,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( From e8fdaa195bf3fe276be58c455d4fc3bad56970a8 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 10 Nov 2020 09:11:19 +0530 Subject: [PATCH 054/197] refactor: PostgreSQL module methods correspond to MariaDB * Added bootstrap_database, import_db_from_sql function APIs similar to MariaDB implementations --- frappe/database/postgres/setup_db.py | 29 +++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/frappe/database/postgres/setup_db.py b/frappe/database/postgres/setup_db.py index f53872db82..6f2ba7a1b7 100644 --- a/frappe/database/postgres/setup_db.py +++ b/frappe/database/postgres/setup_db.py @@ -10,6 +10,23 @@ 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 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''') + sys.exit(1) + +def import_db_from_sql(source_sql=None, verbose=False): + if verbose: + print("Starting Database Import...") # we can't pass psql password in arguments in postgresql as mysql. So # set password connection parameter in environment variable @@ -19,15 +36,21 @@ def setup_database(force, source_sql=None, verbose=False): if not source_sql: source_sql = os.path.join(os.path.dirname(__file__), 'framework_postgres.sql') - subprocess.check_output([ + command = [ '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) + ] - frappe.connect() + if verbose: + print(" ".join(command)) + + subprocess.check_output(command, env=subprocess_env) + + if verbose: + print(f"Imported from Database File: {source_sql}") def setup_help_database(help_db_name): root_conn = get_root_connection() From 19f87c36e51d57c2d7b96d20b43e919c7d4289e1 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 9 Nov 2020 18:57:21 +0530 Subject: [PATCH 055/197] fix: Marked is_downgrade function as only MariaDB compatible --- frappe/installer.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frappe/installer.py b/frappe/installer.py index 2bbf0421e3..b73f3f1d6e 100755 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -118,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 @@ -458,9 +458,10 @@ def extract_sql_gzip(sql_gz_path): return decompressed_file + def extract_files(site_name, file_path, folder_name): - import subprocess import shutil + import subprocess # Need to do frappe.init to maintain the site locals frappe.init(site=site_name) @@ -488,6 +489,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" From 142b6009fe0d2b609a9f0c40a4404a7dfb16ac72 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 10 Nov 2020 10:42:03 +0530 Subject: [PATCH 056/197] fix: Better error message on missing table --- frappe/database/mariadb/setup_db.py | 18 ++++++++++++++---- frappe/database/postgres/setup_db.py | 13 ++++++++++--- 2 files changed, 24 insertions(+), 7 deletions(-) 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 6f2ba7a1b7..109ed0469e 100644 --- a/frappe/database/postgres/setup_db.py +++ b/frappe/database/postgres/setup_db.py @@ -19,9 +19,16 @@ 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 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(): + 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 853acf6dd05176729ed033ec36658557ea31ce76 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 10 Nov 2020 10:51:23 +0530 Subject: [PATCH 057/197] fix: Add "partial" tag in the backup file following site name to indicate its nature --- frappe/utils/backups.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index 552debbaa1..7856c8d953 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -65,6 +65,7 @@ class BackupGenerator: self.ignore_conf = ignore_conf self.include_doctypes = include_doctypes self.exclude_doctypes = exclude_doctypes + self.partial = False if not self.db_type: self.db_type = "mariadb" @@ -144,6 +145,8 @@ class BackupGenerator: self.backup_includes = self.backup_includes or conf_tables["include"] self.backup_excludes = self.backup_excludes or conf_tables["exclude"] + self.partial = (self.backup_includes or self.backup_excludes) and not self.ignore_conf + @property def site_config_backup_path(self): # For backwards compatibility @@ -199,7 +202,10 @@ class BackupGenerator: self.backup_path_conf = site_config_backup_path def set_backup_file_name(self): - # Generate a random name using today's date and a 8 digit random number + if self.partial: + # slugs postfixed with partial won't get returned by get_recent_backup + self.site_slug = self.site_slug + "-partial" + for_conf = self.todays_date + "-" + self.site_slug + "-site_config_backup.json" for_db = self.todays_date + "-" + self.site_slug + "-database.sql.gz" ext = "tgz" if self.compress_files else "tar" From b4e17b9f95f65900b9c287d8a14985fba114065b Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 10 Nov 2020 10:52:10 +0530 Subject: [PATCH 058/197] fix: Give more information about file to match verbosity --- frappe/utils/backups.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index 7856c8d953..f44bcd40e8 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -573,29 +573,29 @@ def delete_temp_backups(older_than=24): os.remove(this_file_path) -def is_file_old(db_file_name, older_than=24): +def is_file_old(file_path, older_than=24): """ Checks if file exists and is older than specified hours Returns -> True: file does not exist or file is old False: file is new """ - if os.path.isfile(db_file_name): + if os.path.isfile(file_path): from datetime import timedelta # Get timestamp of the file - file_datetime = datetime.fromtimestamp(os.stat(db_file_name).st_ctime) + file_datetime = datetime.fromtimestamp(os.stat(file_path).st_ctime) if datetime.today() - file_datetime >= timedelta(hours=older_than): if _verbose: - print("File is old") + print(f"File {file_path} is older than {older_than} hours") return True else: if _verbose: - print("File is recent") + print(f"File {file_path} is recent") return False else: if _verbose: - print("File does not exist") + print(f"File {file_path} does not exist") return True From a073a595448a2459e3d220e6406a46291f462daa Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 11 Nov 2020 09:11:19 +0530 Subject: [PATCH 059/197] fix: Add partial slug only for database file --- frappe/utils/backups.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index f44bcd40e8..e2fe4c559a 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -202,16 +202,14 @@ class BackupGenerator: self.backup_path_conf = site_config_backup_path def set_backup_file_name(self): - if self.partial: - # slugs postfixed with partial won't get returned by get_recent_backup - self.site_slug = self.site_slug + "-partial" - - for_conf = self.todays_date + "-" + self.site_slug + "-site_config_backup.json" - for_db = self.todays_date + "-" + self.site_slug + "-database.sql.gz" + # backups with the partial tag won't get returned by get_recent_backup + partial = "-partial" if self.partial else "" ext = "tgz" if self.compress_files else "tar" - for_public_files = self.todays_date + "-" + self.site_slug + "-files." + ext - for_private_files = self.todays_date + "-" + self.site_slug + "-private-files." + ext + for_conf = f"{self.todays_date}-{self.site_slug}-site_config_backup.json" + for_db = f"{self.todays_date}-{self.site_slug}{partial}-database.sql.gz" + for_public_files = f"{self.todays_date}-{self.site_slug}-files.{ext}" + for_private_files = f"{self.todays_date}-{self.site_slug}-private-files.{ext}" backup_path = self.backup_path or get_backup_path() if not self.backup_path_conf: From e7fb4d0ef3bff2c6da3b77038a90d6b0849f031a Mon Sep 17 00:00:00 2001 From: Aditya Hase Date: Tue, 10 Nov 2020 11:49:03 +0530 Subject: [PATCH 060/197] fix(query-report): Show scrollbar for datatable Show report-wrapper before rendering datatable --- frappe/public/js/frappe/views/reports/query_report.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/views/reports/query_report.js b/frappe/public/js/frappe/views/reports/query_report.js index 130bf372e6..60abb187ae 100644 --- a/frappe/public/js/frappe/views/reports/query_report.js +++ b/frappe/public/js/frappe/views/reports/query_report.js @@ -813,6 +813,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { data.splice(-1, 1); } + this.$report.show(); if (this.datatable && this.datatable.options && (this.datatable.options.showTotalRow ===this.raw_data.add_total_row)) { this.datatable.options.treeView = this.tree_report; @@ -844,7 +845,6 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { if (this.report_settings.after_datatable_render) { this.report_settings.after_datatable_render(this.datatable); } - this.$report.show(); } get_chart_options(data) { From 3fed5c72553e2404123beb63ba10405cbf934536 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 10 Nov 2020 13:11:34 +0530 Subject: [PATCH 061/197] test: Add tests for bench partial-restore --- frappe/tests/test_commands.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index 283f9c01a7..f5cdd6b775 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -12,6 +12,7 @@ from glob import glob # imports - module imports import frappe +from frappe.utils import add_to_date, now from frappe.utils.backups import fetch_latest_backups import frappe.recorder @@ -243,6 +244,30 @@ class TestCommands(BaseTestCommands): database = fetch_latest_backups()["database"] self.assertTrue(exists_in_backup(backup["excludes"]["excludes"], database)) + def test_partial_restore(self): + _now = now() + for num in range(10): + frappe.get_doc({ + "doctype": "ToDo", + "date": add_to_date(_now, days=num), + "description": frappe.mock("paragraph") + }).insert() + todo_count = frappe.db.count("ToDo") + + # check if todos exist, create a partial backup and see if the state is the same after restore + self.assertIsNot(todo_count, 0) + self.execute("bench --site {site} backup --only 'ToDo'") + db_path = fetch_latest_backups(partial=True)["database"] + self.assertTrue("partial" in db_path) + + frappe.db.sql_ddl("DROP TABLE IF EXISTS `tabToDo`") + frappe.db.commit() + + self.execute("bench --site {site} partial-restore {path}", {"path": db_path}) + self.assertEquals(self.returncode, 0) + frappe.db.commit() + self.assertEquals(frappe.db.count("ToDo"), todo_count) + def test_recorder(self): frappe.recorder.stop() From 34af8cb32601993c069a862f1902aac53d848e69 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 12 Nov 2020 09:11:19 +0530 Subject: [PATCH 062/197] fix: Show partial backups when flag set * in fetch_latest_backups whitelisted API * BackupGenerator.get_recent_backup --- frappe/tests/test_commands.py | 8 ++++---- frappe/utils/backups.py | 9 ++++----- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index f5cdd6b775..1063307b76 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -215,27 +215,27 @@ class TestCommands(BaseTestCommands): self.execute("bench --site {site} set-config backup '{includes}' --as-dict", {"includes": json.dumps(backup["includes"])}) self.execute("bench --site {site} backup --verbose") self.assertEquals(self.returncode, 0) - database = fetch_latest_backups()["database"] + database = fetch_latest_backups(partial=True)["database"] self.assertTrue(exists_in_backup(backup["includes"]["includes"], database)) # test 8: take a backup with frappe.conf.backup.excludes self.execute("bench --site {site} set-config backup '{excludes}' --as-dict", {"excludes": json.dumps(backup["excludes"])}) self.execute("bench --site {site} backup --verbose") self.assertEquals(self.returncode, 0) - database = fetch_latest_backups()["database"] + database = fetch_latest_backups(partial=True)["database"] self.assertFalse(exists_in_backup(backup["excludes"]["excludes"], database)) self.assertTrue(exists_in_backup(backup["includes"]["includes"], database)) # test 9: take a backup with --include (with frappe.conf.excludes still set) self.execute("bench --site {site} backup --include '{include}'", {"include": ",".join(backup["includes"]["includes"])}) self.assertEquals(self.returncode, 0) - database = fetch_latest_backups()["database"] + database = fetch_latest_backups(partial=True)["database"] self.assertTrue(exists_in_backup(backup["includes"]["includes"], database)) # test 10: take a backup with --exclude self.execute("bench --site {site} backup --exclude '{exclude}'", {"exclude": ",".join(backup["excludes"]["excludes"])}) self.assertEquals(self.returncode, 0) - database = fetch_latest_backups()["database"] + database = fetch_latest_backups(partial=True)["database"] self.assertFalse(exists_in_backup(backup["excludes"]["excludes"], database)) # test 11: take a backup with --ignore-backup-conf diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index e2fe4c559a..178d213e59 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -202,7 +202,6 @@ class BackupGenerator: self.backup_path_conf = site_config_backup_path def set_backup_file_name(self): - # backups with the partial tag won't get returned by get_recent_backup partial = "-partial" if self.partial else "" ext = "tgz" if self.compress_files else "tar" @@ -221,11 +220,11 @@ class BackupGenerator: if not self.backup_path_private_files: self.backup_path_private_files = os.path.join(backup_path, for_private_files) - def get_recent_backup(self, older_than): + def get_recent_backup(self, older_than, partial=False): backup_path = get_backup_path() file_type_slugs = { - "database": "*-{}-database.sql.gz", + "database": "*-{{}}-{}database.sql.gz".format('*' if partial else ''), "public": "*-{}-files.tar", "private": "*-{}-private-files.tar", "config": "*-{}-site_config_backup.json", @@ -463,7 +462,7 @@ def get_backup(): @frappe.whitelist() -def fetch_latest_backups(): +def fetch_latest_backups(partial=False): """Fetches paths of the latest backup taken in the last 30 days Only for: System Managers @@ -479,7 +478,7 @@ def fetch_latest_backups(): db_type=frappe.conf.db_type, db_port=frappe.conf.db_port, ) - database, public, private, config = odb.get_recent_backup(older_than=24 * 30) + database, public, private, config = odb.get_recent_backup(older_than=24 * 30, partial=partial) return {"database": database, "public": public, "private": private, "config": config} From a22cd461ac85764477a39dc1e5ed613125b4e0bf Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 10 Nov 2020 13:34:48 +0530 Subject: [PATCH 063/197] test: Commit after insert, not before count check --- frappe/tests/test_commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index 1063307b76..d19a62d788 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -252,6 +252,7 @@ class TestCommands(BaseTestCommands): "date": add_to_date(_now, days=num), "description": frappe.mock("paragraph") }).insert() + frappe.db.commit() todo_count = frappe.db.count("ToDo") # check if todos exist, create a partial backup and see if the state is the same after restore @@ -265,7 +266,6 @@ class TestCommands(BaseTestCommands): self.execute("bench --site {site} partial-restore {path}", {"path": db_path}) self.assertEquals(self.returncode, 0) - frappe.db.commit() self.assertEquals(frappe.db.count("ToDo"), todo_count) def test_recorder(self): From a8427c735e97501416b565f1493928a0828c6b67 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 10 Nov 2020 18:01:16 +0530 Subject: [PATCH 064/197] fix: Dont take backup in dry run + other fixes --- frappe/installer.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/frappe/installer.py b/frappe/installer.py index 51113beae8..9807421e98 100755 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -135,7 +135,7 @@ def remove_app(app_name, dry_run=False, yes=False, no_backup=False, force=False) 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) @@ -173,13 +173,15 @@ def remove_app(app_name, dry_run=False, yes=False, no_backup=False, force=False) if not dry_run: remove_from_installed_apps(app_name) - for doctype in set(drop_doctypes): - print("* dropping Table for '{0}'...".format(doctype)) + for doctype in set(drop_doctypes): + print("* dropping Table for '{0}'...".format(doctype)) + if not dry_run: frappe.db.sql_ddl("drop table `tab{0}`".format(doctype)) + if not dry_run: frappe.db.commit() - click.secho("Uninstalled App {0} from Site {1}".format(app_name, frappe.local.site), fg="green") + click.secho("Uninstalled App {0} from Site {1}".format(app_name, frappe.local.site), fg="green") frappe.flags.in_uninstall = False From b3a487242ff52feb6b30e94750b64d32171772f7 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 10 Nov 2020 18:04:44 +0530 Subject: [PATCH 065/197] fix: Use get_all instead of get_list --- frappe/installer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/installer.py b/frappe/installer.py index 9807421e98..dd03783869 100755 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -147,7 +147,7 @@ def remove_app(app_name, dry_run=False, yes=False, no_backup=False, force=False) for module_name in modules: print("Deleting Module '{0}'".format(module_name)) - for doctype in frappe.get_list("DocType", filters={"module": module_name}, fields=["name", "issingle"]): + for doctype in frappe.get_all("DocType", filters={"module": module_name}, fields=["name", "issingle"]): print("* removing DocType '{0}'...".format(doctype.name)) if not dry_run: @@ -161,7 +161,7 @@ def remove_app(app_name, dry_run=False, yes=False, no_backup=False, force=False) doctypes_with_linked_modules = ordered_doctypes + [doctype.parent for doctype in linked_doctypes if doctype.parent not in ordered_doctypes] for doctype in doctypes_with_linked_modules: - for record in frappe.get_list(doctype, filters={"module": module_name}): + for record in frappe.get_all(doctype, filters={"module": module_name}): print("* removing {0} '{1}'...".format(doctype, record.name)) if not dry_run: frappe.delete_doc(doctype, record.name) From 5facf0fd1c45f812f7224d852e11ad63bbeff8c8 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 10 Nov 2020 18:17:54 +0530 Subject: [PATCH 066/197] style: Use f-strings, pluck and Black --- frappe/installer.py | 49 +++++++++++++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/frappe/installer.py b/frappe/installer.py index dd03783869..7f630cbe57 100755 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -121,34 +121,41 @@ def remove_from_installed_apps(app_name): 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 (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_all("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) @@ -156,17 +163,25 @@ def remove_app(app_name, dry_run=False, yes=False, no_backup=False, force=False) 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_all(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) - 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) @@ -174,14 +189,14 @@ def remove_app(app_name, dry_run=False, yes=False, no_backup=False, force=False) remove_from_installed_apps(app_name) for doctype in set(drop_doctypes): - print("* dropping Table for '{0}'...".format(doctype)) + print(f"* dropping Table for '{doctype}'...") if not dry_run: - frappe.db.sql_ddl("drop table `tab{0}`".format(doctype)) + frappe.db.sql_ddl(f"drop table `tab{doctype}`") if not dry_run: 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 From d28fb7ff5e7c74a516a9c2bf5ac44fb1535165cc Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 10 Nov 2020 18:37:21 +0530 Subject: [PATCH 067/197] fix: ignore on_trash, delete comment on dt --- frappe/installer.py | 11 +++++------ frappe/model/delete_doc.py | 14 ++++++++++---- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/frappe/installer.py b/frappe/installer.py index 7f630cbe57..ac411e2667 100755 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -121,6 +121,7 @@ def remove_from_installed_apps(app_name): 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 @@ -158,7 +159,7 @@ def remove_app(app_name, dry_run=False, yes=False, no_backup=False, force=False) 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) @@ -179,14 +180,11 @@ def remove_app(app_name, dry_run=False, yes=False, no_backup=False, force=False) 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) + frappe.delete_doc(doctype, record, ignore_on_trash=True) print(f"* removing Module Def '{module_name}'...") if not dry_run: - frappe.delete_doc("Module Def", module_name) - - if not dry_run: - remove_from_installed_apps(app_name) + frappe.delete_doc("Module Def", module_name, ignore_on_trash=True) for doctype in set(drop_doctypes): print(f"* dropping Table for '{doctype}'...") @@ -194,6 +192,7 @@ def remove_app(app_name, dry_run=False, yes=False, no_backup=False, force=False) frappe.db.sql_ddl(f"drop table `tab{doctype}`") if not dry_run: + remove_from_installed_apps(app_name) frappe.db.commit() click.secho(f"Uninstalled App {app_name} from Site {site}", fg="green") 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 From e036c65ee221a5142db8494b0410f3489fcbf665 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 11 Nov 2020 12:34:56 +0530 Subject: [PATCH 068/197] fix: Bypass validation if force is passed --- frappe/commands/site.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/commands/site.py b/frappe/commands/site.py index 2be566f85f..51c352a931 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -127,7 +127,7 @@ def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_pas else: decompressed_file_name = sql_file_path - validate_database_sql(decompressed_file_name, _raise=force) + validate_database_sql(decompressed_file_name, _raise=not force) site = get_site(context) frappe.init(site=site) From c295882e058737a6f8e5da6244641d4767e19190 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 11 Nov 2020 14:03:03 +0530 Subject: [PATCH 069/197] fix: Don't overwrite same variable --- frappe/installer.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/frappe/installer.py b/frappe/installer.py index 51113beae8..6745a92345 100755 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -415,21 +415,23 @@ def validate_database_sql(path, _raise=True): path (str): Path of the decompressed SQL file _raise (bool, optional): Raise exception if invalid file. Defaults to True. """ - _raise = False + to_raise = False error_message = "" if not os.path.getsize(path): error_message = f"{path} is an empty file!" - _raise = True + to_raise = True if not _raise: with open(path, "r") as f: for line in f: if 'tabDefaultValue' in line: error_message = "Table `tabDefaultValue` not found in file." - _raise = True + to_raise = True - if error_message and _raise: + if error_message: import click click.secho(error_message, fg="red") + + if _raise and to_raise: raise frappe.InvalidDatabaseFile From ff1cf6e7d61150394b7ce0a18275fec4ad5f6049 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 11 Nov 2020 14:26:44 +0530 Subject: [PATCH 070/197] test: Trigger GitHub checks From 0e1807091087018338134b4aa1f10a3fd58e0589 Mon Sep 17 00:00:00 2001 From: Rohan Bansal Date: Wed, 11 Nov 2020 14:56:55 +0530 Subject: [PATCH 071/197] fix: error on trying to check semantic version --- frappe/utils/change_log.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/frappe/utils/change_log.py b/frappe/utils/change_log.py index 29fee2bac0..f7fac4cdf4 100644 --- a/frappe/utils/change_log.py +++ b/frappe/utils/change_log.py @@ -165,9 +165,10 @@ def check_for_update(): add_message_to_redis(updates) + def parse_latest_non_beta_release(response): """ - Pasrses the response JSON for all the releases and returns the latest non prerelease + Parses the response JSON for all the releases and returns the latest non prerelease Parameters response (list): response object returned by github @@ -182,32 +183,34 @@ def parse_latest_non_beta_release(response): return None + def check_release_on_github(app): - # Check if repo remote is on github from subprocess import CalledProcessError + try: + # Check if repo remote is on github remote_url = subprocess.check_output("cd ../apps/{} && git ls-remote --get-url".format(app), shell=True).decode() except CalledProcessError: # Passing this since some apps may not have git initializaed in them - return None + return if isinstance(remote_url, bytes): remote_url = remote_url.decode() if "github.com" not in remote_url: - return None + return # Get latest version from github if 'https' not in remote_url: - return None + return org_name = remote_url.split('/')[3] r = requests.get('https://api.github.com/repos/{}/{}/releases'.format(org_name, app)) if r.ok: - lastest_non_beta_release = parse_latest_non_beta_release(r.json()) - return Version(lastest_non_beta_release), org_name - # In case of an improper response or if there are no releases - return None + latest_non_beta_release = parse_latest_non_beta_release(r.json()) + if latest_non_beta_release: + return Version(latest_non_beta_release), org_name + def add_message_to_redis(update_json): # "update-message" will store the update message string From 53fc7b946aaf4bf0be4528528a3671a35fdf19a4 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 11 Nov 2020 18:22:40 +0530 Subject: [PATCH 072/197] fix: dashboard not visible bug --- frappe/public/js/frappe/form/dashboard.js | 6 +++++- frappe/public/js/frappe/form/layout.js | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) 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/layout.js b/frappe/public/js/frappe/form/layout.js index 6ea21e6e63..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 }); }, From f9523b0a3dc1b8b89aa5bd1e5367b824f9619e9a Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Wed, 11 Nov 2020 19:09:25 +0530 Subject: [PATCH 073/197] fix: Set VSCode keybindings in ace editor --- frappe/public/js/frappe/form/controls/code.js | 1 + 1 file changed, 1 insertion(+) 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) { From 3fec6c2ee1c2bcf467167dd294daf42ba31b4e95 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Wed, 11 Nov 2020 19:09:36 +0530 Subject: [PATCH 074/197] fix: Python syntax highlighting in Script field --- frappe/core/doctype/server_script/server_script.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frappe/core/doctype/server_script/server_script.json b/frappe/core/doctype/server_script/server_script.json index cc3995ad1d..420f96ec2f 100644 --- a/frappe/core/doctype/server_script/server_script.json +++ b/frappe/core/doctype/server_script/server_script.json @@ -31,6 +31,7 @@ "fieldname": "script", "fieldtype": "Code", "label": "Script", + "options": "Python", "reqd": 1 }, { @@ -87,7 +88,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2020-08-24 16:44:41.060350", + "modified": "2020-11-11 12:39:41.391052", "modified_by": "Administrator", "module": "Core", "name": "Server Script", From e230ddf4d3408adbe9a31fa1638ac229504bd3d5 Mon Sep 17 00:00:00 2001 From: prssanna Date: Thu, 12 Nov 2020 13:43:03 +0530 Subject: [PATCH 075/197] fix: allow any field to be set in based on field --- .../automation/doctype/assignment_rule/assignment_rule.js | 2 +- .../automation/doctype/assignment_rule/assignment_rule.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/frappe/automation/doctype/assignment_rule/assignment_rule.js b/frappe/automation/doctype/assignment_rule/assignment_rule.js index 774befc15e..e6f136fe62 100644 --- a/frappe/automation/doctype/assignment_rule/assignment_rule.js +++ b/frappe/automation/doctype/assignment_rule/assignment_rule.js @@ -57,7 +57,7 @@ frappe.ui.form.on('Assignment Rule', { frm.set_fields_as_options( 'field', doctype, - (df) => df.fieldtype == 'Link' && df.options == 'User', + () => true, [{ 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): From e2dd9f401f1901d337d41a7a83678d70bec1e5c1 Mon Sep 17 00:00:00 2001 From: UrvashiKishnani <41088003+UrvashiKishnani@users.noreply.github.com> Date: Thu, 12 Nov 2020 12:50:09 +0400 Subject: [PATCH 076/197] fix(minor): order of HTML closing tags --- frappe/public/js/frappe/form/templates/form_sidebar.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 @@