Selaa lähdekoodia

Merge branch 'develop' into maximum-attachment-limit-validation-develop

version-14
Suraj Shetty 4 vuotta sitten
committed by GitHub
vanhempi
commit
a05ea1eea6
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
10 muutettua tiedostoa jossa 948 lisäystä ja 263 poistoa
  1. +1
    -1
      frappe/build.py
  2. +79
    -81
      frappe/commands/site.py
  3. +5
    -6
      frappe/database/db_manager.py
  4. +14
    -4
      frappe/database/mariadb/setup_db.py
  5. +53
    -12
      frappe/database/postgres/setup_db.py
  6. +2
    -1
      frappe/exceptions.py
  7. +154
    -3
      frappe/installer.py
  8. +1
    -1
      frappe/integrations/frappe_providers/frappecloud.py
  9. +214
    -14
      frappe/tests/test_commands.py
  10. +425
    -140
      frappe/utils/backups.py

+ 1
- 1
frappe/build.py Näytä tiedosto

@@ -105,7 +105,7 @@ def download_frappe_assets(verbose=True):
if frappe_head: if frappe_head:
try: try:
url = get_assets_link(frappe_head) url = get_assets_link(frappe_head)
click.secho("Retreiving assets...", fg="yellow")
click.secho("Retrieving assets...", fg="yellow")
prefix = mkdtemp(prefix="frappe-assets-", suffix=frappe_head) prefix = mkdtemp(prefix="frappe-assets-", suffix=frappe_head)
assets_archive = download_file(url, prefix) assets_archive = download_file(url, prefix)
print("\n{0} Downloaded Frappe assets from {1}".format(green('✔'), url)) print("\n{0} Downloaded Frappe assets from {1}".format(green('✔'), url))


+ 79
- 81
frappe/commands/site.py Näytä tiedosto

@@ -9,7 +9,7 @@ import click
import frappe import frappe
from frappe.commands import get_site, pass_context from frappe.commands import get_site, pass_context
from frappe.exceptions import SiteNotSpecifiedError from frappe.exceptions import SiteNotSpecifiedError
from frappe.utils import get_site_path, touch_file
from frappe.installer import _new_site




@click.command('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: if len(frappe.utils.get_sites()) == 1:
use(site) 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.command('restore')
@click.argument('sql-file-path') @click.argument('sql-file-path')
@@ -107,33 +56,41 @@ def _new_site(db_name, site, mariadb_root_username=None, mariadb_root_password=N
@pass_context @pass_context
def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_password=None, db_name=None, verbose=None, install_app=None, admin_password=None, force=None, with_public_files=None, with_private_files=None): def restore(context, sql_file_path, 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" "Restore site database from an sql file"
from frappe.installer import extract_sql_gzip, extract_files, is_downgrade, validate_database_sql
force = context.force or force
from frappe.installer import (
extract_sql_from_archive,
extract_files,
is_downgrade,
is_partial,
validate_database_sql
)


# 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 = '.'
force = context.force or force
decompressed_file_name = extract_sql_from_archive(sql_file_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
# check if partial backup
if is_partial(decompressed_file_name):
click.secho(
"Partial Backup file detected. You cannot use a partial file to restore a Frappe Site.",
fg="red"
)
click.secho(
"Use `bench partial-restore` to restore a partial backup to an existing site.",
fg="yellow"
)
sys.exit(1)


# check if valid SQL file
validate_database_sql(decompressed_file_name, _raise=not force) validate_database_sql(decompressed_file_name, _raise=not force)

site = get_site(context) site = get_site(context)
frappe.init(site=site) frappe.init(site=site)


# dont allow downgrading to older versions of frappe without force # dont allow downgrading to older versions of frappe without force
if not force and is_downgrade(decompressed_file_name, verbose=True): if not force and is_downgrade(decompressed_file_name, verbose=True):
warn_message = "This is not recommended and may lead to unexpected behaviour. Do you want to continue anyway?"
warn_message = (
"This is not recommended and may lead to unexpected behaviour. "
"Do you want to continue anyway?"
)
click.confirm(warn_message, abort=True) click.confirm(warn_message, abort=True)


_new_site(frappe.conf.db_name, site, mariadb_root_username=mariadb_root_username, _new_site(frappe.conf.db_name, site, mariadb_root_username=mariadb_root_username,
@@ -156,9 +113,28 @@ def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_pas
if decompressed_file_name != sql_file_path: if decompressed_file_name != sql_file_path:
os.remove(decompressed_file_name) os.remove(decompressed_file_name)


success_message = "Site {0} has been restored{1}".format(site, " with files" if (with_public_files or with_private_files) else "")
success_message = "Site {0} has been restored{1}".format(
site,
" with files" if (with_public_files or with_private_files) else ""
)
click.secho(success_message, fg="green") click.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.command('reinstall')
@click.option('--admin-password', help='Administrator Password for reinstalled site') @click.option('--admin-password', help='Administrator Password for reinstalled site')
@click.option('--mariadb-root-username', help='Root username for MariaDB') @click.option('--mariadb-root-username', help='Root username for MariaDB')
@@ -416,16 +392,20 @@ def use(site, sites_path='.'):


@click.command('backup') @click.command('backup')
@click.option('--with-files', default=False, is_flag=True, help="Take backup with files") @click.option('--with-files', default=False, is_flag=True, help="Take backup with files")
@click.option('--include', '--only', '-i', default="", type=str, help="Specify the DocTypes to backup seperated by commas")
@click.option('--exclude', '-e', default="", type=str, help="Specify the DocTypes to not backup seperated by commas")
@click.option('--backup-path', default=None, help="Set path for saving all the files in this operation") @click.option('--backup-path', 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-db', default=None, help="Set path for saving database file")
@click.option('--backup-path-files', default=None, help="Set path for saving public file") @click.option('--backup-path-files', default=None, help="Set path for saving public file")
@click.option('--backup-path-private-files', default=None, help="Set path for saving private file") @click.option('--backup-path-private-files', default=None, help="Set path for saving private file")
@click.option('--backup-path-conf', default=None, help="Set path for saving config file") @click.option('--backup-path-conf', default=None, help="Set path for saving config file")
@click.option('--ignore-backup-conf', default=False, is_flag=True, help="Ignore excludes/includes set in config")
@click.option('--verbose', default=False, is_flag=True, help="Add verbosity") @click.option('--verbose', default=False, is_flag=True, help="Add verbosity")
@click.option('--compress', default=False, is_flag=True, help="Compress private and public files") @click.option('--compress', default=False, is_flag=True, help="Compress private and public files")
@pass_context @pass_context
def backup(context, with_files=False, backup_path=None, backup_path_db=None, backup_path_files=None, def backup(context, with_files=False, backup_path=None, backup_path_db=None, backup_path_files=None,
backup_path_private_files=None, backup_path_conf=None, verbose=False, compress=False):
backup_path_private_files=None, backup_path_conf=None, ignore_backup_conf=False, verbose=False,
compress=False, include="", exclude=""):
"Backup" "Backup"
from frappe.utils.backups import scheduled_backup from frappe.utils.backups import scheduled_backup
verbose = verbose or context.verbose verbose = verbose or context.verbose
@@ -435,11 +415,27 @@ def backup(context, with_files=False, backup_path=None, backup_path_db=None, bac
try: try:
frappe.init(site=site) frappe.init(site=site)
frappe.connect() frappe.connect()
odb = scheduled_backup(ignore_files=not with_files, backup_path=backup_path, backup_path_db=backup_path_db, backup_path_files=backup_path_files, backup_path_private_files=backup_path_private_files, backup_path_conf=backup_path_conf, force=True, verbose=verbose, compress=compress)
odb = scheduled_backup(
ignore_files=not with_files,
backup_path=backup_path,
backup_path_db=backup_path_db,
backup_path_files=backup_path_files,
backup_path_private_files=backup_path_private_files,
backup_path_conf=backup_path_conf,
ignore_conf=ignore_backup_conf,
include_doctypes=include,
exclude_doctypes=exclude,
compress=compress,
verbose=verbose,
force=True
)
except Exception: except Exception:
click.secho("Backup failed for Site {0}. Database or site_config.json may be corrupted".format(site), fg="red") click.secho("Backup failed for Site {0}. Database or site_config.json may be corrupted".format(site), fg="red")
if verbose:
print(frappe.get_traceback())
exit_code = 1 exit_code = 1
continue continue

odb.print_summary() odb.print_summary()
click.secho("Backup for Site {0} has been successfully completed{1}".format(site, " with files" if with_files else ""), fg="green") click.secho("Backup for Site {0} has been successfully completed{1}".format(site, " with files" if with_files else ""), fg="green")
frappe.destroy() frappe.destroy()
@@ -512,13 +508,14 @@ def _drop_site(site, root_login='root', root_password=None, archived_sites_path=
if force: if force:
pass pass
else: else:
click.echo("="*80)
click.echo("Error: The operation has stopped because backup of {s}'s database failed.".format(s=site))
click.echo("Reason: {reason}{sep}".format(reason=str(err), sep="\n"))
click.echo("Fix the issue and try again.")
click.echo(
"Hint: Use 'bench drop-site {s} --force' to force the removal of {s}".format(sep="\n", tab="\t", s=site)
)
messages = [
"=" * 80,
"Error: The operation has stopped because backup of {0}'s database failed.".format(site),
"Reason: {0}\n".format(str(err)),
"Fix the issue and try again.",
"Hint: Use 'bench drop-site {0} --force' to force the removal of {0}".format(site)
]
click.echo("\n".join(messages))
sys.exit(1) sys.exit(1)


drop_user_and_database(frappe.conf.db_name, root_login, root_password) drop_user_and_database(frappe.conf.db_name, root_login, root_password)
@@ -734,5 +731,6 @@ commands = [
stop_recording, stop_recording,
add_to_hosts, add_to_hosts,
start_ngrok, start_ngrok,
build_search_index
build_search_index,
partial_restore
] ]

+ 5
- 6
frappe/database/db_manager.py Näytä tiedosto

@@ -3,7 +3,6 @@ import frappe




class DbManager: class DbManager:

def __init__(self, db): def __init__(self, db):
""" """
Pass root_conn here for access to all databases. Pass root_conn here for access to all databases.
@@ -66,10 +65,10 @@ class DbManager:
esc = make_esc('$ ') esc = make_esc('$ ')


from distutils.spawn import find_executable 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=source
) )
source = '' source = ''
@@ -78,7 +77,7 @@ class DbManager:
source = '< {source}'.format(source=source) source = '< {source}'.format(source=source)


if pipe: 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 = '{pipe} mysql -u {user} -p{password} -h{host} ' + ('-P{port}' if frappe.db.port else '') + ' {target} {source}'
command = command.format( command = command.format(


+ 14
- 4
frappe/database/mariadb/setup_db.py Näytä tiedosto

@@ -1,7 +1,7 @@
from __future__ import unicode_literals from __future__ import unicode_literals


import frappe import frappe
import os, sys
import os
from frappe.database.db_manager import DbManager from frappe.database.db_manager import DbManager


expected_settings_10_2_earlier = { 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) dbman.drop_database(db_name)


def bootstrap_database(db_name, verbose, source_sql=None): def bootstrap_database(db_name, verbose, source_sql=None):
import sys

frappe.connect(db_name=db_name) frappe.connect(db_name=db_name)
if not check_database_settings(): if not check_database_settings():
print('Database settings do not match expected values; stopping database setup.') 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) import_db_from_sql(source_sql, verbose)


frappe.connect(db_name=db_name) 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) sys.exit(1)


def import_db_from_sql(source_sql=None, verbose=False): def import_db_from_sql(source_sql=None, verbose=False):


+ 53
- 12
frappe/database/postgres/setup_db.py Näytä tiedosto

@@ -1,5 +1,7 @@
import frappe, subprocess, os
from six.moves import input
import os

import frappe



def setup_database(force, source_sql=None, verbose=False): def setup_database(force, source_sql=None, verbose=False):
root_conn = get_root_connection() root_conn = get_root_connection()
@@ -10,24 +12,62 @@ def setup_database(force, source_sql=None, verbose=False):
root_conn.sql("CREATE user {0} password '{1}'".format(frappe.conf.db_name, root_conn.sql("CREATE user {0} password '{1}'".format(frappe.conf.db_name,
frappe.conf.db_password)) frappe.conf.db_password))
root_conn.sql("GRANT ALL PRIVILEGES ON DATABASE `{0}` TO {0}".format(frappe.conf.db_name)) root_conn.sql("GRANT ALL PRIVILEGES ON DATABASE `{0}` TO {0}".format(frappe.conf.db_name))
root_conn.close()

bootstrap_database(frappe.conf.db_name, verbose, source_sql=source_sql)
frappe.connect()

def bootstrap_database(db_name, verbose, source_sql=None):
frappe.connect(db_name=db_name)
import_db_from_sql(source_sql, verbose)
frappe.connect(db_name=db_name)

if 'tabDefaultValue' not in frappe.db.get_tables():
import sys
from click import secho

secho(
"Table 'tabDefaultValue' missing in the restored site. "
"This may be due to incorrect permissions or the result of a restore from a bad backup file. "
"Database not installed correctly.",
fg="red"
)
sys.exit(1)

def import_db_from_sql(source_sql=None, verbose=False):
from shutil import which
from subprocess import run, PIPE


# we can't pass psql password in arguments in postgresql as mysql. So # we can't pass psql password in arguments in postgresql as mysql. So
# set password connection parameter in environment variable # set password connection parameter in environment variable
subprocess_env = os.environ.copy() subprocess_env = os.environ.copy()
subprocess_env['PGPASSWORD'] = str(frappe.conf.db_password) subprocess_env['PGPASSWORD'] = str(frappe.conf.db_password)

# bootstrap db # bootstrap db
if not source_sql: if not source_sql:
source_sql = os.path.join(os.path.dirname(__file__), 'framework_postgres.sql') source_sql = os.path.join(os.path.dirname(__file__), 'framework_postgres.sql')


subprocess.check_output([
'psql', frappe.conf.db_name,
'-h', frappe.conf.db_host or 'localhost',
'-p', str(frappe.conf.db_port or '5432'),
'-U', frappe.conf.db_name,
'-f', source_sql
], env=subprocess_env)
pv = which('pv')


frappe.connect()
_command = (
f"psql {frappe.conf.db_name} "
f"-h {frappe.conf.db_host or 'localhost'} -p {str(frappe.conf.db_port or '5432')} "
f"-U {frappe.conf.db_name}"
)

if pv:
command = f"{pv} {source_sql} | " + _command
else:
command = _command + f" -f {source_sql}"

print("Restoring Database file...")
if verbose:
print(command)

restore_proc = run(command, env=subprocess_env, shell=True, stdout=PIPE)

if verbose:
print(f"\nSTDOUT by psql:\n{restore_proc.stdout.decode()}\nImported from Database File: {source_sql}")


def setup_help_database(help_db_name): def setup_help_database(help_db_name):
root_conn = get_root_connection() root_conn = get_root_connection()
@@ -38,19 +78,20 @@ def setup_help_database(help_db_name):
root_conn.sql("GRANT ALL PRIVILEGES ON DATABASE `{0}` TO {0}".format(help_db_name)) root_conn.sql("GRANT ALL PRIVILEGES ON DATABASE `{0}` TO {0}".format(help_db_name))


def get_root_connection(root_login=None, root_password=None): def get_root_connection(root_login=None, root_password=None):
import getpass
if not frappe.local.flags.root_connection: if not frappe.local.flags.root_connection:
if not root_login: if not root_login:
root_login = frappe.conf.get("root_login") or None root_login = frappe.conf.get("root_login") or None


if not root_login: if not root_login:
from six.moves import input
root_login = input("Enter postgres super user: ") root_login = input("Enter postgres super user: ")


if not root_password: if not root_password:
root_password = frappe.conf.get("root_password") or None root_password = frappe.conf.get("root_password") or None


if not root_password: if not root_password:
root_password = getpass.getpass("Postgres super user password: ")
from getpass import getpass
root_password = getpass("Postgres super user password: ")


frappe.local.flags.root_connection = frappe.database.get_db(user=root_login, password=root_password) frappe.local.flags.root_connection = frappe.database.get_db(user=root_login, password=root_password)




+ 2
- 1
frappe/exceptions.py Näytä tiedosto

@@ -111,4 +111,5 @@ class AttachmentLimitReached(Exception): pass
class InvalidAuthorizationHeader(CSRFTokenError): pass class InvalidAuthorizationHeader(CSRFTokenError): pass
class InvalidAuthorizationPrefix(CSRFTokenError): pass class InvalidAuthorizationPrefix(CSRFTokenError): pass
class InvalidAuthorizationToken(CSRFTokenError): pass class InvalidAuthorizationToken(CSRFTokenError): pass
class InvalidDatabaseFile(ValidationError): pass
class InvalidDatabaseFile(ValidationError): pass
class ExecutableNotFound(FileNotFoundError): pass

+ 154
- 3
frappe/installer.py Näytä tiedosto

@@ -3,8 +3,90 @@


import json import json
import os import os
from frappe.defaults import _clear_cache
import sys

import frappe 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, def install_db(root_login="root", root_password=None, db_name=None, source_sql=None,
@@ -36,9 +118,9 @@ def install_db(root_login="root", root_password=None, db_name=None, source_sql=N


def install_app(name, verbose=False, set_as_patched=True): 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.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.model.sync import sync_for
from frappe.modules.utils import sync_customizations from frappe.modules.utils import sync_customizations
from frappe.utils.fixtures import sync_fixtures


frappe.flags.in_install = name frappe.flags.in_install = name
frappe.flags.ignore_in_install = False frappe.flags.ignore_in_install = False
@@ -347,6 +429,37 @@ def remove_missing_apps():
frappe.db.set_global("installed_apps", json.dumps(installed_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): def extract_sql_gzip(sql_gz_path):
import subprocess import subprocess


@@ -361,9 +474,10 @@ def extract_sql_gzip(sql_gz_path):


return decompressed_file return decompressed_file



def extract_files(site_name, file_path, folder_name): def extract_files(site_name, file_path, folder_name):
import subprocess
import shutil import shutil
import subprocess


# Need to do frappe.init to maintain the site locals # Need to do frappe.init to maintain the site locals
frappe.init(site=site_name) frappe.init(site=site_name)
@@ -391,6 +505,12 @@ def extract_files(site_name, file_path, folder_name):


def is_downgrade(sql_file_path, verbose=False): def is_downgrade(sql_file_path, verbose=False):
"""checks if input db backup will get downgraded on current bench""" """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 from semantic_version import Version
head = "INSERT INTO `tabInstalled Application` VALUES" head = "INSERT INTO `tabInstalled Application` VALUES"


@@ -424,6 +544,37 @@ def is_downgrade(sql_file_path, verbose=False):
return downgrade return downgrade




def is_partial(sql_file_path):
with open(sql_file_path) as f:
header = " ".join([f.readline() for _ in range(5)])
if "Partial Backup" in header:
return True
return False


def partial_restore(sql_file_path, verbose=False):
sql_file = extract_sql_from_archive(sql_file_path)

if frappe.conf.db_type in (None, "mariadb"):
from frappe.database.mariadb.setup_db import import_db_from_sql
elif frappe.conf.db_type == "postgres":
from frappe.database.postgres.setup_db import import_db_from_sql
import warnings
from click import style
warn = style(
"Delete the tables you want to restore manually before attempting"
" partial restore operation for PostreSQL databases",
fg="yellow"
)
warnings.warn(warn)

import_db_from_sql(source_sql=sql_file, verbose=verbose)

# Removing temporarily created file
if sql_file != sql_file_path:
os.remove(sql_file)


def validate_database_sql(path, _raise=True): def validate_database_sql(path, _raise=True):
"""Check if file has contents and if DefaultValue table exists """Check if file has contents and if DefaultValue table exists




+ 1
- 1
frappe/integrations/frappe_providers/frappecloud.py Näytä tiedosto

@@ -6,7 +6,7 @@ import frappe




def frappecloud_migrator(local_site): def frappecloud_migrator(local_site):
print("Retreiving Site Migrator...")
print("Retrieving Site Migrator...")
remote_site = frappe.conf.frappecloud_url or "frappecloud.com" remote_site = frappe.conf.frappecloud_url or "frappecloud.com"
request_url = "https://{}/api/method/press.api.script".format(remote_site) request_url = "https://{}/api/method/press.api.script".format(remote_site)
request = requests.get(request_url) request = requests.get(request_url)


+ 214
- 14
frappe/tests/test_commands.py Näytä tiedosto

@@ -1,24 +1,88 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors


# imports - standard imports # imports - standard imports
import gzip
import json
import os import os
import shlex import shlex
import subprocess import subprocess
import sys
import unittest import unittest
from glob import glob
import glob


# imports - module imports # imports - module imports
import frappe import frappe
from frappe.utils.backups import fetch_latest_backups
import frappe.recorder import frappe.recorder
from frappe.installer import add_to_installed_apps
from frappe.utils import add_to_date, now
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): 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 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
"""
predicate = (
'COPY 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([predicate.format(doctype).lower() in content.lower() for doctype in doctypes])


class BaseTestCommands(unittest.TestCase): class BaseTestCommands(unittest.TestCase):
def execute(self, command, kwargs=None): def execute(self, command, kwargs=None):
site = {"site": frappe.local.site} site = {"site": frappe.local.site}
@@ -26,13 +90,26 @@ class BaseTestCommands(unittest.TestCase):
kwargs.update(site) kwargs.update(site)
else: else:
kwargs = site kwargs = site
command = command.replace("\n", " ").format(**kwargs)
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._proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
self.stdout = clean(self._proc.stdout) self.stdout = clean(self._proc.stdout)
self.stderr = clean(self._proc.stderr) self.stderr = clean(self._proc.stderr)
self.returncode = clean(self._proc.returncode) 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): class TestCommands(BaseTestCommands):
def test_execute(self): def test_execute(self):
@@ -52,9 +129,24 @@ class TestCommands(BaseTestCommands):
# The returned value has quotes which have been trimmed for the test # The returned value has quotes which have been trimmed for the test
self.execute("""bench --site {site} execute frappe.bold --kwargs '{{"text": "DocType"}}'""") self.execute("""bench --site {site} execute frappe.bold --kwargs '{{"text": "DocType"}}'""")
self.assertEquals(self.returncode, 0) self.assertEquals(self.returncode, 0)
self.assertEquals(self.stdout[1:-1], frappe.bold(text='DocType'))
self.assertEquals(self.stdout[1:-1], frappe.bold(text="DocType"))


def test_backup(self): def test_backup(self):
backup = {
"includes": {
"includes": [
"ToDo",
"Note",
]
},
"excludes": {
"excludes": [
"Activity Log",
"Access Log",
"Error Log"
]
}
}
home = os.path.expanduser("~") home = os.path.expanduser("~")
site_backup_path = frappe.utils.get_site_path("private", "backups") site_backup_path = frappe.utils.get_site_path("private", "backups")


@@ -94,16 +186,19 @@ class TestCommands(BaseTestCommands):
"db_path": "database.sql.gz", "db_path": "database.sql.gz",
"files_path": "public.tar", "files_path": "public.tar",
"private_path": "private.tar", "private_path": "private.tar",
"conf_path": "config.json"
"conf_path": "config.json",
}.items() }.items()
} }


self.execute("""bench
self.execute(
"""bench
--site {site} backup --with-files --site {site} backup --with-files
--backup-path-db {db_path} --backup-path-db {db_path}
--backup-path-files {files_path} --backup-path-files {files_path}
--backup-path-private-files {private_path} --backup-path-private-files {private_path}
--backup-path-conf {conf_path}""", kwargs)
--backup-path-conf {conf_path}""",
kwargs,
)


self.assertEquals(self.returncode, 0) self.assertEquals(self.returncode, 0)
for path in kwargs.values(): for path in kwargs.values():
@@ -111,16 +206,122 @@ class TestCommands(BaseTestCommands):


# test 5: take a backup with --compress # test 5: take a backup with --compress
self.execute("bench --site {site} backup --with-files --compress") self.execute("bench --site {site} backup --with-files --compress")

self.assertEquals(self.returncode, 0) self.assertEquals(self.returncode, 0)

compressed_files = glob(site_backup_path + "/*.tgz")
compressed_files = glob.glob(site_backup_path + "/*.tgz")
self.assertGreater(len(compressed_files), 0) self.assertGreater(len(compressed_files), 0)


# test 6: take a backup with --verbose # test 6: take a backup with --verbose
self.execute("bench --site {site} backup --verbose") self.execute("bench --site {site} backup --verbose")
self.assertEquals(self.returncode, 0) 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(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(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(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(partial=True)["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))

def test_restore(self):
# step 0: create a site to run the test on
global_config = {
"admin_password": frappe.conf.admin_password,
"root_login": frappe.conf.root_login,
"root_password": frappe.conf.root_password,
"db_type": frappe.conf.db_type,
}
site_data = {"another_site": f"{frappe.local.site}-restore.test", **global_config}
for key, value in global_config.items():
if value:
self.execute(f"bench set-config {key} {value} -g")
self.execute(
"bench new-site {another_site} --admin-password {admin_password} --db-type"
" {db_type}",
site_data,
)

# test 1: bench restore from full backup
self.execute("bench --site {another_site} backup --ignore-backup-conf", site_data)
self.execute(
"bench --site {another_site} execute frappe.utils.backups.fetch_latest_backups",
site_data,
)
site_data.update({"database": json.loads(self.stdout)["database"]})
self.execute("bench --site {another_site} restore {database}", site_data)

# test 2: restore from partial backup
self.execute("bench --site {another_site} backup --exclude 'ToDo'", site_data)
site_data.update({"kw": "\"{'partial':True}\""})
self.execute(
"bench --site {another_site} execute"
" frappe.utils.backups.fetch_latest_backups --kwargs {kw}",
site_data,
)
site_data.update({"database": json.loads(self.stdout)["database"]})
self.execute("bench --site {another_site} restore {database}", site_data)
self.assertEquals(self.returncode, 1)

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()
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
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)
self.assertEquals(frappe.db.count("ToDo"), todo_count)

def test_recorder(self): def test_recorder(self):
frappe.recorder.stop() frappe.recorder.stop()


@@ -133,7 +334,6 @@ class TestCommands(BaseTestCommands):
self.assertEqual(frappe.recorder.status(), False) self.assertEqual(frappe.recorder.status(), False)


def test_remove_from_installed_apps(self): def test_remove_from_installed_apps(self):
from frappe.installer import add_to_installed_apps
app = "test_remove_app" app = "test_remove_app"
add_to_installed_apps(app) add_to_installed_apps(app)




+ 425
- 140
frappe/utils/backups.py Näytä tiedosto

@@ -2,11 +2,12 @@
# MIT License. See license.txt # MIT License. See license.txt


# imports - standard imports # imports - standard imports
import json
import gzip
import os import os
from calendar import timegm from calendar import timegm
from datetime import datetime from datetime import datetime
from glob import glob from glob import glob
from shutil import which


# imports - third party imports # imports - third party imports
import click import click
@@ -14,24 +15,42 @@ import click
# imports - module imports # imports - module imports
import frappe import frappe
from frappe import _, conf from frappe import _, conf
from frappe.utils import get_url, now, now_datetime, get_file_size
from frappe.utils import get_file_size, get_url, now, now_datetime


# backup variable for backwards compatibility # backup variable for backwards compatibility
verbose = False verbose = False
compress = False compress = False
_verbose = verbose _verbose = verbose
base_tables = ["__Auth", "__global_search", "__UserSettings"]




class BackupGenerator: 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,
verbose=False, db_type='mariadb', backup_path_conf=None, compress_files=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 global _verbose
self.compress_files = compress_files or compress self.compress_files = compress_files or compress
self.db_host = db_host self.db_host = db_host
@@ -45,23 +64,35 @@ class BackupGenerator:
self.backup_path_db = backup_path_db self.backup_path_db = backup_path_db
self.backup_path_files = backup_path_files self.backup_path_files = backup_path_files
self.backup_path_private_files = backup_path_private_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
self.partial = False


if not self.db_type: 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) site = frappe.local.site or frappe.generate_hash(length=8)
self.site_slug = site.replace('.', '_')
self.site_slug = site.replace(".", "_")
self.verbose = verbose self.verbose = verbose
self.setup_backup_directory() self.setup_backup_directory()
self.setup_backup_tables()
_verbose = verbose _verbose = verbose


def setup_backup_directory(self): 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: if not specified:
backups_folder = get_backup_path() backups_folder = get_backup_path()
@@ -71,32 +102,93 @@ class BackupGenerator:
if self.backup_path: if self.backup_path:
os.makedirs(self.backup_path, exist_ok=True) 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: if file_path:
dir = os.path.dirname(file_path) dir = os.path.dirname(file_path)
os.makedirs(dir, exist_ok=True) os.makedirs(dir, exist_ok=True)


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 and doctype in existing_doctypes:
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(",")),
}
specified_tables = get_tables(frappe.conf.get("backup", {}).get("includes", []))
include_tables = (specified_tables + base_tables) if specified_tables else []

conf_tables = {
"include": include_tables,
"exclude": get_tables(frappe.conf.get("backup", {}).get("excludes", [])),
}

self.backup_includes = passed_tables["include"]
self.backup_excludes = passed_tables["exclude"]

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"]

self.partial = (self.backup_includes or self.backup_excludes) and not self.ignore_conf

@property @property
def site_config_backup_path(self): def site_config_backup_path(self):
# For backwards compatibility # 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) return getattr(self, "backup_path_conf", None)


def get_backup(self, older_than=24, ignore_files=False, force=False): 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: 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: 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() self.set_backup_file_name()


if not (last_db and last_file and last_private_file and site_config_backup_path): if not (last_db and last_file and last_private_file and site_config_backup_path):
@@ -112,13 +204,13 @@ class BackupGenerator:
self.backup_path_conf = site_config_backup_path self.backup_path_conf = site_config_backup_path


def set_backup_file_name(self): def set_backup_file_name(self):
#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"
partial = "-partial" if self.partial else ""
ext = "tgz" if self.compress_files else "tar" 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() backup_path = self.backup_path or get_backup_path()


if not self.backup_path_conf: if not self.backup_path_conf:
@@ -130,11 +222,11 @@ class BackupGenerator:
if not self.backup_path_private_files: if not self.backup_path_private_files:
self.backup_path_private_files = os.path.join(backup_path, for_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() backup_path = get_backup_path()


file_type_slugs = { file_type_slugs = {
"database": "*-{}-database.sql.gz",
"database": "*-{{}}-{}database.sql.gz".format('*' if partial else ''),
"public": "*-{}-files.tar", "public": "*-{}-files.tar",
"private": "*-{}-private-files.tar", "private": "*-{}-private-files.tar",
"config": "*-{}-site_config_backup.json", "config": "*-{}-site_config_backup.json",
@@ -158,8 +250,7 @@ class BackupGenerator:
return file_path return file_path


latest_backups = { 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 = { recent_backups = {
@@ -175,32 +266,40 @@ class BackupGenerator:


def zip_files(self): def zip_files(self):
# For backwards compatibility - pre v13 # 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() return self.backup_files()


def get_summary(self): def get_summary(self):
summary = { summary = {
"config": { "config": {
"path": self.backup_path_conf, "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": { "database": {
"path": self.backup_path_db, "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 return summary


@@ -208,21 +307,29 @@ class BackupGenerator:
backup_summary = self.get_summary() backup_summary = self.get_summary()
print("Backup Summary for {0} at {1}".format(frappe.local.site, now())) 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(): 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): def backup_files(self):
import subprocess import subprocess


for folder in ("public", "private"): for folder in ("public", "private"):
files_path = frappe.get_site_path(folder, "files") 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: if self.compress_files:
cmd_string = "tar cf - {1} | gzip > {0}" cmd_string = "tar cf - {1} | gzip > {0}"
else: else:
cmd_string = "tar -cf {0} {1}" 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: if self.verbose and output:
print(output.decode("utf8")) print(output.decode("utf8"))
@@ -236,34 +343,114 @@ class BackupGenerator:


def take_dump(self): def take_dump(self):
import frappe.utils import frappe.utils
from frappe.utils.change_log import get_app_branch

db_exc = {
"mariadb": ("mysqldump", which("mysqldump")),
"postgres": ("pg_dump", which("pg_dump")),
}[self.db_type]
gzip_exc = which("gzip")

if not (gzip_exc and db_exc[1]):
_exc = "gzip" if not gzip_exc else db_exc[0]
frappe.throw(
f"{_exc} not found in PATH! This is required to take a backup.",
exc=frappe.ExecutableNotFound
)
db_exc = db_exc[0]

database_header_content = [
f"Backup generated by Frappe {frappe.__version__} on branch {get_app_branch('frappe') or 'N/A'}",
"",
]


# escape reserved characters # escape reserved characters
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.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')
args = frappe._dict(
[item[0], frappe.utils.esc(str(item[1]), "$ ")]
for item in self.__dict__.copy().items()
)

if self.backup_includes:
backup_info = ("Backing Up Tables: ", ", ".join(self.backup_includes))
elif self.backup_excludes:
backup_info = ("Skipping Tables: ", ", ".join(self.backup_excludes))

if self.partial:
print(''.join(backup_info), "\n")
database_header_content.extend([
f"Partial Backup of Frappe Site {frappe.local.site}",
("Backup contains: " if self.backup_includes else "Backup excludes: ") + backup_info[1],
"",
])

generated_header = "\n".join([f"-- {x}" for x in database_header_content]) + "\n"

with gzip.open(args.backup_path_db, "wt") as f:
f.write(generated_header)

if self.db_type == "postgres":
if self.backup_includes:
args["include"] = " ".join(
["--table='public.\"{0}\"'".format(table) for table in self.backup_includes]
)
elif self.backup_excludes:
args["exclude"] = " ".join(
["--exclude-table-data='public.\"{0}\"'".format(table) for table in self.backup_excludes]
)

cmd_string = (
"{db_exc} postgres://{user}:{password}@{db_host}:{db_port}/{db_name}"
" {include} {exclude} | {gzip} >> {backup_path_db}"
) )


err, out = frappe.utils.execute_in_shell(cmd_string)
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 = (
"{db_exc} --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.user,
password=args.password,
db_exc=db_exc,
db_host=args.db_host,
db_port=args.db_port,
db_name=args.db_name,
backup_path_db=args.backup_path_db,
exclude=args.get("exclude", ""),
include=args.get("include", ""),
gzip=gzip_exc,
)

if self.verbose:
print(command + "\n")

err, out = frappe.utils.execute_in_shell(command)


def send_email(self): 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 from frappe.email import get_system_managers


recipient_list = 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, msg = """Hello,


@@ -275,11 +462,13 @@ Your backups are ready to be downloaded.
This link will be valid for 24 hours. A new backup will be available for This link will be valid for 24 hours. A new backup will be available for
download only after 24 hours.""" % { download only after 24 hours.""" % {
"db_backup_url": db_backup_url, "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) 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) frappe.sendmail(recipients=recipient_list, msg=msg, subject=subject)
return recipient_list return recipient_list
@@ -288,20 +477,29 @@ download only after 24 hours.""" % {
@frappe.whitelist() @frappe.whitelist()
def get_backup(): 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() 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() odb.get_backup()
recipient_list = odb.send_email() 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() @frappe.whitelist()
def fetch_latest_backups():
def fetch_latest_backups(partial=False):
"""Fetches paths of the latest backup taken in the last 30 days """Fetches paths of the latest backup taken in the last 30 days
Only for: System Managers Only for: System Managers


@@ -317,43 +515,88 @@ def fetch_latest_backups():
db_type=frappe.conf.db_type, db_type=frappe.conf.db_type,
db_port=frappe.conf.db_port, db_port=frappe.conf.db_port,
) )
database, public, private, config = odb.get_recent_backup(older_than=24 * 30)

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, force=False, verbose=False, compress=False):
database, public, private, config = odb.get_recent_backup(older_than=24 * 30, partial=partial)

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,
):
"""this function is called from scheduler """this function is called from scheduler
deletes backups older than 7 days
takes backup"""
odb = new_backup(older_than, ignore_files, backup_path=backup_path, backup_path_db=backup_path_db, backup_path_files=backup_path_files, backup_path_private_files=backup_path_private_files, backup_path_conf=backup_path_conf, force=force, verbose=verbose, compress=compress)
deletes backups older than 7 days
takes backup"""
odb = new_backup(
older_than=older_than,
ignore_files=ignore_files,
backup_path=backup_path,
backup_path_db=backup_path_db,
backup_path_files=backup_path_files,
backup_path_private_files=backup_path_private_files,
backup_path_conf=backup_path_conf,
ignore_conf=ignore_conf,
include_doctypes=include_doctypes,
exclude_doctypes=exclude_doctypes,
compress=compress,
force=force,
verbose=verbose,
)
return odb 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, force=False, verbose=False, compress=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,
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,
db_host = frappe.db.host,
db_port = frappe.db.port,
db_type = frappe.conf.db_type,
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) odb.get_backup(older_than, ignore_files, force=force)
return odb return odb



def delete_temp_backups(older_than=24): 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() backup_path = get_backup_path()
if os.path.exists(backup_path): if os.path.exists(backup_path):
@@ -363,54 +606,68 @@ def delete_temp_backups(older_than=24):
if is_file_old(this_file_path, older_than): if is_file_old(this_file_path, older_than):
os.remove(this_file_path) 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
else:

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(file_path):
from datetime import timedelta

# Get timestamp of the file
file_datetime = datetime.fromtimestamp(os.stat(file_path).st_ctime)
if datetime.today() - file_datetime >= timedelta(hours=older_than):
if _verbose: if _verbose:
print("File does not exist")
print(f"File {file_path} is older than {older_than} hours")
return True return True
else:
if _verbose:
print(f"File {file_path} is recent")
return False
else:
if _verbose:
print(f"File {file_path} does not exist")
return True



def get_backup_path(): def get_backup_path():
backup_path = frappe.utils.get_site_path(conf.get("backup_path", "private/backups")) backup_path = frappe.utils.get_site_path(conf.get("backup_path", "private/backups"))
return backup_path 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" "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 { return {
"backup_path_db": odb.backup_path_db, "backup_path_db": odb.backup_path_db,
"backup_path_files": odb.backup_path_files, "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__": 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 import sys

cmd = sys.argv[1] cmd = sys.argv[1]


db_type = 'mariadb'
db_type = "mariadb"
try: try:
db_type = sys.argv[6] db_type = sys.argv[6]
except IndexError: except IndexError:
@@ -423,19 +680,47 @@ if __name__ == "__main__":
pass pass


if cmd == "is_file_old": 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) is_file_old(odb.db_file_name)


if cmd == "get_backup": 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() odb.get_backup()


if cmd == "take_dump": 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() odb.take_dump()


if cmd == "send_email": 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") odb.send_email("abc.sql.gz")


if cmd == "delete_temp_backups": if cmd == "delete_temp_backups":


Ladataan…
Peruuta
Tallenna