diff --git a/cypress/integration/depends_on.js b/cypress/integration/depends_on.js new file mode 100644 index 000000000000..73faad845a80 --- /dev/null +++ b/cypress/integration/depends_on.js @@ -0,0 +1,58 @@ +context('Depends On', () => { + before(() => { + cy.login(); + cy.visit('/desk'); + cy.window().its('frappe').then(frappe => { + frappe.call('frappe.tests.ui_test_helpers.create_doctype', { + name: 'Test Depends On', + fields: [ + { + "label": "Test Field", + "fieldname": "test_field", + "fieldtype": "Data", + }, + { + "label": "Dependant Field", + "fieldname": "dependant_field", + "fieldtype": "Data", + "mandatory_depends_on": "eval:doc.test_field=='Some Value'", + "read_only_depends_on": "eval:doc.test_field=='Some Other Value'", + }, + { + "label": "Display Dependant Field", + "fieldname": "display_dependant_field", + "fieldtype": "Data", + 'depends_on': "eval:doc.test_field=='Value'" + }, + ] + }); + }); + }); + it('should set the field as mandatory depending on other fields value', () => { + cy.new_form('Test Depends On'); + cy.fill_field('test_field', 'Some Value'); + cy.get('button.primary-action').contains('Save').click(); + cy.get('.msgprint-dialog .modal-title').contains('Missing Fields').should('be.visible'); + cy.get('body').click(); + cy.fill_field('test_field', 'Random value'); + cy.get('button.primary-action').contains('Save').click(); + cy.get('.msgprint-dialog .modal-title').contains('Missing Fields').should('not.be.visible'); + }); + it('should set the field as read only depending on other fields value', () => { + cy.new_form('Test Depends On'); + cy.fill_field('dependant_field', 'Some Value'); + cy.fill_field('test_field', 'Some Other Value'); + cy.get('body').click(); + cy.get('.control-input [data-fieldname="dependant_field"]').should('be.disabled'); + cy.fill_field('test_field', 'Random Value'); + cy.get('body').click(); + cy.get('.control-input [data-fieldname="dependant_field"]').should('not.be.disabled'); + }); + it('should display the field depending on other fields value', () => { + cy.get('.control-input [data-fieldname="display_dependant_field"]').should('not.be.visible'); + cy.get('.control-input [data-fieldname="test_field"]').clear(); + cy.fill_field('test_field', 'Value'); + cy.get('body').click(); + cy.get('.control-input [data-fieldname="display_dependant_field"]').should('be.visible'); + }); +}); diff --git a/frappe/__init__.py b/frappe/__init__.py index 873ab4b3977f..218aad52b2f1 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -24,7 +24,7 @@ sys.setdefaultencoding("utf-8") __frappe_version__ = '12.8.1' -__version__ = '2.3.0' +__version__ = '2.3.1' __title__ = "Frappe Framework" local = Local() diff --git a/frappe/commands/site.py b/frappe/commands/site.py index e1531ae99362..fefb4f4c8ade 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -28,6 +28,8 @@ @click.option('--db-name', help='Database name') @click.option('--db-password', help='Database password') @click.option('--db-type', default='mariadb', type=click.Choice(['mariadb', 'postgres']), help='Optional "postgres" or "mariadb". Default is "mariadb"') +@click.option('--db-host', help='Database Host') +@click.option('--db-port', type=int, help='Database Port') @click.option('--mariadb-root-username', default='root', help='Root username for MariaDB') @click.option('--mariadb-root-password', help='Root password for MariaDB') @click.option('--no-mariadb-socket', is_flag=True, default=False, help='Set MariaDB host to % and use TCP/IP Socket instead of using the UNIX Socket') @@ -137,7 +139,7 @@ def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_pas _new_site(frappe.conf.db_name, site, mariadb_root_username=mariadb_root_username, mariadb_root_password=mariadb_root_password, admin_password=admin_password, verbose=context.verbose, install_apps=install_app, source_sql=decompressed_file_name, - force=True) + force=True, db_type=frappe.conf.db_type) # Extract public and/or private files to the restored site, if user has given the path if with_public_files: @@ -457,7 +459,7 @@ def _drop_site(site, root_login='root', root_password=None, archived_sites_path= 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=err[1], sep="\n")) + 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) diff --git a/frappe/contacts/doctype/address/address.json b/frappe/contacts/doctype/address/address.json index 713322d2831a..28d6e5b4f650 100644 --- a/frappe/contacts/doctype/address/address.json +++ b/frappe/contacts/doctype/address/address.json @@ -15,6 +15,7 @@ "county", "state", "country", + "country_code", "pincode", "column_break0", "email_id", @@ -110,6 +111,7 @@ "fieldname": "phone", "fieldtype": "Data", "label": "Phone", + "options": "Phone", "reqd": 1 }, { @@ -158,11 +160,19 @@ "fieldname": "ignore_owner_links", "fieldtype": "Check", "label": "Ignore Owner Links" + }, + { + "fetch_from": "country.code", + "fieldname": "country_code", + "fieldtype": "Data", + "hidden": 1, + "label": "Country Code", + "read_only": 1 } ], "icon": "fa fa-map-marker", "idx": 5, - "modified": "2020-12-02 20:42:36.303850", + "modified": "2021-06-15 08:57:55.857908", "modified_by": "Administrator", "module": "Contacts", "name": "Address", diff --git a/frappe/core/doctype/docfield/docfield.json b/frappe/core/doctype/docfield/docfield.json index b8757c6cdf0c..32821838e96e 100644 --- a/frappe/core/doctype/docfield/docfield.json +++ b/frappe/core/doctype/docfield/docfield.json @@ -42,6 +42,10 @@ "report_hide", "remember_last_selected_value", "ignore_xss_filter", + "property_depends_on_section", + "mandatory_depends_on", + "column_break_38", + "read_only_depends_on", "display", "in_filter", "no_copy", @@ -421,11 +425,32 @@ "hidden": 1, "oldfieldname": "oldfieldtype", "oldfieldtype": "Data" + }, + { + "fieldname": "mandatory_depends_on", + "fieldtype": "Code", + "label": "Mandatory Depends On", + "options": "JS" + }, + { + "fieldname": "read_only_depends_on", + "fieldtype": "Code", + "label": "Read Only Depends On", + "options": "JS" + }, + { + "fieldname": "property_depends_on_section", + "fieldtype": "Section Break", + "label": "Property Depends On" + }, + { + "fieldname": "column_break_38", + "fieldtype": "Column Break" } ], "idx": 1, "istable": 1, - "modified": "2021-04-15 23:05:58.400774", + "modified": "2021-06-10 00:00:53.242492", "modified_by": "Administrator", "module": "Core", "name": "DocField", diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 10d637c27128..38f5a266c59e 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -920,7 +920,7 @@ def check_sort_field(meta): def check_illegal_depends_on_conditions(docfield): ''' assignment operation should not be allowed in the depends on condition.''' - depends_on_fields = ["depends_on", "collapsible_depends_on"] + depends_on_fields = ["depends_on", "collapsible_depends_on", "mandatory_depends_on", "read_only_depends_on"] for field in depends_on_fields: depends_on = docfield.get(field, None) if depends_on and ("=" in depends_on) and \ diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py index 07a42e73a134..8a098b1e381d 100644 --- a/frappe/core/doctype/doctype/test_doctype.py +++ b/frappe/core/doctype/doctype/test_doctype.py @@ -96,14 +96,19 @@ def test_depends_on_fields(self): def test_all_depends_on_fields_conditions(self): import re - docfields = frappe.get_all("DocField", or_filters={ + docfields = frappe.get_all("DocField", + or_filters={ "ifnull(depends_on, '')": ("!=", ''), - "ifnull(collapsible_depends_on, '')": ("!=", '') - }, fields=["parent", "depends_on", "collapsible_depends_on", "fieldname", "fieldtype"]) + "ifnull(collapsible_depends_on, '')": ("!=", ''), + "ifnull(mandatory_depends_on, '')": ("!=", ''), + "ifnull(read_only_depends_on, '')": ("!=", '') + }, + fields=["parent", "depends_on", "collapsible_depends_on", "mandatory_depends_on", + "read_only_depends_on", "fieldname", "fieldtype"]) pattern = """[\w\.:_]+\s*={1}\s*[\w\.@'"]+""" for field in docfields: - for depends_on in ["depends_on", "collapsible_depends_on"]: + for depends_on in ["depends_on", "collapsible_depends_on", "mandatory_depends_on", "read_only_depends_on"]: condition = field.get(depends_on) if condition: self.assertFalse(re.match(pattern, condition)) diff --git a/frappe/custom/doctype/custom_field/custom_field.json b/frappe/custom/doctype/custom_field/custom_field.json index 59bd88233ba5..6ce76d7935ef 100644 --- a/frappe/custom/doctype/custom_field/custom_field.json +++ b/frappe/custom/doctype/custom_field/custom_field.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_import": 1, "creation": "2013-01-10 16:34:01", "description": "Adds a custom field to a DocType", @@ -24,10 +25,8 @@ "collapsible_depends_on", "default", "depends_on", - "description", - "permlevel", - "width", - "columns", + "mandatory_depends_on", + "read_only_depends_on", "properties", "reqd", "unique", @@ -47,7 +46,11 @@ "search_index", "allow_in_quick_entry", "ignore_xss_filter", - "translatable" + "translatable", + "description", + "permlevel", + "width", + "columns" ], "fields": [ { @@ -356,6 +359,18 @@ "fieldname": "allow_in_quick_entry", "fieldtype": "Check", "label": "Allow in Quick Entry" + }, + { + "fieldname": "mandatory_depends_on", + "fieldtype": "Code", + "label": "Mandatory Depends On", + "length": 255 + }, + { + "fieldname": "read_only_depends_on", + "fieldtype": "Code", + "label": "Read Only Depends On", + "length": 255 } ], "icon": "fa fa-glass", diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py index 5beb709b5b43..1a6b173067e4 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -65,6 +65,8 @@ 'report_hide': 'Check', 'allow_on_submit': 'Check', 'translatable': 'Check', + 'mandatory_depends_on': 'Data', + 'read_only_depends_on': 'Data', 'depends_on': 'Data', 'description': 'Text', 'default': 'Text', diff --git a/frappe/custom/doctype/customize_form_field/customize_form_field.json b/frappe/custom/doctype/customize_form_field/customize_form_field.json index 37d5517ec018..9acb4fe49d01 100644 --- a/frappe/custom/doctype/customize_form_field/customize_form_field.json +++ b/frappe/custom/doctype/customize_form_field/customize_form_field.json @@ -37,6 +37,10 @@ "allow_on_submit", "report_hide", "remember_last_selected_value", + "property_depends_on_section", + "mandatory_depends_on", + "column_break_33", + "read_only_depends_on", "display", "default", "in_filter", @@ -354,6 +358,27 @@ "fieldname": "allow_in_quick_entry", "fieldtype": "Check", "label": " Allow in Quick Entry " + }, + { + "fieldname": "property_depends_on_section", + "fieldtype": "Section Break", + "label": "Property Depends On" + }, + { + "fieldname": "mandatory_depends_on", + "fieldtype": "Code", + "label": "Mandatory Depends On", + "options": "JS" + }, + { + "fieldname": "column_break_33", + "fieldtype": "Column Break" + }, + { + "fieldname": "read_only_depends_on", + "fieldtype": "Code", + "label": "Read Only Depends On", + "options": "JS" } ], "idx": 1, diff --git a/frappe/database/mariadb/framework_mariadb.sql b/frappe/database/mariadb/framework_mariadb.sql index 23c83e0c62de..79aa279f6f5a 100644 --- a/frappe/database/mariadb/framework_mariadb.sql +++ b/frappe/database/mariadb/framework_mariadb.sql @@ -40,6 +40,8 @@ CREATE TABLE `tabDocField` ( `show_preview_popup` int(1) NOT NULL DEFAULT 0, `trigger` varchar(255) DEFAULT NULL, `collapsible_depends_on` text, + `mandatory_depends_on` text, + `read_only_depends_on` text, `depends_on` text, `permlevel` int(11) NOT NULL DEFAULT 0, `ignore_user_permissions` int(1) NOT NULL DEFAULT 0, diff --git a/frappe/database/postgres/framework_postgres.sql b/frappe/database/postgres/framework_postgres.sql index 03dcfdc8ef99..519971b060a8 100644 --- a/frappe/database/postgres/framework_postgres.sql +++ b/frappe/database/postgres/framework_postgres.sql @@ -40,6 +40,8 @@ CREATE TABLE "tabDocField" ( "show_preview_popup" smallint NOT NULL DEFAULT 0, "trigger" varchar(255) DEFAULT NULL, "collapsible_depends_on" text, + "mandatory_depends_on" text, + "read_only_depends_on" text, "depends_on" text, "permlevel" bigint NOT NULL DEFAULT 0, "ignore_user_permissions" smallint NOT NULL DEFAULT 0, diff --git a/frappe/database/postgres/setup_db.py b/frappe/database/postgres/setup_db.py index 01a97178f9dc..0099139cf1a2 100644 --- a/frappe/database/postgres/setup_db.py +++ b/frappe/database/postgres/setup_db.py @@ -1,7 +1,7 @@ import frappe, subprocess, os from six.moves import input -def setup_database(force, source_sql, verbose): +def setup_database(force, source_sql=None, verbose=False): root_conn = get_root_connection() root_conn.commit() root_conn.sql("DROP DATABASE IF EXISTS `{0}`".format(frappe.conf.db_name)) @@ -16,10 +16,12 @@ def setup_database(force, source_sql, verbose): subprocess_env = os.environ.copy() subprocess_env['PGPASSWORD'] = str(frappe.conf.db_password) # bootstrap db + if not source_sql: + source_sql = os.path.join(os.path.dirname(__file__), 'framework_postgres.sql') + subprocess.check_output([ 'psql', frappe.conf.db_name, '-h', frappe.conf.db_host or 'localhost', '-U', - frappe.conf.db_name, '-f', - os.path.join(os.path.dirname(__file__), 'framework_postgres.sql') + frappe.conf.db_name, '-f', source_sql ], env=subprocess_env) frappe.connect() @@ -49,4 +51,4 @@ def get_root_connection(root_login=None, root_password=None): frappe.local.flags.root_connection = frappe.database.get_db(user=root_login, password=root_password) - return frappe.local.flags.root_connection + return frappe.local.flags.root_connection \ No newline at end of file diff --git a/frappe/installer.py b/frappe/installer.py index cfd1d5f85fcc..39961b864323 100755 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -27,7 +27,8 @@ def install_db(root_login="root", root_password=None, db_name=None, source_sql=N if not db_type: db_type = frappe.conf.db_type or 'mariadb' - make_conf(db_name, site_config=site_config, db_password=db_password, db_type=db_type) + make_conf(db_name, site_config=site_config, db_password=db_password, db_type=db_type, + db_host=db_host, db_port=db_port) frappe.flags.in_install_db = True frappe.flags.root_login = root_login @@ -190,14 +191,14 @@ def init_singles(): doc.flags.ignore_validate=True doc.save() -def make_conf(db_name=None, db_password=None, site_config=None, db_type=None): +def make_conf(db_name=None, db_password=None, site_config=None, db_type=None, db_host=None, db_port=None): site = frappe.local.site - make_site_config(db_name, db_password, site_config, db_type=db_type) + make_site_config(db_name, db_password, site_config, db_type=db_type, db_host=db_host, db_port=db_port) sites_path = frappe.local.sites_path frappe.destroy() frappe.init(site, sites_path=sites_path) -def make_site_config(db_name=None, db_password=None, site_config=None, db_type=None): +def make_site_config(db_name=None, db_password=None, site_config=None, db_type=None, db_host=None, db_port=None): frappe.create_folder(os.path.join(frappe.local.site_path)) site_file = get_site_config_path() @@ -208,6 +209,12 @@ def make_site_config(db_name=None, db_password=None, site_config=None, db_type=N if db_type: site_config['db_type'] = db_type + if db_host: + site_config['db_host'] = db_host + + if db_port: + site_config['db_port'] = db_port + with open(site_file, "w") as f: f.write(json.dumps(site_config, indent=1, sort_keys=True)) diff --git a/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py b/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py index c152d867da34..14d8a3af97e0 100644 --- a/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py +++ b/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py @@ -9,7 +9,7 @@ import os from frappe import _ from frappe.model.document import Document -from frappe.integrations.offsite_backup_utils import get_latest_backup_file, send_email, validate_file_size +from frappe.integrations.offsite_backup_utils import get_latest_backup_file, send_email, validate_file_size, get_chunk_site from frappe.integrations.utils import make_post_request from frappe.utils import (cint, get_request_site_address, get_files_path, get_backups_path, get_url, encode) @@ -95,9 +95,12 @@ def backup_to_dropbox(upload_db_backup=True): if frappe.flags.create_new_backup: backup = new_backup(ignore_files=True) filename = os.path.join(get_backups_path(), os.path.basename(backup.backup_path_db)) + site_config = os.path.join(get_backups_path(), os.path.basename(backup.site_config_backup_path)) else: - filename = get_latest_backup_file() + filename, site_config = get_latest_backup_file() + upload_file_to_dropbox(filename, "/database", dropbox_client) + upload_file_to_dropbox(site_config, "/database", dropbox_client) # delete older databases if dropbox_settings['no_of_backups']: @@ -162,8 +165,9 @@ def upload_file_to_dropbox(filename, folder, dropbox_client): return create_folder_if_not_exists(folder, dropbox_client) - chunk_size = 15 * 1024 * 1024 file_size = os.path.getsize(encode(filename)) + chunk_size = get_chunk_site(file_size) + mode = (dropbox.files.WriteMode.overwrite) f = open(encode(filename), 'rb') @@ -345,4 +349,4 @@ def generate_oauth2_access_token_from_oauth1_token(dropbox_settings=None): "oauth1_token_secret": dropbox_settings["access_secret"] } - return make_post_request(url, auth=auth, headers=headers, data=json.dumps(data)) + return make_post_request(url, auth=auth, headers=headers, data=json.dumps(data)) \ No newline at end of file diff --git a/frappe/integrations/doctype/google_drive/google_drive.py b/frappe/integrations/doctype/google_drive/google_drive.py index 036f7906e342..416de355bd28 100644 --- a/frappe/integrations/doctype/google_drive/google_drive.py +++ b/frappe/integrations/doctype/google_drive/google_drive.py @@ -19,7 +19,7 @@ from frappe.utils import get_backups_path, get_bench_path from frappe.utils.backups import new_backup from frappe.integrations.doctype.google_settings.google_settings import get_auth_url -from frappe.integrations.offsite_backup_utils import get_latest_backup_file, send_email, validate_file_size +from frappe.integrations.offsite_backup_utils import get_latest_backup_file, send_email, validate_file_size, backup_files SCOPES = "https://www.googleapis.com/auth/drive" @@ -189,14 +189,22 @@ def upload_system_backup_to_google_drive(): if frappe.flags.create_new_backup: set_progress(1, "Backing up Data.") backup = new_backup() + file_urls = [] + file_urls.append(backup.backup_path_db) + file_urls.append(backup.site_config_backup_path) - fileurl_backup = os.path.basename(backup.backup_path_db) - fileurl_public_files = os.path.basename(backup.backup_path_files) - fileurl_private_files = os.path.basename(backup.backup_path_private_files) + if account.file_backup: + file_urls.append(backup.backup_path_files) + file_urls.append(backup.backup_path_private_files) else: - fileurl_backup, fileurl_public_files, fileurl_private_files = get_latest_backup_file(with_files=True) + if account.file_backup: + backup_files() + file_urls = get_latest_backup_file(with_files=account.file_backup) + + for fileurl in file_urls: + if not fileurl: + continue - for fileurl in [fileurl_backup, fileurl_public_files, fileurl_private_files]: file_metadata = { "name": fileurl, "parents": [account.backup_folder_id] @@ -205,7 +213,7 @@ def upload_system_backup_to_google_drive(): try: media = MediaFileUpload(get_absolute_path(filename=fileurl), mimetype="application/gzip", resumable=True) except IOError as e: - frappe.throw(_("Google Drive - Could not locate locate - {0}").format(e)) + frappe.throw(_("Google Drive - Could not locate - {0}").format(e)) try: set_progress(2, "Uploading backup to Google Drive.") @@ -219,15 +227,17 @@ def upload_system_backup_to_google_drive(): return _("Google Drive Backup Successful.") def daily_backup(): - if frappe.db.get_single_value("Google Drive", "frequency") == "Daily": + drive_settings = frappe.db.get_singles_dict('Google Drive') + if drive_settings.enable and drive_settings.frequency == "Daily": upload_system_backup_to_google_drive() def weekly_backup(): - if frappe.db.get_single_value("Google Drive", "frequency") == "Weekly": + drive_settings = frappe.db.get_singles_dict('Google Drive') + if drive_settings.enable and drive_settings.frequency == "Weekly": upload_system_backup_to_google_drive() def get_absolute_path(filename): - file_path = os.path.join(get_backups_path()[2:], filename) + file_path = os.path.join(get_backups_path()[2:], os.path.basename(filename)) return "{0}/sites/{1}".format(get_bench_path(), file_path) def set_progress(progress, message): diff --git a/frappe/integrations/doctype/google_settings/google_settings.json b/frappe/integrations/doctype/google_settings/google_settings.json index 086c56c020ef..1cd5154f9053 100644 --- a/frappe/integrations/doctype/google_settings/google_settings.json +++ b/frappe/integrations/doctype/google_settings/google_settings.json @@ -8,7 +8,9 @@ "client_id", "client_secret", "sb_01", - "api_key" + "api_key", + "column_break_7", + "geocode_api_key" ], "fields": [ { @@ -32,7 +34,7 @@ { "description": "Used For Google Maps Integration.", "fieldname": "api_key", - "fieldtype": "Data", + "fieldtype": "Password", "label": "API Key" }, { @@ -45,11 +47,20 @@ "depends_on": "enable", "fieldname": "sb_01", "fieldtype": "Section Break", - "label": "API Key" + "label": "Google Maps" + }, + { + "fieldname": "column_break_7", + "fieldtype": "Column Break" + }, + { + "fieldname": "geocode_api_key", + "fieldtype": "Password", + "label": "Geocode API Key" } ], "issingle": 1, - "modified": "2019-08-06 22:37:41.699703", + "modified": "2021-06-04 01:06:28.551912", "modified_by": "Administrator", "module": "Integrations", "name": "Google Settings", diff --git a/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.json b/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.json index 2f57b07835a1..3864c911bc27 100755 --- a/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.json +++ b/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.json @@ -1,167 +1,146 @@ { - "creation": "2017-09-04 20:57:20.129205", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "enabled", - "api_access_section", - "access_key_id", - "column_break_4", - "secret_access_key", - "notification_section", - "notify_email", - "column_break_8", - "send_email_for_successful_backup", - "s3_bucket_details_section", - "bucket", - "endpoint_url", - "column_break_13", - "region", - "backup_details_section", - "frequency", - "backup_files", - "column_break_18", - "backup_limit" - ], - "fields": [ - { - "default": "0", - "fieldname": "enabled", - "fieldtype": "Check", - "label": "Enable Automatic Backup" - }, - { - "fieldname": "notify_email", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Send Notifications To", - "reqd": 1 - }, - { - "default": "1", - "description": "Note: By default emails for failed backups are sent.", - "fieldname": "send_email_for_successful_backup", - "fieldtype": "Check", - "label": "Send Email for Successful Backup" - }, - { - "fieldname": "frequency", - "fieldtype": "Select", - "in_list_view": 1, - "label": "Backup Frequency", - "options": "Daily\nWeekly\nMonthly\nNone", - "reqd": 1 - }, - { - "fieldname": "access_key_id", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Access Key ID", - "reqd": 1 - }, - { - "fieldname": "secret_access_key", - "fieldtype": "Password", - "in_list_view": 1, - "label": "Access Key Secret", - "reqd": 1 - }, - { - "default": "us-east-1", - "description": "See https://docs.aws.amazon.com/de_de/general/latest/gr/rande.html#s3_region for details.", - "fieldname": "region", - "fieldtype": "Select", - "label": "Region", - "options": "us-east-1\nus-east-2\nus-west-1\nus-west-2\nap-south-1\nap-southeast-1\nap-southeast-2\nap-northeast-1\nap-northeast-2\nap-northeast-3\nca-central-1\ncn-north-1\ncn-northwest-1\neu-central-1\neu-west-1\neu-west-2\neu-west-3\neu-north-1\nsa-east-1" - }, - { - "fieldname": "endpoint_url", - "fieldtype": "Data", - "label": "Endpoint URL" - }, - { - "fieldname": "bucket", - "fieldtype": "Data", - "label": "Bucket Name", - "reqd": 1 - }, - { - "description": "Set to 0 for no limit on the number of backups taken.", - "fieldname": "backup_limit", - "fieldtype": "Int", - "label": "Backup Limit", - "reqd": 1 - }, - { - "depends_on": "enabled", - "fieldname": "api_access_section", - "fieldtype": "Section Break", - "label": "API Access" - }, - { - "fieldname": "column_break_4", - "fieldtype": "Column Break" - }, - { - "depends_on": "enabled", - "fieldname": "notification_section", - "fieldtype": "Section Break", - "label": "Notification" - }, - { - "fieldname": "column_break_8", - "fieldtype": "Column Break" - }, - { - "depends_on": "enabled", - "fieldname": "s3_bucket_details_section", - "fieldtype": "Section Break", - "label": "S3 Bucket Details" - }, - { - "fieldname": "column_break_13", - "fieldtype": "Column Break" - }, - { - "depends_on": "enabled", - "fieldname": "backup_details_section", - "fieldtype": "Section Break", - "label": "Backup Details" - }, - { - "default": "1", - "description": "Backup public and private files along with the database.", - "fieldname": "backup_files", - "fieldtype": "Check", - "label": "Backup Files" - }, - { - "fieldname": "column_break_18", - "fieldtype": "Column Break" - } - ], - "hide_toolbar": 1, - "issingle": 1, - "modified": "2020-04-13 21:15:35.197420", - "modified_by": "Administrator", - "module": "Integrations", - "name": "S3 Backup Settings", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "quick_entry": 1, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1 -} \ No newline at end of file + "creation": "2017-09-04 20:57:20.129205", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "enabled", + "api_access_section", + "access_key_id", + "column_break_4", + "secret_access_key", + "notification_section", + "notify_email", + "column_break_8", + "send_email_for_successful_backup", + "s3_bucket_details_section", + "bucket", + "endpoint_url", + "column_break_13", + "backup_details_section", + "frequency", + "backup_files" + ], + "fields": [ + { + "default": "0", + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enable Automatic Backup" + }, + { + "fieldname": "notify_email", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Send Notifications To", + "reqd": 1 + }, + { + "default": "1", + "description": "By default, emails are only sent for failed backups.", + "fieldname": "send_email_for_successful_backup", + "fieldtype": "Check", + "label": "Send Email for Successful Backup" + }, + { + "fieldname": "frequency", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Backup Frequency", + "options": "Daily\nWeekly\nMonthly\nNone", + "reqd": 1 + }, + { + "fieldname": "access_key_id", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Access Key ID", + "reqd": 1 + }, + { + "fieldname": "secret_access_key", + "fieldtype": "Password", + "in_list_view": 1, + "label": "Access Key Secret", + "reqd": 1 + }, + { + "default": "https://s3.amazonaws.com", + "fieldname": "endpoint_url", + "fieldtype": "Data", + "label": "Endpoint URL" + }, + { + "fieldname": "bucket", + "fieldtype": "Data", + "label": "Bucket Name", + "reqd": 1 + }, + { + "depends_on": "enabled", + "fieldname": "api_access_section", + "fieldtype": "Section Break", + "label": "API Access" + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "depends_on": "enabled", + "fieldname": "notification_section", + "fieldtype": "Section Break", + "label": "Notification" + }, + { + "fieldname": "column_break_8", + "fieldtype": "Column Break" + }, + { + "depends_on": "enabled", + "fieldname": "s3_bucket_details_section", + "fieldtype": "Section Break", + "label": "S3 Bucket Details" + }, + { + "fieldname": "column_break_13", + "fieldtype": "Column Break" + }, + { + "depends_on": "enabled", + "fieldname": "backup_details_section", + "fieldtype": "Section Break", + "label": "Backup Details" + }, + { + "default": "1", + "description": "Backup public and private files along with the database.", + "fieldname": "backup_files", + "fieldtype": "Check", + "label": "Backup Files" + } + ], + "hide_toolbar": 1, + "issingle": 1, + "modified": "2020-12-07 15:30:55.047689", + "modified_by": "Administrator", + "module": "Integrations", + "name": "S3 Backup Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 + } \ No newline at end of file diff --git a/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.py b/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.py index 0b951d99e9f1..80282deb3160 100755 --- a/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.py +++ b/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.py @@ -19,8 +19,12 @@ class S3BackupSettings(Document): def validate(self): + if not self.enabled: + return + if not self.endpoint_url: self.endpoint_url = 'https://s3.amazonaws.com' + conn = boto3.client( 's3', aws_access_key_id=self.access_key_id, @@ -28,19 +32,21 @@ def validate(self): endpoint_url=self.endpoint_url ) - bucket_lower = str(self.bucket) - try: - conn.list_buckets() - - except ClientError: - frappe.throw(_("Invalid Access Key ID or Secret Access Key.")) - - try: - conn.create_bucket(Bucket=bucket_lower, CreateBucketConfiguration={ - 'LocationConstraint': self.region}) - except ClientError: - frappe.throw(_("Unable to create bucket: {0}. Change it to a more unique name.").format(bucket_lower)) + # Head_bucket returns a 200 OK if the bucket exists and have access to it. + # Requires ListBucket permission + conn.head_bucket(Bucket=self.bucket) + except ClientError as e: + error_code = e.response['Error']['Code'] + bucket_name = frappe.bold(self.bucket) + if error_code == '403': + msg = _("Do not have permission to access bucket {0}.").format(bucket_name) + elif error_code == '404': + msg = _("Bucket {0} not found.").format(bucket_name) + else: + msg = e.args[0] + + frappe.throw(msg) @frappe.whitelist() @@ -109,58 +115,38 @@ def backup_to_s3(): if frappe.flags.create_new_backup: backup = new_backup(ignore_files=False, backup_path_db=None, - backup_path_files=None, backup_path_private_files=None, force=True) + backup_path_files=None, backup_path_private_files=None, force=True) db_filename = os.path.join(get_backups_path(), os.path.basename(backup.backup_path_db)) + site_config = os.path.join(get_backups_path(), os.path.basename(backup.site_config_backup_path)) if backup_files: files_filename = os.path.join(get_backups_path(), os.path.basename(backup.backup_path_files)) private_files = os.path.join(get_backups_path(), os.path.basename(backup.backup_path_private_files)) else: if backup_files: - db_filename, files_filename, private_files = get_latest_backup_file(with_files=backup_files) + db_filename, site_config, files_filename, private_files = get_latest_backup_file(with_files=backup_files) else: - db_filename = get_latest_backup_file() + db_filename, site_config = get_latest_backup_file() + folder = os.path.basename(db_filename)[:15] + '/' # for adding datetime to folder name upload_file_to_s3(db_filename, folder, conn, bucket) + upload_file_to_s3(site_config, folder, conn, bucket) + if backup_files: - upload_file_to_s3(private_files, folder, conn, bucket) - upload_file_to_s3(files_filename, folder, conn, bucket) - delete_old_backups(doc.backup_limit, bucket) + if private_files: + upload_file_to_s3(private_files, folder, conn, bucket) + + if files_filename: + upload_file_to_s3(files_filename, folder, conn, bucket) def upload_file_to_s3(filename, folder, conn, bucket): destpath = os.path.join(folder, os.path.basename(filename)) try: print("Uploading file:", filename) - conn.upload_file(filename, bucket, destpath) + conn.upload_file(filename, bucket, destpath) # Requires PutObject permission except Exception as e: frappe.log_error() - print("Error uploading: %s" % (e)) - - -def delete_old_backups(limit, bucket): - all_backups = [] - doc = frappe.get_single("S3 Backup Settings") - backup_limit = int(limit) - - s3 = boto3.resource( - 's3', - aws_access_key_id=doc.access_key_id, - aws_secret_access_key=doc.get_password('secret_access_key'), - endpoint_url=doc.endpoint_url or 'https://s3.amazonaws.com' - ) - bucket = s3.Bucket(bucket) - objects = bucket.meta.client.list_objects_v2(Bucket=bucket.name, Delimiter='/') - if objects: - for obj in objects.get('CommonPrefixes'): - all_backups.append(obj.get('Prefix')) - - oldest_backup = sorted(all_backups)[0] - - if len(all_backups) > backup_limit: - print("Deleting Backup: {0}".format(oldest_backup)) - for obj in bucket.objects.filter(Prefix=oldest_backup): - # delete all keys that are inside the oldest_backup - s3.Object(bucket.name, obj.key).delete() + print("Error uploading: %s" % (e)) \ No newline at end of file diff --git a/frappe/integrations/offsite_backup_utils.py b/frappe/integrations/offsite_backup_utils.py index 7e80cb68c48a..86da376caf56 100644 --- a/frappe/integrations/offsite_backup_utils.py +++ b/frappe/integrations/offsite_backup_utils.py @@ -6,32 +6,38 @@ import frappe import glob import os -from frappe.utils import split_emails, get_backups_path +from frappe.utils import split_emails, now_datetime, cint def send_email(success, service_name, doctype, email_field, error_status=None): recipients = get_recipients(doctype, email_field) if not recipients: - frappe.log_error("No Email Recipient found for {0}".format(service_name), - "{0}: Failed to send backup status email".format(service_name)) + frappe.log_error( + "No Email Recipient found for {0}".format(service_name), + "{0}: Failed to send backup status email".format(service_name), + ) return if success: - if not frappe.db.get_value(doctype, None, "send_email_for_successful_backup"): + if not frappe.db.get_single_value(doctype, "send_email_for_successful_backup"): return subject = "Backup Upload Successful" message = """ -

Backup Uploaded Successfully!

-

Hi there, this is just to inform you that your backup was successfully uploaded to your {0} bucket. So relax!

""".format(service_name) +

Backup Uploaded Successfully!

+

Hi there, this is just to inform you that your backup was successfully uploaded to your {0} bucket. So relax!

""".format( + service_name + ) else: subject = "[Warning] Backup Upload Failed" message = """ -

Backup Upload Failed!

-

Oops, your automated backup to {0} failed.

-

Error message: {1}

-

Please contact your system manager for more information.

""".format(service_name, error_status) +

Backup Upload Failed!

+

Oops, your automated backup to {0} failed.

+

Error message: {1}

+

Please contact your system manager for more information.

""".format( + service_name, error_status + ) frappe.sendmail(recipients=recipients, subject=subject, message=message) @@ -44,28 +50,31 @@ def get_recipients(doctype, email_field): def get_latest_backup_file(with_files=False): - - def get_latest(file_ext): - file_list = glob.glob(os.path.join(get_backups_path(), file_ext)) - return max(file_list, key=os.path.getctime) - - latest_file = get_latest('*.sql.gz') + from frappe.utils.backups import BackupGenerator + + 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, + ) + database, public, private, config = odb.get_recent_backup(older_than=24 * 30) if with_files: - latest_public_file_bak = get_latest('*-files.tar') - latest_private_file_bak = get_latest('*-private-files.tar') - return latest_file, latest_public_file_bak, latest_private_file_bak + return database, config, public, private - return latest_file + return database, config def get_file_size(file_path, unit): if not unit: - unit = 'MB' + unit = "MB" file_size = os.path.getsize(file_path) - memory_size_unit_mapper = {'KB': 1, 'MB': 2, 'GB': 3, 'TB': 4} + memory_size_unit_mapper = {"KB": 1, "MB": 2, "GB": 3, "TB": 4} i = 0 while i < memory_size_unit_mapper[unit]: file_size = file_size / 1000.0 @@ -73,11 +82,44 @@ def get_file_size(file_path, unit): return file_size +def get_chunk_site(file_size): + ''' this function will return chunk size in megabytes based on file size ''' + + file_size_in_gb = cint(file_size/1024/1024) + + MB = 1024 * 1024 + if file_size_in_gb > 5000: + return 200 * MB + elif file_size_in_gb >= 3000: + return 150 * MB + elif file_size_in_gb >= 1000: + return 100 * MB + elif file_size_in_gb >= 500: + return 50 * MB + else: + return 15 * MB def validate_file_size(): frappe.flags.create_new_backup = True - latest_file = get_latest_backup_file() - file_size = get_file_size(latest_file, unit='GB') + latest_file, site_config = get_latest_backup_file() + file_size = get_file_size(latest_file, unit="GB") if file_size > 1: frappe.flags.create_new_backup = False + +def backup_files(): + """Only zips and places public and private files in backup folder""" + from frappe.utils.backups import BackupGenerator + + 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.todays_date = now_datetime().strftime('%Y%m%d_%H%M%S') + odb.set_backup_file_name() + odb.zip_files() \ No newline at end of file diff --git a/frappe/public/js/frappe/form/layout.js b/frappe/public/js/frappe/form/layout.js index 1afc095f91ea..d4d79ddd5a18 100644 --- a/frappe/public/js/frappe/form/layout.js +++ b/frappe/public/js/frappe/form/layout.js @@ -452,27 +452,27 @@ frappe.ui.form.Layout = Class.extend({ // build dependants' dictionary var has_dep = false; - for(var fkey in this.fields_list) { + for (var fkey in this.fields_list) { var f = this.fields_list[fkey]; f.dependencies_clear = true; - if(f.df.depends_on) { + if (f.df.depends_on || f.df.mandatory_depends_on || f.df.read_only_depends_on) { has_dep = true; } } - if(!has_dep)return; + if (!has_dep) return; // show / hide based on values - for(var i=me.fields_list.length-1;i>=0;i--) { + for (var i=me.fields_list.length-1;i>=0;i--) { var f = me.fields_list[i]; f.guardian_has_value = true; - if(f.df.depends_on) { + if (f.df.depends_on) { // evaluate guardian f.guardian_has_value = this.evaluate_depends_on_value(f.df.depends_on); // show / hide - if(f.guardian_has_value) { + if (f.guardian_has_value) { if(f.df.hidden_due_to_dependency) { f.df.hidden_due_to_dependency = false; f.refresh(); @@ -484,10 +484,39 @@ frappe.ui.form.Layout = Class.extend({ } } } + + if (f.df.mandatory_depends_on) { + this.set_dependant_property(f.df.mandatory_depends_on, f.df.fieldname, 'reqd'); + } + + if (f.df.read_only_depends_on) { + this.set_dependant_property(f.df.read_only_depends_on, f.df.fieldname, 'read_only'); + } } this.refresh_section_count(); }, + set_dependant_property: function(condition, fieldname, property) { + let set_property = this.evaluate_depends_on_value(condition); + let value = set_property ? 1 : 0; + let form_obj; + if (this.frm) { + form_obj = this.frm; + } else if (this.is_dialog || this.doctype === 'Web Form') { + form_obj = this; + } + if (form_obj) { + if (this.doc && this.doc.parent && this.doc.parentfield) { + form_obj.setting_dependency = true; + form_obj.set_df_property(this.doc.parentfield, property, value, this.doc.parent, fieldname, this.doc.name); + form_obj.setting_dependency = false; + // refresh child fields + this.fields_dict[fieldname] && this.fields_dict[fieldname].refresh(); + } else { + form_obj.set_df_property(fieldname, property, value); + } + } + }, evaluate_depends_on_value: function(expression) { var out = null; var doc = this.doc; @@ -500,7 +529,7 @@ frappe.ui.form.Layout = Class.extend({ return; } - var parent = this.frm ? this.frm.doc : null; + var parent = this.frm ? this.frm.doc : this.doc || null; if(typeof(expression) === 'boolean') { out = expression; diff --git a/frappe/public/js/frappe/form/toolbar.js b/frappe/public/js/frappe/form/toolbar.js index 85628791559f..5f5c0f075657 100644 --- a/frappe/public/js/frappe/form/toolbar.js +++ b/frappe/public/js/frappe/form/toolbar.js @@ -41,6 +41,12 @@ frappe.ui.form.Toolbar = Class.extend({ var title = this.frm.docname; } + this.page.$sub_title_area.css("cursor", "copy"); + this.page.$sub_title_area.on('click', (event) => { + event.stopImmediatePropagation(); + frappe.utils.copy_to_clipboard(this.frm.docname); + }); + var me = this; title = __(title); this.page.set_title(title); @@ -52,6 +58,7 @@ frappe.ui.form.Toolbar = Class.extend({ this.set_indicator(); }, + is_title_editable: function() { let title_field = this.frm.meta.title_field; let doc_field = this.frm.get_docfield(title_field); diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js index 85da08ae9de9..99858fb4a3b5 100644 --- a/frappe/public/js/frappe/utils/utils.js +++ b/frappe/public/js/frappe/utils/utils.js @@ -2,7 +2,7 @@ // MIT License. See license.txt import deep_equal from "fast-deep-equal"; -import { parsePhoneNumberFromString } from "libphonenumber-js"; +import { parsePhoneNumber } from "libphonenumber-js"; frappe.provide('frappe.utils'); @@ -621,7 +621,7 @@ Object.assign(frappe.utils, { format_phone: function(value, doc) { let country_code = frappe.boot.default_country.code; - if (frappe.meta.has_field(doc.doctype, "country_code")) { + if (Object.prototype.hasOwnProperty.call(doc, "country_code")) { country_code = doc.country_code; } @@ -630,8 +630,8 @@ Object.assign(frappe.utils, { value = "+" + value; } - let formatted = parsePhoneNumberFromString(value, country_code.toUpperCase()); - return formatted ? formatted.formatInternational() : formatted; + let formatted = parsePhoneNumber(value, country_code.toUpperCase()); + return formatted.isValid() ? formatted.formatInternational() : false; }, supportsES6: function() { diff --git a/frappe/tests/ui_test_helpers.py b/frappe/tests/ui_test_helpers.py index 7442bf3d4647..9a8e2ce76b59 100644 --- a/frappe/tests/ui_test_helpers.py +++ b/frappe/tests/ui_test_helpers.py @@ -75,6 +75,23 @@ def create_contact_phone_nos_records(): doc.append('phone_nos', {'phone': '123456{}'.format(index)}) doc.insert() +@frappe.whitelist() +def create_doctype(name, fields): + fields = frappe.parse_json(fields) + if frappe.db.exists('DocType', name): + return + frappe.get_doc({ + "doctype": "DocType", + "module": "Core", + "custom": 1, + "fields": fields, + "permissions": [{ + "role": "System Manager", + "read": 1 + }], + "name": name + }).insert() + @frappe.whitelist() def create_contact_records(): if frappe.db.get_all('Contact', {'first_name': 'Test Form Contact 1'}): @@ -92,3 +109,4 @@ def insert_contact(first_name, phone_number): }) doc.append('phone_nos', {'phone': phone_number}) doc.insert() + diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index 1545c9b05445..008f2eb5ab78 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -6,7 +6,10 @@ from __future__ import print_function, unicode_literals import os +import json +from calendar import timegm from datetime import datetime +from glob import glob import frappe from frappe import _, conf @@ -20,21 +23,34 @@ class BackupGenerator: """ 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" """ 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=3306, verbose=False): + backup_path_private_files=None, db_host="localhost", db_port=None, verbose=False, + db_type='mariadb'): global _verbose self.db_host = db_host - self.db_port = db_port or 3306 + self.db_port = db_port self.db_name = db_name + self.db_type = db_type self.user = user self.password = password self.backup_path_files = backup_path_files self.backup_path_db = backup_path_db self.backup_path_private_files = backup_path_private_files + + if not self.db_type: + 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 + + site = frappe.local.site or frappe.generate_hash(length=8) + self.site_slug = site.replace('.', '_') + self.verbose = verbose _verbose = verbose @@ -46,15 +62,18 @@ def get_backup(self, older_than=24, ignore_files=False, force=False): #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 = 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 = 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') if not (self.backup_path_files and self.backup_path_db and self.backup_path_private_files): self.set_backup_file_name() - if not (last_db and last_file and last_private_file): + if not (last_db and last_file and last_private_file and site_config_backup_path): self.take_dump() + self.copy_site_config() if not ignore_files: self.zip_files() @@ -62,16 +81,13 @@ def get_backup(self, older_than=24, ignore_files=False, force=False): self.backup_path_files = last_file self.backup_path_db = last_db self.backup_path_private_files = last_private_file + self.site_config_backup_path = site_config_backup_path def set_backup_file_name(self): - todays_date = now_datetime().strftime('%Y%m%d_%H%M%S') - site = frappe.local.site or frappe.generate_hash(length=8) - site = site.replace('.', '_') - #Generate a random name using today's date and a 8 digit random number - for_db = todays_date + "-" + site + "-database.sql.gz" - for_public_files = todays_date + "-" + site + "-files.tar" - for_private_files = todays_date + "-" + site + "-private-files.tar" + for_db = self.todays_date + "-" + self.site_slug + "-database.sql.gz" + for_public_files = self.todays_date + "-" + self.site_slug + "-files.tar" + for_private_files = self.todays_date + "-" + self.site_slug + "-private-files.tar" backup_path = get_backup_path() if not self.backup_path_db: @@ -82,23 +98,47 @@ def set_backup_file_name(self): self.backup_path_private_files = os.path.join(backup_path, for_private_files) def get_recent_backup(self, older_than): - file_list = os.listdir(get_backup_path()) - backup_path_files = None - backup_path_db = None - backup_path_private_files = None + backup_path = get_backup_path() - for this_file in file_list: - this_file = cstr(this_file) - this_file_path = os.path.join(get_backup_path(), this_file) - if not is_file_old(this_file_path, older_than): - if "_private_files" in this_file_path: - backup_path_private_files = this_file_path - elif "_files" in this_file_path: - backup_path_files = this_file_path - elif "_database" in this_file_path: - backup_path_db = this_file_path + file_type_slugs = { + "database": "*-{}-database.sql.gz", + "public": "*-{}-files.tar", + "private": "*-{}-private-files.tar", + "config": "*-{}-site_config_backup.json", + } + + def backup_time(file_path): + file_name = file_path.split(os.sep)[-1] + file_timestamp = file_name.split("-")[0] + return timegm(datetime.strptime(file_timestamp, "%Y%m%d_%H%M%S").utctimetuple()) + + def get_latest(file_pattern): + file_pattern = os.path.join(backup_path, file_pattern.format(self.site_slug)) + file_list = glob(file_pattern) + if file_list: + return max(file_list, key=backup_time) + + def old_enough(file_path): + if file_path: + if not os.path.isfile(file_path) or is_file_old(file_path, older_than): + return None + return file_path + + latest_backups = { + file_type: get_latest(pattern) + for file_type, pattern in file_type_slugs.items() + } + + recent_backups = { + file_type: old_enough(file_name) for file_type, file_name in latest_backups.items() + } - return (backup_path_db, backup_path_files, backup_path_private_files) + return ( + recent_backups.get("database"), + recent_backups.get("public"), + recent_backups.get("private"), + recent_backups.get("config"), + ) def zip_files(self): for folder in ("public", "private"): @@ -111,6 +151,21 @@ def zip_files(self): if self.verbose: print('Backed up files', os.path.abspath(backup_path)) + def copy_site_config(self): + site_config_backup_path = os.path.join( + get_backup_path(), + "{time_stamp}-{site_slug}-site_config_backup.json".format( + time_stamp=self.todays_date, + site_slug=self.site_slug)) + site_config_path = os.path.join(frappe.get_site_path(), "site_config.json") + site_config = {} + if os.path.exists(site_config_path): + site_config.update(frappe.get_file_json(site_config_path)) + with open(site_config_backup_path, "w") as f: + f.write(json.dumps(site_config, indent=2)) + f.flush() + self.site_config_backup_path = site_config_backup_path + def take_dump(self): import frappe.utils @@ -119,6 +174,17 @@ def take_dump(self): 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') + ) + err, out = frappe.utils.execute_in_shell(cmd_string) def send_email(self): @@ -132,16 +198,13 @@ def send_email(self): files_backup_url = get_url(os.path.join('backups', os.path.basename(self.backup_path_files))) msg = """Hello, - -Your backups are ready to be downloaded. - -1. [Click here to download the database backup](%(db_backup_url)s) -2. [Click here to download the files backup](%(files_backup_url)s) - -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 + Your backups are ready to be downloaded. + 1. [Click here to download the database backup](%(db_backup_url)s) + 2. [Click here to download the files backup](%(files_backup_url)s) + 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 } datetime_str = datetime.fromtimestamp(os.stat(self.backup_path_db).st_ctime) @@ -158,12 +221,40 @@ def get_backup(): 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) + 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.whitelist() +def fetch_latest_backups(): + """Fetches paths of the latest backup taken in the last 30 days + Only for: System Managers + Returns: + dict: relative Backup Paths + """ + frappe.only_for("System Manager") + 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, + ) + 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_db=None, backup_path_files=None, backup_path_private_files=None, force=False, verbose=False): """this function is called from scheduler deletes backups older than 7 days @@ -173,14 +264,14 @@ def scheduled_backup(older_than=6, ignore_files=False, backup_path_db=None, back 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): delete_temp_backups(older_than = frappe.conf.keep_backups_for_hours or 24) - odb = BackupGenerator(db_name=frappe.conf.db_name,\ - user=frappe.local.conf.replica_db_name if frappe.local.conf.different_credentials_for_replica else frappe.conf.db_name, - password=frappe.local.conf.replica_db_password if frappe.local.conf.different_credentials_for_replica else frappe.conf.db_password, + odb = BackupGenerator(frappe.conf.db_name, frappe.conf.db_name, + frappe.conf.db_password, backup_path_db=backup_path_db, backup_path_files=backup_path_files, backup_path_private_files=backup_path_private_files, - db_host = frappe.local.conf.replica_host if frappe.conf.read_from_replica else frappe.db.host, + db_host = frappe.db.host, db_port = frappe.db.port, - verbose = verbose) + db_type = frappe.conf.db_type, + verbose=verbose) odb.get_backup(older_than, ignore_files, force=force) return odb @@ -237,26 +328,39 @@ def backup(with_files=False, backup_path_db=None, backup_path_files=None, quiet= if __name__ == "__main__": """ - is_file_old db_name user password db_host - get_backup db_name user password db_host + 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' + try: + db_type = sys.argv[6] + except IndexError: + pass + + db_port = 3306 + try: + db_port = int(sys.argv[7]) + except IndexError: + pass + if cmd == "is_file_old": - odb = BackupGenerator(sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5] or "localhost") + 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") + 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") + 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") + 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": - delete_temp_backups() + delete_temp_backups() \ No newline at end of file