diff --git a/cypress/integration/list_view_settings.js b/cypress/integration/list_view_settings.js index 51cba94a70..47f8efe94b 100644 --- a/cypress/integration/list_view_settings.js +++ b/cypress/integration/list_view_settings.js @@ -9,7 +9,9 @@ context('List View Settings', () => { cy.get('.sidebar-stat').should('contain', "Tags"); }); it('disable count and sidebar stats then verify', () => { + cy.wait(300); cy.visit('/desk#List/DocType/List'); + cy.wait(300); cy.get('.list-count').should('contain', "20 of"); cy.get('button').contains('Menu').click(); cy.get('.dropdown-menu li').filter(':visible').contains('Settings').click(); diff --git a/cypress/integration/login.js b/cypress/integration/login.js index 3f13130b58..861377444c 100644 --- a/cypress/integration/login.js +++ b/cypress/integration/login.js @@ -21,6 +21,15 @@ context('Login', () => { cy.location('pathname').should('eq', '/login'); }); + it('shows invalid login if incorrect credentials', () => { + cy.get('#login_email').type('Administrator'); + cy.get('#login_password').type('qwer'); + + cy.get('.btn-login').click(); + cy.get('.page-card-head').contains('Invalid Login. Try again.'); + cy.location('pathname').should('eq', '/login'); + }); + it('logs in using correct credentials', () => { cy.get('#login_email').type('Administrator'); cy.get('#login_password').type(Cypress.config('adminPassword')); @@ -30,12 +39,30 @@ context('Login', () => { cy.window().its('frappe.session.user').should('eq', 'Administrator'); }); - it('shows invalid login if incorrect credentials', () => { + it('check redirect after login', () => { + + // mock for OAuth 2.0 client_id, redirect_uri, scope and state + const payload = new URLSearchParams({ + uuid: '6fed1519-cfd8-4a2d-84a6-9a1799c7c741', + encoded_string: 'hello all', + encoded_url: 'http://test.localhost/callback', + base64_string: 'aGVsbG8gYWxs' + }); + + cy.request('/api/method/logout'); + + // redirect-to /me page with params to mock OAuth 2.0 like request + cy.visit( + '/login?redirect-to=/me?' + + encodeURIComponent(payload.toString().replace("+", " ")) + ); + cy.get('#login_email').type('Administrator'); - cy.get('#login_password').type('qwer'); + cy.get('#login_password').type(Cypress.config('adminPassword')); cy.get('.btn-login').click(); - cy.get('.page-card-head').contains('Invalid Login. Try again.'); - cy.location('pathname').should('eq', '/login'); + + // verify redirected location and url params after login + cy.url().should('include', '/me?' + payload.toString().replace('+', '%20')); }); }); diff --git a/frappe/__init__.py b/frappe/__init__.py index eae8b0d76f..f0b6bfe41b 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -345,7 +345,7 @@ def msgprint(msg, title=None, raise_exception=0, as_table=False, indicator=None, style="margin: 0;">{}'''.format(table_rows) if flags.print_messages and out.message: - print("Message: " + repr(out.message).encode("utf-8")) + print(f"Message: {repr(out.message).encode('utf-8')}") if title: out.title = title diff --git a/frappe/app.py b/frappe/app.py index 41798b0bc4..3bb764149b 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -26,6 +26,7 @@ from frappe.core.doctype.comment.comment import update_comments_in_parent_after_ from frappe import _ import frappe.recorder import frappe.monitor +import frappe.rate_limiter local_manager = LocalManager([frappe.local]) @@ -54,6 +55,7 @@ def application(request): frappe.recorder.record() frappe.monitor.start() + frappe.rate_limiter.apply() if frappe.local.form_dict.cmd: response = frappe.handler.handle() @@ -93,9 +95,13 @@ def application(request): if response and hasattr(frappe.local, 'cookie_manager'): frappe.local.cookie_manager.flush_cookies(response=response) + frappe.rate_limiter.update() frappe.monitor.stop(response) frappe.recorder.dump() + if response and hasattr(frappe.local, 'rate_limiter'): + response.headers.extend(frappe.local.rate_limiter.headers()) + frappe.destroy() return response @@ -171,6 +177,9 @@ def handle_exception(e): http_status_code=http_status_code, indicator_color='red') return_as_message = True + elif http_status_code == 429: + response = frappe.rate_limiter.respond() + else: traceback = "
" + sanitize_html(frappe.get_traceback()) + "
" if frappe.local.flags.disable_traceback: diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.py b/frappe/automation/doctype/auto_repeat/auto_repeat.py index bfcaf684d6..c447c55727 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.py @@ -299,17 +299,20 @@ def get_next_schedule_date(schedule_date, frequency, start_date, repeat_on_day=N next_date = get_next_date(start_date, month_count) else: days = 7 if frequency == 'Weekly' else 1 - next_date = add_days(start_date, days) + next_date = add_days(schedule_date, days) # next schedule date should be after or on current date if not for_full_schedule: while getdate(next_date) < getdate(today()): if month_count: month_count += month_map.get(frequency) - next_date = get_next_date(start_date, month_count, day_count) + next_date = get_next_date(start_date, month_count, day_count) + elif days: + next_date = add_days(next_date, days) return next_date + def get_next_date(dt, mcount, day=None): dt = getdate(dt) dt += relativedelta(months=mcount, day=day) diff --git a/frappe/automation/doctype/auto_repeat/test_auto_repeat.py b/frappe/automation/doctype/auto_repeat/test_auto_repeat.py index 769c77b67c..60fa9cb59e 100644 --- a/frappe/automation/doctype/auto_repeat/test_auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/test_auto_repeat.py @@ -99,13 +99,18 @@ class TestAutoRepeat(unittest.TestCase): def test_next_schedule_date(self): current_date = getdate(today()) todo = frappe.get_doc( - dict(doctype='ToDo', description='test next schedule date todo', assigned_by='Administrator')).insert() + dict(doctype='ToDo', description='test next schedule date for monthly', assigned_by='Administrator')).insert() doc = make_auto_repeat(frequency='Monthly', reference_document=todo.name, start_date=add_months(today(), -2)) # next_schedule_date is set as on or after current date # it should not be a previous month's date self.assertTrue((doc.next_schedule_date >= current_date)) + todo = frappe.get_doc( + dict(doctype='ToDo', description='test next schedule date for daily', assigned_by='Administrator')).insert() + doc = make_auto_repeat(frequency='Daily', reference_document=todo.name, start_date=add_days(today(), -2)) + self.assertEqual(getdate(doc.next_schedule_date), current_date) + def make_auto_repeat(**args): args = frappe._dict(args) diff --git a/frappe/boot.py b/frappe/boot.py index 9d5dbe1909..42b5ca38b7 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -107,13 +107,20 @@ def load_desktop_data(bootinfo): bootinfo.allowed_modules = get_modules_from_all_apps_for_user() bootinfo.allowed_workspaces = get_desk_sidebar_items(True) -def get_allowed_pages(): - return get_user_pages_or_reports('Page') +def get_allowed_pages(cache=False): + return get_user_pages_or_reports('Page', cache=cache) -def get_allowed_reports(): - return get_user_pages_or_reports('Report') +def get_allowed_reports(cache=False): + return get_user_pages_or_reports('Report', cache=cache) + +def get_user_pages_or_reports(parent, cache=False): + _cache = frappe.cache() + + if cache: + has_role = _cache.get_value('has_role:' + parent, user=frappe.session.user) + if has_role: + return has_role -def get_user_pages_or_reports(parent): roles = frappe.get_roles() has_role = {} column = get_column(parent) @@ -184,6 +191,8 @@ def get_user_pages_or_reports(parent): for report in reports: has_role[report.name]["report_type"] = report.report_type + # Expire every six hours + _cache.set_value('has_role:' + parent, has_role, frappe.session.user, 21600) return has_role def get_column(doctype): diff --git a/frappe/cache_manager.py b/frappe/cache_manager.py index 2daed59074..4560680653 100644 --- a/frappe/cache_manager.py +++ b/frappe/cache_manager.py @@ -20,7 +20,8 @@ global_cache_keys = ("app_hooks", "installed_apps", user_cache_keys = ("bootinfo", "user_recent", "roles", "user_doc", "lang", "defaults", "user_permissions", "home_page", "linked_with", - "desktop_icons", 'portal_menu_items') + "desktop_icons", 'portal_menu_items', 'user_perm_can_read', + "has_role:Page", "has_role:Report") doctype_cache_keys = ("meta", "form_meta", "table_columns", "last_modified", "linked_doctypes", 'notifications', 'workflow' ,'energy_point_rule_map') diff --git a/frappe/commands/site.py b/frappe/commands/site.py index 52994ccec3..82ed72dd5c 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -256,6 +256,15 @@ def migrate(context, rebuild_website=False, skip_failing=False): print("Compiling Python Files...") compileall.compile_dir('../apps', quiet=1, rx=re.compile('.*node_modules.*')) +@click.command('migrate-to') +@click.argument('frappe_provider') +@pass_context +def migrate_to(context, frappe_provider): + "Migrates site to the specified provider" + from frappe.integrations.frappe_providers import migrate_to + for site in context.sites: + migrate_to(site, frappe_provider) + @click.command('run-patch') @click.argument('module') @pass_context @@ -317,23 +326,25 @@ def use(site, sites_path='.'): if os.path.exists(os.path.join(sites_path, site)): with open(os.path.join(sites_path, "currentsite.txt"), "w") as sitefile: sitefile.write(site) + print("Current Site set to {}".format(site)) else: print("{} does not exist".format(site)) @click.command('backup') @click.option('--with-files', default=False, is_flag=True, help="Take backup with files") +@click.option('--verbose', default=False, is_flag=True) @pass_context def backup(context, with_files=False, backup_path_db=None, backup_path_files=None, - backup_path_private_files=None, quiet=False): + backup_path_private_files=None, quiet=False, verbose=False): "Backup" from frappe.utils.backups import scheduled_backup - verbose = context.verbose + verbose = verbose or context.verbose exit_code = 0 for site in context.sites: try: frappe.init(site=site) frappe.connect() - odb = scheduled_backup(ignore_files=not with_files, backup_path_db=backup_path_db, backup_path_files=backup_path_files, backup_path_private_files=backup_path_private_files, force=True) + odb = scheduled_backup(ignore_files=not with_files, backup_path_db=backup_path_db, backup_path_files=backup_path_files, backup_path_private_files=backup_path_private_files, force=True, verbose=verbose) except Exception as e: if verbose: print("Backup failed for {0}. Database or site_config.json may be corrupted".format(site)) @@ -342,10 +353,12 @@ def backup(context, with_files=False, backup_path_db=None, backup_path_files=Non if verbose: from frappe.utils import now - print("database backup taken -", odb.backup_path_db, "- on", now()) + summary_title = "Backup Summary at {0}".format(now()) + print(summary_title + "\n" + "-" * len(summary_title)) + print("Database backup:", odb.backup_path_db) if with_files: - print("files backup taken -", odb.backup_path_files, "- on", now()) - print("private files backup taken -", odb.backup_path_private_files, "- on", now()) + print("Public files: ", odb.backup_path_files) + print("Private files: ", odb.backup_path_private_files) frappe.destroy() sys.exit(exit_code) @@ -559,6 +572,7 @@ commands = [ install_app, list_apps, migrate, + migrate_to, new_site, reinstall, reload_doc, diff --git a/frappe/config/customization.py b/frappe/config/customization.py index 06eaa2ea00..3d587e6839 100644 --- a/frappe/config/customization.py +++ b/frappe/config/customization.py @@ -3,7 +3,7 @@ from frappe import _ def get_data(): return [ - { + { "label": _("Form Customization"), "icon": "fa fa-glass", "items": [ @@ -57,9 +57,9 @@ def get_data(): }, { "type": "doctype", - "label": _("Custom Tags"), - "name": "Tag Category", - "description": _("Add your own Tag Categories") + "label": _("Package"), + "name": "Package", + "description": _("Import and Export Packages.") } ] } diff --git a/frappe/core/doctype/data_import/importer_new.py b/frappe/core/doctype/data_import/importer_new.py index 3dcb902482..040e9fabc4 100644 --- a/frappe/core/doctype/data_import/importer_new.py +++ b/frappe/core/doctype/data_import/importer_new.py @@ -74,7 +74,6 @@ class Importer: self.read_content(content, extension) self.validate_template_content() - self.remove_empty_rows_and_columns() def read_file(self, file_path): extn = file_path.split(".")[1] @@ -99,6 +98,8 @@ class Importer: elif extension == "xls": data = read_xls_file_from_attached_file(content) + data = self.remove_empty_rows_and_columns(data) + if len(data) <= 1: frappe.throw( _("Import template should contain a Header and atleast one row."), title=error_title @@ -114,42 +115,41 @@ class Importer: _("Number of columns does not match with data"), title=_("Invalid Template") ) - def remove_empty_rows_and_columns(self): + def remove_empty_rows_and_columns(self, raw_data): self.row_index_map = [] removed_rows = [] removed_columns = [] # remove empty rows - data = [] - for i, row in enumerate(self.data): + data_without_empty_rows = [] + for i, row in enumerate(raw_data): if all(v in INVALID_VALUES for v in row): # empty row removed_rows.append(i) else: - data.append(row) + data_without_empty_rows.append(row) self.row_index_map.append(i) # remove empty columns # a column with a header and no data is a valid column # a column with no header and no data will be removed - header_row = [] - for i, column in enumerate(self.header_row): - column_values = [row[i] for row in data] - values = [column] + column_values - if all(v in INVALID_VALUES for v in values): + first_row = data_without_empty_rows[0] + for i, column in enumerate(first_row): + column_values = [row[i] for row in data_without_empty_rows] + if all(v in INVALID_VALUES for v in column_values): # empty column removed_columns.append(i) - else: - header_row.append(column) - data_without_empty_columns = [] - # remove empty columns from data - for i, row in enumerate(data): - new_row = [v for j, v in enumerate(row) if j not in removed_columns] - data_without_empty_columns.append(new_row) + if removed_columns: + data_without_empty_rows_and_columns = [] + # remove empty columns from data + for i, row in enumerate(data_without_empty_rows): + new_row = [v for j, v in enumerate(row) if j not in removed_columns] + data_without_empty_rows_and_columns.append(new_row) + else: + data_without_empty_rows_and_columns = data_without_empty_rows - self.data = data_without_empty_columns - self.header_row = header_row + return data_without_empty_rows_and_columns def get_data_for_import_preview(self): out = frappe._dict() @@ -325,7 +325,7 @@ class Importer: def detect_date_formats(self, columns): for col in columns: - if col.df and col.df.fieldtype in ['Date', 'Time', 'Datetime']: + if col.df and col.df.fieldtype in ["Date", "Time", "Datetime"]: col.date_format = self.guess_date_format_for_column(col, columns) return columns @@ -351,7 +351,16 @@ class Importer: value = cstr(value) # convert boolean values to 0 or 1 - if df.fieldtype == "Check" and value.lower().strip() in ["t", "f", "true", "false", "yes", "no", "y", "n"]: + if df.fieldtype == "Check" and value.lower().strip() in [ + "t", + "f", + "true", + "false", + "yes", + "no", + "y", + "n", + ]: value = value.lower().strip() value = 1 if value in ["t", "true", "y", "yes"] else 0 @@ -398,8 +407,9 @@ class Importer: date_values = [ row[column_index] for row in self.data[:PARSE_ROW_COUNT] if row[column_index] ] - date_formats = [guess_date_format(d) if isinstance(d, str) else None - for d in date_values] + date_formats = [ + guess_date_format(d) if isinstance(d, str) else None for d in date_values + ] if not date_formats: return max_occurred_date_format = max(set(date_formats), key=date_formats.count) @@ -827,9 +837,9 @@ class Importer: id_value = doc[id_fieldname] existing_doc = frappe.get_doc(self.doctype, id_value) existing_doc.flags.updater_reference = { - 'doctype': self.data_import.doctype, - 'docname': self.data_import.name, - 'label': _('via Data Import') + "doctype": self.data_import.doctype, + "docname": self.data_import.name, + "label": _("via Data Import"), } existing_doc.update(doc) existing_doc.save() diff --git a/frappe/core/doctype/data_import_beta/data_import_beta.js b/frappe/core/doctype/data_import_beta/data_import_beta.js index 82c490c524..527dbd7d0c 100644 --- a/frappe/core/doctype/data_import_beta/data_import_beta.js +++ b/frappe/core/doctype/data_import_beta/data_import_beta.js @@ -177,8 +177,8 @@ frappe.ui.form.on('Data Import Beta', { start_import(frm) { frm .call({ - doc: frm.doc, - method: 'start_import', + method: 'form_start_import', + args: { data_import: frm.doc.name }, btn: frm.page.btn_primary }) .then(r => { @@ -252,8 +252,8 @@ frappe.ui.form.on('Data Import Beta', { frm .call({ - doc: frm.doc, method: 'get_preview_from_template', + args: { data_import: frm.doc.name }, error_handlers: { TimestampMismatchError() { // ignore this error diff --git a/frappe/core/doctype/data_import_beta/data_import_beta.py b/frappe/core/doctype/data_import_beta/data_import_beta.py index d010cd7ec2..8f12bd20ed 100644 --- a/frappe/core/doctype/data_import_beta/data_import_beta.py +++ b/frappe/core/doctype/data_import_beta/data_import_beta.py @@ -61,6 +61,16 @@ class DataImportBeta(Document): return Importer(self.reference_doctype, data_import=self) +@frappe.whitelist() +def get_preview_from_template(data_import): + return frappe.get_doc("Data Import Beta", data_import).get_preview_from_template() + + +@frappe.whitelist() +def form_start_import(data_import): + return frappe.get_doc("Data Import Beta", data_import).start_import() + + def start_import(data_import): """This method runs in background job""" data_import = frappe.get_doc("Data Import Beta", data_import) @@ -69,12 +79,11 @@ def start_import(data_import): i.import_data() except: frappe.db.rollback() - data_import.db_set('status', 'Error') + data_import.db_set("status", "Error") frappe.log_error(title=data_import.name) frappe.db.commit() - frappe.publish_realtime( - "data_import_refresh", {"data_import": data_import.name} - ) + frappe.publish_realtime("data_import_refresh", {"data_import": data_import.name}) + @frappe.whitelist() def download_template( diff --git a/frappe/core/doctype/docfield/docfield.json b/frappe/core/doctype/docfield/docfield.json index 6d8ee41a5a..8e7516cd0a 100644 --- a/frappe/core/doctype/docfield/docfield.json +++ b/frappe/core/doctype/docfield/docfield.json @@ -43,6 +43,7 @@ "report_hide", "remember_last_selected_value", "ignore_xss_filter", + "hide_border", "property_depends_on_section", "mandatory_depends_on", "column_break_38", @@ -448,12 +449,19 @@ { "fieldname": "column_break_38", "fieldtype": "Column Break" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype=='Section Break'", + "fieldname": "hide_border", + "fieldtype": "Check", + "label": "Hide Border" } ], "idx": 1, "istable": 1, "links": [], - "modified": "2020-04-19 21:54:13.783908", + "modified": "2020-04-27 11:38:21.223185", "modified_by": "Administrator", "module": "Core", "name": "DocField", diff --git a/frappe/desk/doctype/onboarding/__init__.py b/frappe/core/doctype/installed_application/__init__.py similarity index 100% rename from frappe/desk/doctype/onboarding/__init__.py rename to frappe/core/doctype/installed_application/__init__.py diff --git a/frappe/core/doctype/installed_application/installed_application.json b/frappe/core/doctype/installed_application/installed_application.json new file mode 100644 index 0000000000..1f32c557ce --- /dev/null +++ b/frappe/core/doctype/installed_application/installed_application.json @@ -0,0 +1,49 @@ +{ + "actions": [], + "creation": "2020-05-11 17:44:54.674657", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "app_name", + "app_version", + "git_branch" + ], + "fields": [ + { + "fieldname": "git_branch", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Git Branch", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "app_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Application Name", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "app_version", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Application Version", + "read_only": 1, + "reqd": 1 + } + ], + "istable": 1, + "links": [], + "modified": "2020-05-12 10:09:49.148087", + "modified_by": "Administrator", + "module": "Core", + "name": "Installed Application", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/core/doctype/installed_application/installed_application.py b/frappe/core/doctype/installed_application/installed_application.py new file mode 100644 index 0000000000..6bb12afc49 --- /dev/null +++ b/frappe/core/doctype/installed_application/installed_application.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class InstalledApplication(Document): + pass diff --git a/frappe/core/doctype/installed_applications/__init__.py b/frappe/core/doctype/installed_applications/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/core/doctype/installed_applications/installed_applications.js b/frappe/core/doctype/installed_applications/installed_applications.js new file mode 100644 index 0000000000..9a1fd5ac18 --- /dev/null +++ b/frappe/core/doctype/installed_applications/installed_applications.js @@ -0,0 +1,8 @@ +// Copyright (c) 2020, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Installed Applications', { + // refresh: function(frm) { + + // } +}); diff --git a/frappe/core/doctype/installed_applications/installed_applications.json b/frappe/core/doctype/installed_applications/installed_applications.json new file mode 100644 index 0000000000..f2345e66b2 --- /dev/null +++ b/frappe/core/doctype/installed_applications/installed_applications.json @@ -0,0 +1,42 @@ +{ + "actions": [], + "creation": "2020-05-11 17:45:41.587750", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "installed_applications" + ], + "fields": [ + { + "fieldname": "installed_applications", + "fieldtype": "Table", + "label": "Installed Applications", + "options": "Installed Application", + "read_only": 1 + } + ], + "issingle": 1, + "links": [], + "modified": "2020-05-12 10:09:14.310622", + "modified_by": "Administrator", + "module": "Core", + "name": "Installed Applications", + "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/core/doctype/installed_applications/installed_applications.py b/frappe/core/doctype/installed_applications/installed_applications.py new file mode 100644 index 0000000000..aa0401f368 --- /dev/null +++ b/frappe/core/doctype/installed_applications/installed_applications.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe.model.document import Document + +class InstalledApplications(Document): + def update_versions(self): + self.delete_key("installed_applications") + for app in frappe.utils.get_installed_apps_info(): + self.append("installed_applications", { + "app_name": app.get("app_name"), + "app_version": app.get("version"), + "git_branch": app.get("branch") + }) + self.save() \ No newline at end of file diff --git a/frappe/core/doctype/installed_applications/test_installed_applications.py b/frappe/core/doctype/installed_applications/test_installed_applications.py new file mode 100644 index 0000000000..ab9b849fa1 --- /dev/null +++ b/frappe/core/doctype/installed_applications/test_installed_applications.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestInstalledApplications(unittest.TestCase): + pass diff --git a/frappe/core/page/dashboard/dashboard.js b/frappe/core/page/dashboard/dashboard.js index 222a31a863..0d1337351e 100644 --- a/frappe/core/page/dashboard/dashboard.js +++ b/frappe/core/page/dashboard/dashboard.js @@ -6,7 +6,7 @@ frappe.provide('frappe.dashboards.chart_sources'); frappe.pages['dashboard'].on_page_load = function(wrapper) { - var page = frappe.ui.make_app_page({ + frappe.ui.make_app_page({ parent: wrapper, title: __("Dashboard"), single_column: true @@ -21,7 +21,7 @@ frappe.pages['dashboard'].on_page_load = function(wrapper) { class Dashboard { constructor(wrapper) { this.wrapper = $(wrapper); - $(`
+ $(`
`).appendTo(this.wrapper.find(".page-content").empty()); this.container = this.wrapper.find(".dashboard-graph"); diff --git a/frappe/custom/doctype/custom_field/custom_field.json b/frappe/custom/doctype/custom_field/custom_field.json index 394f38b56c..122e6c7070 100644 --- a/frappe/custom/doctype/custom_field/custom_field.json +++ b/frappe/custom/doctype/custom_field/custom_field.json @@ -48,6 +48,7 @@ "allow_in_quick_entry", "ignore_xss_filter", "translatable", + "hide_border", "description", "permlevel", "width", @@ -378,12 +379,19 @@ "fieldname": "in_preview", "fieldtype": "Check", "label": "In Preview" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype=='Section Break'", + "fieldname": "hide_border", + "fieldtype": "Check", + "label": "Hide Border" } ], "icon": "fa fa-glass", "idx": 1, "links": [], - "modified": "2020-04-10 11:57:10.392218", + "modified": "2020-04-27 11:40:48.325481", "modified_by": "Administrator", "module": "Custom", "name": "Custom Field", diff --git a/frappe/custom/doctype/custom_link/__init__.py b/frappe/custom/doctype/custom_link/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/custom/doctype/custom_link/custom_link.js b/frappe/custom/doctype/custom_link/custom_link.js new file mode 100644 index 0000000000..8662724b1a --- /dev/null +++ b/frappe/custom/doctype/custom_link/custom_link.js @@ -0,0 +1,20 @@ +// Copyright (c) 2020, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Custom Link', { + refresh: function(frm) { + frm.set_query("document_type", function () { + return { + filters: { + custom: 0, + istable: 0, + module: ['not in', ["Email", "Core", "Custom", "Event Streaming", "Social", "Data Migration", "Geo", "Desk"]] + } + }; + }); + + frm.add_custom_button(__('Go to {0} List', [frm.doc.document_type]), function() { + frappe.set_route('List', frm.doc.document_type); + }); + } +}); diff --git a/frappe/custom/doctype/custom_link/custom_link.json b/frappe/custom/doctype/custom_link/custom_link.json new file mode 100644 index 0000000000..350e6b1c2d --- /dev/null +++ b/frappe/custom/doctype/custom_link/custom_link.json @@ -0,0 +1,52 @@ +{ + "actions": [], + "autoname": "field:document_type", + "creation": "2020-04-08 15:16:44.342509", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "document_type", + "links" + ], + "fields": [ + { + "fieldname": "document_type", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Document Type", + "options": "DocType", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "links", + "fieldtype": "Table", + "label": "Links", + "options": "DocType Link" + } + ], + "links": [], + "modified": "2020-04-08 16:42:59.402671", + "modified_by": "Administrator", + "module": "Custom", + "name": "Custom Link", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/custom/doctype/custom_link/custom_link.py b/frappe/custom/doctype/custom_link/custom_link.py new file mode 100644 index 0000000000..11316d5751 --- /dev/null +++ b/frappe/custom/doctype/custom_link/custom_link.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class CustomLink(Document): + pass diff --git a/frappe/desk/doctype/onboarding/test_onboarding.py b/frappe/custom/doctype/custom_link/test_custom_link.py similarity index 81% rename from frappe/desk/doctype/onboarding/test_onboarding.py rename to frappe/custom/doctype/custom_link/test_custom_link.py index 8a9e346fd9..a292f73ad0 100644 --- a/frappe/desk/doctype/onboarding/test_onboarding.py +++ b/frappe/custom/doctype/custom_link/test_custom_link.py @@ -6,5 +6,5 @@ from __future__ import unicode_literals # import frappe import unittest -class TestOnboarding(unittest.TestCase): +class TestCustomLink(unittest.TestCase): pass diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py index ebf01d11b3..6a54d9c7e6 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -76,7 +76,8 @@ docfield_properties = { 'remember_last_selected_value': 'Check', 'allow_bulk_edit': 'Check', 'auto_repeat': 'Link', - 'allow_in_quick_entry': 'Check' + 'allow_in_quick_entry': 'Check', + 'hide_border': 'Check' } allowed_fieldtype_change = (('Currency', 'Float', 'Percent'), ('Small Text', 'Data'), 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 d7887cf8bd..2c5fb874f7 100644 --- a/frappe/custom/doctype/customize_form_field/customize_form_field.json +++ b/frappe/custom/doctype/customize_form_field/customize_form_field.json @@ -39,6 +39,7 @@ "allow_on_submit", "report_hide", "remember_last_selected_value", + "hide_border", "property_depends_on_section", "mandatory_depends_on", "column_break_33", @@ -388,12 +389,19 @@ "fieldname": "in_preview", "fieldtype": "Check", "label": "In Preview" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype=='Section Break'", + "fieldname": "hide_border", + "fieldtype": "Check", + "label": "Hide Border" } ], "idx": 1, "istable": 1, "links": [], - "modified": "2020-04-10 11:58:44.573537", + "modified": "2020-04-27 11:39:26.389300", "modified_by": "Administrator", "module": "Custom", "name": "Customize Form Field", diff --git a/frappe/custom/doctype/package_document_type/__init__.py b/frappe/custom/doctype/package_document_type/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/custom/doctype/package_document_type/package_document_type.json b/frappe/custom/doctype/package_document_type/package_document_type.json new file mode 100644 index 0000000000..6d011bd4e4 --- /dev/null +++ b/frappe/custom/doctype/package_document_type/package_document_type.json @@ -0,0 +1,65 @@ +{ + "actions": [], + "creation": "2020-05-14 16:45:47.196395", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "document_type", + "column_break_2", + "attachments", + "overwrite", + "section_break_4", + "filters_json" + ], + "fields": [ + { + "fieldname": "document_type", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Document Type", + "options": "DocType", + "reqd": 1 + }, + { + "fieldname": "column_break_2", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "attachments", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Include Attachments" + }, + { + "default": "0", + "fieldname": "overwrite", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Overwrite" + }, + { + "fieldname": "section_break_4", + "fieldtype": "Section Break" + }, + { + "fieldname": "filters_json", + "fieldtype": "Code", + "label": "Filters", + "options": "JSON" + } + ], + "istable": 1, + "links": [], + "modified": "2020-05-14 16:45:47.196395", + "modified_by": "Administrator", + "module": "Custom", + "name": "Package Document Type", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/custom/doctype/package_document_type/package_document_type.py b/frappe/custom/doctype/package_document_type/package_document_type.py new file mode 100644 index 0000000000..6e166eecbd --- /dev/null +++ b/frappe/custom/doctype/package_document_type/package_document_type.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class PackageDocumentType(Document): + pass diff --git a/frappe/custom/doctype/package_publish_target/__init__.py b/frappe/custom/doctype/package_publish_target/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/custom/doctype/package_publish_target/package_publish_target.json b/frappe/custom/doctype/package_publish_target/package_publish_target.json new file mode 100644 index 0000000000..baeb7cb8bc --- /dev/null +++ b/frappe/custom/doctype/package_publish_target/package_publish_target.json @@ -0,0 +1,47 @@ +{ + "actions": [], + "creation": "2020-05-13 16:04:32.724663", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "instance_url", + "username", + "password" + ], + "fields": [ + { + "fieldname": "instance_url", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Site URL", + "reqd": 1 + }, + { + "fieldname": "username", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Username", + "reqd": 1 + }, + { + "fieldname": "password", + "fieldtype": "Password", + "in_list_view": 1, + "label": "Password", + "reqd": 1 + } + ], + "istable": 1, + "links": [], + "modified": "2020-05-15 17:35:16.282235", + "modified_by": "Administrator", + "module": "Custom", + "name": "Package Publish Target", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/custom/doctype/package_publish_target/package_publish_target.py b/frappe/custom/doctype/package_publish_target/package_publish_target.py new file mode 100644 index 0000000000..34eee02562 --- /dev/null +++ b/frappe/custom/doctype/package_publish_target/package_publish_target.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class PackagePublishTarget(Document): + pass diff --git a/frappe/custom/doctype/package_publish_tool/__init__.py b/frappe/custom/doctype/package_publish_tool/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/custom/doctype/package_publish_tool/package_publish_tool.js b/frappe/custom/doctype/package_publish_tool/package_publish_tool.js new file mode 100644 index 0000000000..a0190a8d8c --- /dev/null +++ b/frappe/custom/doctype/package_publish_tool/package_publish_tool.js @@ -0,0 +1,159 @@ +// Copyright (c) 2020, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Package Publish Tool', { + refresh: function(frm) { + frm.set_query("document_type", "package_details", function () { + return { + filters: { + "istable": 0, + } + }; + }); + + frappe.realtime.on("package", (data) => { + frm.dashboard.show_progress(data.prefix, data.progress / data.total * 100, __("{0}", [data.message])); + if ((data.progress+1) != data.total) { + frm.dashboard.show_progress(data.prefix, data.progress / data.total * 100, __("{0}", [data.message])); + } else { + frm.dashboard.hide_progress(); + } + }); + + frm.trigger("show_instructions"); + frm.trigger("last_deployed_on"); + frm.trigger("set_dirty_trigger"); + frm.trigger("set_deploy_primary_action"); + }, + last_deployed_on: function(frm) { + if (frm.doc.last_deployed_on) { + frm.trigger("show_indicator"); + } + }, + show_indicator: function(frm) { + let pretty_date = frappe.datetime.prettyDate(frm.doc.last_deployed_on); + frm.page.set_indicator(__("Last published {0}", [pretty_date]), "blue"); + }, + set_dirty_trigger: function(frm) { + $(frm.wrapper).on("dirty", function() { + frm.page.set_primary_action(__('Save'), () => frm.save()); + }); + }, + set_deploy_primary_action: function(frm) { + if (frm.doc.package_details.length && frm.doc.instances.length) { + frm.page.set_primary_action(__("Publish"), function () { + frappe.show_alert({ + message: __("Publishing documents..."), + indicator: "green" + }); + + frappe.call({ + method: "frappe.custom.doctype.package_publish_tool.package_publish_tool.deploy_package", + callback: function() { + frm.reload_doc(); + frappe.msgprint(__("Documents have been published.")); + } + }); + }); + } + }, + show_instructions: function(frm) { + let field = frm.get_field("html_info"); + field.html(` +

+ Package Publish Tool let's you copy documents from your site to any other remote site. + Follow the steps below to publish. +

+
    +
  1. Add Document Types that you want to copy from the table below. You can also add filters by expanding the row.
  2. +
  3. Add the Sites URL where you want to copy these documents, and enter the Username and Password.
  4. +
  5. Click on Save. Now, you can click on Publish and the documents will be copied.
  6. +
+ `); + } +}); + +frappe.ui.form.on('Package Document Type', { + form_render: function (frm, cdt, cdn) { + function _show_filters(filters, table) { + table.find('tbody').empty(); + + if (filters.length > 0) { + filters.forEach(filter => { + const filter_row = + $(` + ${filter[1]} + ${filter[2] || ""} + ${filter[3]} + `); + + table.find('tbody').append(filter_row); + }); + } else { + const filter_row = $(` + ${__("Click to Set Filters")}`); + table.find('tbody').append(filter_row); + } + } + + let row = frappe.get_doc(cdt, cdn); + + let wrapper = $(`[data-fieldname="filters_json"]`).empty(); + let table = $(` + + + + + + + + + +
${__('Filter')}${__('Condition')}${__('Value')}
`).appendTo(wrapper); + $(`

${__("Click table to edit")}

`).appendTo(wrapper); + + let filters = JSON.parse(row.filters_json || '[]'); + _show_filters(filters, table); + + table.on('click', () => { + if (!row.document_type) { + frappe.msgprint(__("Select Document Type.")); + return; + } + + frappe.model.with_doctype(row.document_type, function() { + let dialog = new frappe.ui.Dialog({ + title: __('Set Filters'), + fields: [ + { + fieldtype: 'HTML', + label: 'Filters', + fieldname: 'filter_area', + } + ], + primary_action: function() { + let values = filter_group.get_filters(); + let flt = []; + if (values) { + values.forEach(function(value) { + flt.push([value[0], value[1], value[2], value[3]]); + }); + } + row.filters_json = JSON.stringify(flt); + _show_filters(flt, table); + dialog.hide(); + }, + primary_action_label: "Set" + }); + + let filter_group = new frappe.ui.FilterGroup({ + parent: dialog.get_field('filter_area').$wrapper, + doctype: row.document_type, + on_change: () => {}, + }); + filter_group.add_filters_to_filter_group(filters); + dialog.show(); + }); + }); + }, +}); diff --git a/frappe/custom/doctype/package_publish_tool/package_publish_tool.json b/frappe/custom/doctype/package_publish_tool/package_publish_tool.json new file mode 100644 index 0000000000..0f85ae0348 --- /dev/null +++ b/frappe/custom/doctype/package_publish_tool/package_publish_tool.json @@ -0,0 +1,84 @@ +{ + "actions": [], + "creation": "2020-05-13 15:54:38.082657", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "html_info", + "sb_00", + "package_details", + "sb_01", + "instances", + "last_deployed_on" + ], + "fields": [ + { + "description": "Click on the row for accessing filters.", + "fieldname": "package_details", + "fieldtype": "Table", + "label": "Document Types", + "options": "Package Document Type", + "reqd": 1 + }, + { + "fieldname": "instances", + "fieldtype": "Table", + "label": "Sites", + "options": "Package Publish Target", + "reqd": 1 + }, + { + "fieldname": "html_info", + "fieldtype": "HTML" + }, + { + "fieldname": "last_deployed_on", + "fieldtype": "Datetime", + "hidden": 1, + "label": "Last Deployed On", + "read_only": 1 + }, + { + "fieldname": "sb_00", + "fieldtype": "Section Break" + }, + { + "fieldname": "sb_01", + "fieldtype": "Section Break" + } + ], + "issingle": 1, + "links": [], + "modified": "2020-05-15 17:31:37.060199", + "modified_by": "Administrator", + "module": "Custom", + "name": "Package Publish Tool", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "All", + "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/custom/doctype/package_publish_tool/package_publish_tool.py b/frappe/custom/doctype/package_publish_tool/package_publish_tool.py new file mode 100644 index 0000000000..a01dd0ba47 --- /dev/null +++ b/frappe/custom/doctype/package_publish_tool/package_publish_tool.py @@ -0,0 +1,177 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +import json +import datetime +import base64 +from frappe.model.document import Document +from frappe.utils.file_manager import save_file, get_file +from frappe import _ +from six import string_types +from frappe.frappeclient import FrappeClient +from frappe.utils import get_datetime_str, get_datetime +from frappe.utils.password import get_decrypted_password + +class PackagePublishTool(Document): + pass + +@frappe.whitelist() +def deploy_package(): + package, doc = export_package() + + file_name = "Package-" + get_datetime_str(get_datetime()) + + length = len(doc.instances) + for idx, instance in enumerate(doc.instances): + frappe.publish_realtime("package", {"progress": idx, "total": length, "message": instance.instance_url, "prefix": _("Deploying")}, + user=frappe.session.user) + + install_package_to_remote(package, instance) + + frappe.db.set_value("Package Publish Tool", "Package Publish Tool", "last_deployed_on", frappe.utils.now_datetime()) + +def install_package_to_remote(package, instance): + try: + connection = FrappeClient(instance.instance_url, instance.username, get_decrypted_password(instance.doctype, instance.name)) + except Exception: + frappe.log_error(frappe.get_traceback()) + frappe.throw(_("Couldn't connect to site {0}. Please check Error Logs.").format(instance.instance_url)) + + try: + connection.post_request({ + "cmd": "frappe.custom.doctype.package_publish_tool.package_publish_tool.import_package", + "package": json.dumps(package) + }) + except Exception: + frappe.log_error(frappe.get_traceback()) + frappe.throw(_("Error while installing package to site {0}. Please check Error Logs.").format(instance.instance_url)) + +@frappe.whitelist() +def export_package(): + """Export package as JSON.""" + package_doc = frappe.get_single("Package Publish Tool") + package = [] + + for doctype in package_doc.package_details: + filters = [] + + if doctype.get("filters_json"): + filters = json.loads(doctype.get("filters_json")) + + docs = frappe.get_all(doctype.get("document_type"), filters=filters) + length = len(docs) + + for idx, doc in enumerate(docs): + frappe.publish_realtime("package", { + "progress":idx, "total":length, + "message":doctype.get("document_type"), + "prefix": _("Exporting") + }, + user=frappe.session.user) + + document = frappe.get_doc(doctype.get("document_type"), doc.name).as_dict() + attachments = [] + + if doctype.attachments: + filters = { + "attached_to_doctype": document.get("doctype"), + "attached_to_name": document.get("name") + } + + for f in frappe.get_list("File", filters=filters): + fname, fcontents = get_file(f.name) + attachments.append({ + "fname": fname, + "content": base64.b64encode(fcontents).decode('ascii') + }) + + document.update({ + "__attachments": attachments, + "__overwrite": True if doctype.overwrite else False + }) + + package.append(document) + + return post_process(package), package_doc + +@frappe.whitelist() +def import_package(package=None): + """Import package from JSON.""" + if isinstance(package, string_types): + package = json.loads(package) + + for doc in package: + modified = doc.pop("modified") + overwrite = doc.pop("__overwrite") + attachments = doc.pop("__attachments") + exists = frappe.db.exists(doc.get("doctype"), doc.get("name")) + + if not exists: + d = frappe.get_doc(doc).insert(ignore_permissions=True, ignore_if_duplicate=True) + if attachments: + add_attachment(attachments, d) + else: + docname = doc.pop("name") + document = frappe.get_doc(doc.get("doctype"), docname) + + if overwrite: + update_document(document, doc, attachments) + + else: + if frappe.utils.get_datetime(document.modified) < frappe.utils.get_datetime(modified): + update_document(document, doc, attachments) + +def update_document(document, doc, attachments): + document.update(doc) + document.save() + if attachments: + add_attachment(attachments, document) + +def add_attachment(attachments, doc): + for attachment in attachments: + save_file(attachment.get("fname"), base64.b64decode(attachment.get("content")), doc.get("doctype"), doc.get("name")) + +def post_process(package): + """Remove the keys from Document and Child Document. Convert datetime, date, time to str.""" + del_keys = ('modified_by', 'creation', 'owner', 'idx', 'docstatus') + child_del_keys = ('modified_by', 'creation', 'owner', 'idx', 'docstatus', 'name') + + for doc in package: + for key in del_keys: + if key in doc: + del doc[key] + + for key, value in doc.items(): + stringified_value = get_stringified_value(value) + if stringified_value: + doc[key] = stringified_value + + if not isinstance(value, list): + continue + + for child in value: + for child_key in child_del_keys: + if child_key in child: + del child[child_key] + + for child_key, child_value in child.items(): + stringified_value = get_stringified_value(child_value) + if stringified_value: + child[child_key] = stringified_value + + return package + +def get_stringified_value(value): + if isinstance(value, datetime.datetime): + return frappe.utils.get_datetime_str(value) + + if isinstance(value, datetime.date): + return frappe.utils.get_date_str(value) + + if isinstance(value, datetime.timedelta): + return frappe.utils.get_time_str(value) + + return None diff --git a/frappe/custom/doctype/package_publish_tool/test_package_publish_tool.py b/frappe/custom/doctype/package_publish_tool/test_package_publish_tool.py new file mode 100644 index 0000000000..8332240543 --- /dev/null +++ b/frappe/custom/doctype/package_publish_tool/test_package_publish_tool.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestPackagePublishTool(unittest.TestCase): + pass diff --git a/frappe/database/mariadb/framework_mariadb.sql b/frappe/database/mariadb/framework_mariadb.sql index 46940cc846..bd93069a3f 100644 --- a/frappe/database/mariadb/framework_mariadb.sql +++ b/frappe/database/mariadb/framework_mariadb.sql @@ -63,6 +63,7 @@ CREATE TABLE `tabDocField` ( `precision` varchar(255) DEFAULT NULL, `length` int(11) NOT NULL DEFAULT 0, `translatable` int(1) NOT NULL DEFAULT 0, + `hide_border` int(1) NOT NULL DEFAULT 0, PRIMARY KEY (`name`), KEY `parent` (`parent`), KEY `label` (`label`), diff --git a/frappe/database/postgres/framework_postgres.sql b/frappe/database/postgres/framework_postgres.sql index 26760dbcc9..76309e7347 100644 --- a/frappe/database/postgres/framework_postgres.sql +++ b/frappe/database/postgres/framework_postgres.sql @@ -63,6 +63,7 @@ CREATE TABLE "tabDocField" ( "precision" varchar(255) DEFAULT NULL, "length" bigint NOT NULL DEFAULT 0, "translatable" smallint NOT NULL DEFAULT 0, + "hide_border" smallint NOT NULL DEFAULT 0, PRIMARY KEY ("name") ) ; diff --git a/frappe/desk/desktop.py b/frappe/desk/desktop.py index f2047003fa..512b3f2890 100644 --- a/frappe/desk/desktop.py +++ b/frappe/desk/desktop.py @@ -21,19 +21,17 @@ class Workspace: self.extended_charts = [] self.extended_shortcuts = [] - user = frappe.get_user() - user.build_permissions() - - user_doc = frappe.get_doc('User', frappe.session.user) - self.blocked_modules = user_doc.get_blocked_modules() + self.user = frappe.get_user() + self.allowed_modules = self.get_cached_value('user_allowed_modules', self.get_allowed_modules) self.doc = self.get_page_for_user() - if self.doc.module in self.blocked_modules: + if self.doc.module not in self.allowed_modules: raise frappe.PermissionError - self.user = user - self.allowed_pages = get_allowed_pages() - self.allowed_reports = get_allowed_reports() + self.can_read = self.get_cached_value('user_perm_can_read', self.get_can_read_items) + + self.allowed_pages = get_allowed_pages(cache=True) + self.allowed_reports = get_allowed_reports(cache=True) self.onboarding_doc = self.get_onboarding_doc() self.onboarding = None @@ -41,6 +39,31 @@ class Workspace: self.restricted_doctypes = frappe.cache().get_value("domain_restricted_doctypes") or build_domain_restriced_doctype_cache() self.restricted_pages = frappe.cache().get_value("domain_restricted_pages") or build_domain_restriced_page_cache() + def get_cached_value(self, cache_key, fallback_fn): + _cache = frappe.cache() + + value = _cache.get_value(cache_key, user=frappe.session.user) + if value: + return value + + value = fallback_fn() + + # Expire every six hour + _cache.set_value(cache_key, value, frappe.session.user, 21600) + return value + + def get_can_read_items(self): + if not self.user.can_read: + self.user.build_permissions() + + return self.user.can_read + + def get_allowed_modules(self): + if not self.user.allow_modules: + self.user.build_permissions() + + return self.user.allow_modules + def get_page_for_user(self): filters = { 'extends': self.page_name, @@ -61,14 +84,14 @@ class Workspace: if not self.doc.onboarding: return None - if frappe.db.get_value("Onboarding", self.doc.onboarding, "is_complete"): + if frappe.db.get_value("Module Onboarding", self.doc.onboarding, "is_complete"): return None - doc = frappe.get_doc("Onboarding", self.doc.onboarding) + doc = frappe.get_doc("Module Onboarding", self.doc.onboarding) # Check if user is allowed allowed_roles = set(doc.get_allowed_roles()) - user_roles = set(self.user.get_roles()) + user_roles = set(frappe.get_roles()) if not allowed_roles & user_roles: return None @@ -83,7 +106,7 @@ class Workspace: "extends": self.page_name, 'restrict_to_domain': ['in', frappe.get_active_domains()], 'for_user': '', - 'module': ['not in', self.blocked_modules] + 'module': ['in', self.allowed_modules] }) pages = [frappe.get_doc("Desk Page", page['name']) for page in pages] @@ -97,13 +120,15 @@ class Workspace: item_type = item_type.lower() if item_type == "doctype": - return (name in self.user.can_read and name in self.restricted_doctypes) + return (name in self.can_read and name in self.restricted_doctypes) if item_type == "page": return (name in self.allowed_pages and name in self.restricted_pages) if item_type == "report": return name in self.allowed_reports if item_type == "help": return True + if item_type == "dashboard": + return True return False @@ -140,9 +165,9 @@ class Workspace: default_country = frappe.db.get_default("country") def _doctype_contains_a_record(name): - exists = self.table_counts.get(name) - if not exists: - if not frappe.db.get_value('DocType', name, 'issingle'): + exists = self.table_counts.get(name, None) + if exists is None: + if not frappe.db.get_value('DocType', name, 'issingle', cache=True): exists = frappe.db.count(name) else: exists = True @@ -249,6 +274,8 @@ class Workspace: for doc in self.onboarding_doc.get_steps(): step = doc.as_dict().copy() step.label = _(doc.title) + if step.action == "Create Entry": + step.is_submittable = frappe.db.get_value("DocType", step.reference_document, 'is_submittable', cache=True) steps.append(step) return steps @@ -292,7 +319,6 @@ def get_desk_sidebar_items(flatten=False): filters = { 'restrict_to_domain': ['in', frappe.get_active_domains()], 'extends_another_page': 0, - 'is_standard': 1, 'for_user': '', 'module': ['not in', blocked_modules] } diff --git a/frappe/desk/doctype/dashboard/dashboard.json b/frappe/desk/doctype/dashboard/dashboard.json index c17bc3235c..c0e2bddcf8 100644 --- a/frappe/desk/doctype/dashboard/dashboard.json +++ b/frappe/desk/doctype/dashboard/dashboard.json @@ -9,6 +9,7 @@ "dashboard_name", "is_default", "charts", + "chart_options", "cards" ], "fields": [ @@ -33,6 +34,13 @@ "options": "Dashboard Chart Link", "reqd": 1 }, + { + "description": "Set Default Options for all charts on this Dashboard (Ex: \"colors\": [\"#d1d8dd\", \"#ff5858\"])", + "fieldname": "chart_options", + "fieldtype": "Code", + "label": "Chart Options", + "options": "JSON" + }, { "fieldname": "cards", "fieldtype": "Table", @@ -41,7 +49,7 @@ } ], "links": [], - "modified": "2020-04-19 17:44:36.237163", + "modified": "2020-04-29 13:26:37.362482", "modified_by": "Administrator", "module": "Desk", "name": "Dashboard", diff --git a/frappe/desk/doctype/dashboard/dashboard.py b/frappe/desk/doctype/dashboard/dashboard.py index b85e135071..af0c48d9c6 100644 --- a/frappe/desk/doctype/dashboard/dashboard.py +++ b/frappe/desk/doctype/dashboard/dashboard.py @@ -5,6 +5,8 @@ from __future__ import unicode_literals from frappe.model.document import Document import frappe +from frappe import _ +import json class Dashboard(Document): def on_update(self): @@ -13,13 +15,29 @@ class Dashboard(Document): frappe.db.sql('''update tabDashboard set is_default = 0 where name != %s''', self.name) + def validate(self): + self.validate_custom_options() + + def validate_custom_options(self): + if self.chart_options: + try: + json.loads(self.chart_options) + except ValueError as error: + frappe.throw(_("Invalid json added in the custom options: {0}").format(error)) + @frappe.whitelist() def get_permitted_charts(dashboard_name): permitted_charts = [] dashboard = frappe.get_doc('Dashboard', dashboard_name) for chart in dashboard.charts: if frappe.has_permission('Dashboard Chart', doc=chart.chart): - permitted_charts.append(chart) + chart_dict = frappe._dict() + chart_dict.update(chart.as_dict()) + + if dashboard.get('chart_options'): + chart_dict.custom_options = dashboard.get('chart_options') + permitted_charts.append(chart_dict) + return permitted_charts @frappe.whitelist() diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.js b/frappe/desk/doctype/dashboard_chart/dashboard_chart.js index f8d5886b26..2ec73cff42 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.js +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.js @@ -49,6 +49,7 @@ frappe.ui.form.on('Dashboard Chart', { }); frm.set_df_property("filters_section", "hidden", 1); + frm.trigger('set_time_series'); frm.set_query('document_type', function() { return { filters: { @@ -57,6 +58,7 @@ frappe.ui.form.on('Dashboard Chart', { } }); frm.trigger('update_options'); + frm.trigger('set_heatmap_year_options'); if (frm.doc.report_name) { frm.trigger('set_chart_report_filters'); } @@ -70,7 +72,17 @@ frappe.ui.form.on('Dashboard Chart', { frm.trigger("show_filters"); }, + set_heatmap_year_options: function(frm) { + if (frm.doc.type == 'Heatmap') { + frappe.db.get_doc('System Settings').then(doc => { + const creation_date = doc.creation; + frm.set_df_property('heatmap_year', 'options', frappe.dashboard_utils.get_years_since_creation(creation_date)); + }); + } + }, + chart_type: function(frm) { + frm.trigger('set_time_series'); if (frm.doc.chart_type == 'Report') { frm.set_query('report_name', () => { return { @@ -80,23 +92,25 @@ frappe.ui.form.on('Dashboard Chart', { } }); } else { - // set timeseries based on chart type - if (['Count', 'Average', 'Sum'].includes(frm.doc.chart_type)) { - frm.set_value('timeseries', 1); - } else { - frm.set_value('timeseries', 0); - } - if (frm.doc.chart_type == 'Group By') { - frm.set_df_property('type', 'options', ['Line', 'Bar', 'Percentage', 'Pie']); + frm.set_df_property('type', 'options', ['Line', 'Bar', 'Percentage', 'Pie', 'Donut']); } else { - frm.set_df_property('type', 'options', ['Line', 'Bar']); + frm.set_df_property('type', 'options', ['Line', 'Bar', 'Heatmap']); } frm.set_value('document_type', ''); } }, + set_time_series: function(frm) { + // set timeseries based on chart type + if (['Count', 'Average', 'Sum'].includes(frm.doc.chart_type)) { + frm.set_value('timeseries', 1); + } else { + frm.set_value('timeseries', 0); + } + }, + document_type: function(frm) { // update `based_on` options based on date / datetime fields frm.set_value('source', ''); @@ -283,17 +297,7 @@ frappe.ui.form.on('Dashboard Chart', { }); } } else if (frm.chart_filters.length) { - fields = frm.chart_filters.filter(f => { - if (f.on_change && !f.reqd) { - return false; - } - if (f.get_query || f.get_data) { - f.read_only = 1; - } - - return f.fieldname; - }); - + fields = frm.chart_filters.filter(f => f.fieldname); fields.map( f => { if (filters[f.fieldname]) { let condition = '='; @@ -353,10 +357,10 @@ frappe.ui.form.on('Dashboard Chart', { } dialog.show(); + //Set query report object so that it can be used while fetching filter values in the report + frappe.query_report = new frappe.views.QueryReport({'filters': dialog.fields_list}); dialog.set_values(filters); }); }, }); - - diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.json b/frappe/desk/doctype/dashboard_chart/dashboard_chart.json index b5201a8b1f..72f5c43316 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.json +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.json @@ -23,17 +23,18 @@ "number_of_groups", "column_break_6", "is_public", + "heatmap_year", "timespan", "from_date", "to_date", "time_interval", "timeseries", + "type", "filters_section", "filters_json", "chart_options_section", - "type", - "column_break_2", "color", + "column_break_2", "custom_options", "section_break_10", "last_synced_on" @@ -85,14 +86,14 @@ "fieldtype": "Column Break" }, { - "depends_on": "timeseries", + "depends_on": "eval: doc.timeseries && doc.type !== 'Heatmap'", "fieldname": "timespan", "fieldtype": "Select", "label": "Timespan", "options": "Last Year\nLast Quarter\nLast Month\nLast Week\nSelect Date Range" }, { - "depends_on": "timeseries", + "depends_on": "eval: doc.timeseries && doc.type !== 'Heatmap'", "fieldname": "time_interval", "fieldtype": "Select", "label": "Time Interval", @@ -100,7 +101,7 @@ }, { "default": "0", - "depends_on": "eval: ['Count', 'Sum', 'Average'].includes(doc.chart_type)", + "depends_on": "eval: !['Group By', 'Report'].includes(doc.chart_type)\n", "fieldname": "timeseries", "fieldtype": "Check", "label": "Time Series" @@ -123,10 +124,11 @@ "label": "Chart Options" }, { + "default": "Line", "fieldname": "type", "fieldtype": "Select", "label": "Type", - "options": "Line\nBar\nPercentage\nPie\nDonut", + "options": "Line\nBar\nHeatmap", "reqd": 1 }, { @@ -134,7 +136,7 @@ "fieldtype": "Column Break" }, { - "depends_on": "eval:doc.chart_type !== 'Report'", + "depends_on": "eval: doc.chart_type !== 'Report' && doc.type !== 'Heatmap'", "fieldname": "color", "fieldtype": "Color", "label": "Color" @@ -217,7 +219,7 @@ "options": "Dashboard Chart Field" }, { - "description": "Ex: \"colors\": [\"#d1d8dd\", \"#ff5858\"]", + "description": "Ex: \"colors\": [\"#d1d8dd\", \"#ff5858\"] (the options set here will override the chart options set in the Dashboard)", "fieldname": "custom_options", "fieldtype": "Code", "label": "Custom Options" @@ -228,10 +230,16 @@ "fieldname": "is_public", "fieldtype": "Check", "label": "Is Public" + }, + { + "depends_on": "eval: doc.type == 'Heatmap'", + "fieldname": "heatmap_year", + "fieldtype": "Select", + "label": "Year" } ], "links": [], - "modified": "2020-05-01 15:22:59.119341", + "modified": "2020-05-01 19:45:01.669384", "modified_by": "Administrator", "module": "Desk", "name": "Dashboard Chart", @@ -275,4 +283,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} \ No newline at end of file +} diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py index 417ef2ba82..7e375e835f 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py @@ -8,7 +8,7 @@ from frappe import _ import datetime import json from frappe.utils.dashboard import cache_source, get_from_date_from_timespan -from frappe.utils import nowdate, add_to_date, getdate, get_last_day, formatdate, get_datetime +from frappe.utils import nowdate, add_to_date, getdate, get_last_day, formatdate, get_datetime, cint from frappe.model.naming import append_number_if_name_exists from frappe.boot import get_allowed_reports from frappe.model.document import Document @@ -58,13 +58,13 @@ def has_permission(doc, ptype, user): @frappe.whitelist() @cache_source def get(chart_name = None, chart = None, no_cache = None, filters = None, from_date = None, - to_date = None, timespan = None, time_interval = None, refresh = None): + to_date = None, timespan = None, time_interval = None, heatmap_year=None, refresh = None): if chart_name: chart = frappe.get_doc('Dashboard Chart', chart_name) else: chart = frappe._dict(frappe.parse_json(chart)) - + heatmap_year = heatmap_year or chart.heatmap_year timespan = timespan or chart.timespan if timespan == 'Select Date Range': @@ -87,7 +87,10 @@ def get(chart_name = None, chart = None, no_cache = None, filters = None, from_d if chart.chart_type == 'Group By': chart_config = get_group_by_chart_config(chart, filters) else: - chart_config = get_chart_config(chart, filters, timespan, timegrain, from_date, to_date) + if chart.type == 'Heatmap': + chart_config = get_heatmap_chart_config(chart, filters, heatmap_year) + else: + chart_config = get_chart_config(chart, filters, timespan, timegrain, from_date, to_date) return chart_config @@ -174,6 +177,41 @@ def get_chart_config(chart, filters, timespan, timegrain, from_date, to_date): return chart_config +def get_heatmap_chart_config(chart, filters, heatmap_year): + aggregate_function = get_aggregate_function(chart.chart_type) + value_field = chart.value_based_on or '1' + doctype = chart.document_type + datefield = chart.based_on + year = cint(heatmap_year) if heatmap_year else getdate(nowdate()).year + year_start_date = datetime.date(year, 1, 1).strftime('%Y-%m-%d') + next_year_start_date = datetime.date(year + 1, 1, 1).strftime('%Y-%m-%d') + + filters.append([doctype, datefield, '>', "{date}".format(date=year_start_date), False]) + filters.append([doctype, datefield, '<', "{date}".format(date=next_year_start_date), False]) + + if frappe.db.db_type == 'mariadb': + timestamp_field = 'unix_timestamp({datefield})'.format(datefield=datefield) + else: + timestamp_field = 'extract(epoch from timestamp {datefield})'.format(datefield=datefield) + + data = dict(frappe.db.get_all( + doctype, + fields = [ + timestamp_field, + '{aggregate_function}({value_field})'.format(aggregate_function=aggregate_function, value_field=value_field), + ], + filters = filters, + group_by = 'date({datefield})'.format(datefield=datefield), + as_list = 1, + order_by = '{datefield} asc'.format(datefield=datefield), + ignore_ifnull = True + )) + + chart_config = { + 'labels': [], + 'dataPoints': data, + } + return chart_config def get_group_by_chart_config(chart, filters): @@ -397,11 +435,11 @@ class DashboardChart(Document): def check_document_type(self): if frappe.get_meta(self.document_type).issingle: - frappe.throw("You cannot create a dashboard chart from single DocTypes") + frappe.throw(_("You cannot create a dashboard chart from single DocTypes")) def validate_custom_options(self): if self.custom_options: try: json.loads(self.custom_options) except ValueError as error: - frappe.throw("Invalid json added in the custom options: %s" % error) \ No newline at end of file + frappe.throw(_("Invalid json added in the custom options: {0}").format(error)) diff --git a/frappe/desk/doctype/desk_page/desk_page.js b/frappe/desk/doctype/desk_page/desk_page.js index 3087a5f5b8..503859eb61 100644 --- a/frappe/desk/doctype/desk_page/desk_page.js +++ b/frappe/desk/doctype/desk_page/desk_page.js @@ -2,16 +2,22 @@ // For license information, please see license.txt frappe.ui.form.on('Desk Page', { - setup: function(frm) { + refresh: function(frm) { + frm.enable_save(); frm.get_field("is_standard").toggle(frappe.boot.developer_mode); frm.get_field("extends_another_page").toggle(frappe.boot.developer_mode); - if (!frappe.boot.developer_mode || frm.doc.for_user) { + frm.get_field("developer_mode_only").toggle(frappe.boot.developer_mode); + + if (frm.doc.for_user) { + frm.set_df_property("extends", "read_only", true); + } + + if (frm.doc.for_user || (frm.doc.is_standard && !frappe.boot.developer_mode)) { frm.trigger('disable_form'); } }, disable_form: function(frm) { - frm.set_read_only(); frm.fields .filter(field => field.has_input) .forEach(field => { diff --git a/frappe/desk/doctype/desk_page/desk_page.json b/frappe/desk/doctype/desk_page/desk_page.json index cb106c5dd4..851eb43b23 100644 --- a/frappe/desk/doctype/desk_page/desk_page.json +++ b/frappe/desk/doctype/desk_page/desk_page.json @@ -8,8 +8,8 @@ "engine": "InnoDB", "field_order": [ "label", - "extends", "for_user", + "extends", "module", "category", "restrict_to_domain", @@ -170,7 +170,7 @@ "search_index": 1 }, { - "depends_on": "eval:doc.extends_another_page == 1", + "depends_on": "eval:doc.extends_another_page == 1 || doc.for_user", "fieldname": "extends", "fieldtype": "Link", "in_standard_filter": 1, @@ -188,11 +188,11 @@ "fieldname": "onboarding", "fieldtype": "Link", "label": "Onboarding", - "options": "Onboarding" + "options": "Module Onboarding" } ], "links": [], - "modified": "2020-04-26 12:21:46.205079", + "modified": "2020-05-13 19:01:42.041524", "modified_by": "Administrator", "module": "Desk", "name": "Desk Page", diff --git a/frappe/desk/doctype/desk_shortcut/desk_shortcut.json b/frappe/desk/doctype/desk_shortcut/desk_shortcut.json index 9f8990732a..f3fd546a77 100644 --- a/frappe/desk/doctype/desk_shortcut/desk_shortcut.json +++ b/frappe/desk/doctype/desk_shortcut/desk_shortcut.json @@ -6,9 +6,9 @@ "engine": "InnoDB", "field_order": [ "type", - "label", - "column_break_4", "link_to", + "column_break_4", + "label", "icon", "restrict_to_domain", "section_break_5", @@ -23,7 +23,7 @@ "fieldtype": "Select", "in_list_view": 1, "label": "Type", - "options": "DocType\nReport\nPage", + "options": "DocType\nReport\nPage\nDashboard", "reqd": 1 }, { @@ -81,13 +81,14 @@ { "fieldname": "label", "fieldtype": "Data", + "in_list_view": 1, "label": "Label", "reqd": 1 } ], "istable": 1, "links": [], - "modified": "2020-04-07 19:04:23.645198", + "modified": "2020-05-14 16:02:15.420993", "modified_by": "Administrator", "module": "Desk", "name": "Desk Shortcut", diff --git a/frappe/desk/doctype/module_onboarding/__init__.py b/frappe/desk/doctype/module_onboarding/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/desk/doctype/onboarding/onboarding.js b/frappe/desk/doctype/module_onboarding/module_onboarding.js similarity index 93% rename from frappe/desk/doctype/onboarding/onboarding.js rename to frappe/desk/doctype/module_onboarding/module_onboarding.js index bed7dbd5de..d95920e2ca 100644 --- a/frappe/desk/doctype/onboarding/onboarding.js +++ b/frappe/desk/doctype/module_onboarding/module_onboarding.js @@ -1,7 +1,7 @@ // Copyright (c) 2020, Frappe Technologies and contributors // For license information, please see license.txt -frappe.ui.form.on("Onboarding", { +frappe.ui.form.on("Module Onboarding", { refresh: function(frm) { frappe.boot.developer_mode && frm.set_intro( diff --git a/frappe/desk/doctype/onboarding/onboarding.json b/frappe/desk/doctype/module_onboarding/module_onboarding.json similarity index 98% rename from frappe/desk/doctype/onboarding/onboarding.json rename to frappe/desk/doctype/module_onboarding/module_onboarding.json index b1d563a9dc..9810e7a15f 100644 --- a/frappe/desk/doctype/onboarding/onboarding.json +++ b/frappe/desk/doctype/module_onboarding/module_onboarding.json @@ -93,7 +93,7 @@ "modified": "2020-05-01 19:37:21.492405", "modified_by": "Administrator", "module": "Desk", - "name": "Onboarding", + "name": "Module Onboarding", "owner": "Administrator", "permissions": [ { diff --git a/frappe/desk/doctype/onboarding/onboarding.py b/frappe/desk/doctype/module_onboarding/module_onboarding.py similarity index 89% rename from frappe/desk/doctype/onboarding/onboarding.py rename to frappe/desk/doctype/module_onboarding/module_onboarding.py index c8527d22b6..89160a60f0 100644 --- a/frappe/desk/doctype/onboarding/onboarding.py +++ b/frappe/desk/doctype/module_onboarding/module_onboarding.py @@ -8,10 +8,10 @@ from frappe.model.document import Document from frappe.modules.export_file import export_to_files -class Onboarding(Document): +class ModuleOnboarding(Document): def on_update(self): if frappe.conf.developer_mode: - export_to_files(record_list=[['Onboarding', self.name]], record_module=self.module) + export_to_files(record_list=[['Module Onboarding', self.name]], record_module=self.module) for step in self.steps: export_to_files(record_list=[['Onboarding Step', step.step]], record_module=self.module) diff --git a/frappe/desk/doctype/module_onboarding/test_module_onboarding.py b/frappe/desk/doctype/module_onboarding/test_module_onboarding.py new file mode 100644 index 0000000000..ef305667b1 --- /dev/null +++ b/frappe/desk/doctype/module_onboarding/test_module_onboarding.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestModuleOnboarding(unittest.TestCase): + pass diff --git a/frappe/desk/doctype/note/note.js b/frappe/desk/doctype/note/note.js index c237998ccf..5718180b70 100644 --- a/frappe/desk/doctype/note/note.js +++ b/frappe/desk/doctype/note/note.js @@ -1,9 +1,9 @@ frappe.ui.form.on("Note", { refresh: function(frm) { - if(frm.doc.__islocal) { + if (frm.doc.__islocal) { frm.events.set_editable(frm, true); } else { - if(!frm.doc.content) { + if (!frm.doc.content) { frm.doc.content = ""; } @@ -18,16 +18,15 @@ frappe.ui.form.on("Note", { // hide all fields other than content // no permission - if(editable && !frm.perm[0].write) return; + if (editable && !frm.perm[0].write) return; // content read_only - frm.set_df_property("content", "read_only", editable ? 0: 1); + frm.set_df_property("content", "read_only", editable ? 0 : 1); // hide all other fields $.each(frm.fields_dict, function(fieldname) { - - if(fieldname !== "content") { - frm.set_df_property(fieldname, "hidden", editable ? 0: 1); + if (fieldname !== "content") { + frm.set_df_property(fieldname, "hidden", editable ? 0 : 1); } }); @@ -39,3 +38,16 @@ frappe.ui.form.on("Note", { frm.is_note_editable = editable; } }); + +frappe.tour['Note'] = [ + { + fieldname: "title", + title: "Title of the Note", + description: "This is the name by which the note will be saved, you can change this later", + }, + { + fieldname: "public", + title: "Sets the Note to Public", + description: "You can change the visibility of the note with this, setting it to public will allow other users to view it.", + }, +]; \ No newline at end of file diff --git a/frappe/desk/doctype/note/test_note.py b/frappe/desk/doctype/note/test_note.py index 8d46eaf336..38894a9c3d 100644 --- a/frappe/desk/doctype/note/test_note.py +++ b/frappe/desk/doctype/note/test_note.py @@ -20,7 +20,7 @@ class TestNote(unittest.TestCase): note = self.insert_note() note.title = 'test note 1' note.content = '1' - note.save() + note.save(ignore_version=False) version = frappe.get_doc('Version', dict(docname=note.name)) data = version.get_data() @@ -33,7 +33,7 @@ class TestNote(unittest.TestCase): # test add note.append('seen_by', {'user': 'Administrator'}) - note.save() + note.save(ignore_version=False) version = frappe.get_doc('Version', dict(docname=note.name)) data = version.get_data() @@ -48,7 +48,7 @@ class TestNote(unittest.TestCase): # test row change note.seen_by[0].user = 'Guest' - note.save() + note.save(ignore_version=False) version = frappe.get_doc('Version', dict(docname=note.name)) data = version.get_data() @@ -62,7 +62,7 @@ class TestNote(unittest.TestCase): # test remove note.seen_by = [] - note.save() + note.save(ignore_version=False) version = frappe.get_doc('Version', dict(docname=note.name)) data = version.get_data() diff --git a/frappe/desk/doctype/onboarding_step/onboarding_step.js b/frappe/desk/doctype/onboarding_step/onboarding_step.js index 3e5d4d4260..793e044d98 100644 --- a/frappe/desk/doctype/onboarding_step/onboarding_step.js +++ b/frappe/desk/doctype/onboarding_step/onboarding_step.js @@ -25,6 +25,24 @@ frappe.ui.form.on("Onboarding Step", { } }, + action: function(frm) { + if (frm.doc.action == "Show Form Tour") { + frm.fields_dict.reference_document.set_description(`You need to add the steps in the contoller JS file. For example: note.js +

+frappe.tour['Note'] = [
+	{
+		fieldname: "title",
+		title: "Title of the Note",
+		description: "...",
+	}
+];
+
+ `); + } else { + frm.fields_dict.reference_document.set_description(null); + } + }, + disable_form: function(frm) { frm.set_read_only(); frm.fields diff --git a/frappe/desk/doctype/onboarding_step/onboarding_step.json b/frappe/desk/doctype/onboarding_step/onboarding_step.json index e1035a4343..37d1d63dbe 100644 --- a/frappe/desk/doctype/onboarding_step/onboarding_step.json +++ b/frappe/desk/doctype/onboarding_step/onboarding_step.json @@ -15,10 +15,16 @@ "action", "column_break_7", "reference_document", + "show_full_form", + "is_single", "reference_report", "report_reference_doctype", "report_type", "report_description", + "path", + "callback_title", + "callback_message", + "validate_action", "field", "value_to_validate", "video_url" @@ -57,7 +63,7 @@ "fieldname": "action", "fieldtype": "Select", "label": "Action", - "options": "Create Entry\nUpdate Settings\nView Report\nWatch Video", + "options": "Create Entry\nUpdate Settings\nShow Form Tour\nView Report\nGo to Page\nWatch Video", "reqd": 1 }, { @@ -65,10 +71,11 @@ "fieldtype": "Column Break" }, { - "depends_on": "eval:doc.action == \"Create Entry\" || doc.action == \"Update Settings\"", + "depends_on": "eval:doc.action == \"Create Entry\" || doc.action == \"Update Settings\" || doc.action == \"Create Entry\" || doc.action == \"Show Form Tour\"", "fieldname": "reference_document", "fieldtype": "Link", "label": "Reference Document", + "mandatory_depends_on": "eval:doc.action == \"Create Entry\" || doc.action == \"Update Settings\" || doc.action == \"Create Entry\" || doc.action == \"Show Form Tour\"", "options": "DocType" }, { @@ -83,7 +90,8 @@ "depends_on": "eval:doc.action == \"Watch Video\"", "fieldname": "video_url", "fieldtype": "Data", - "label": "Video URL" + "label": "Video URL", + "mandatory_depends_on": "eval:doc.action == \"Watch Video\"" }, { "depends_on": "eval:doc.action == \"View Report\"", @@ -101,17 +109,19 @@ "label": "Is Skipped" }, { - "depends_on": "eval:doc.action == \"Update Settings\"", + "depends_on": "eval:doc.action == \"Update Settings\" && doc.validate_action", "fieldname": "field", "fieldtype": "Select", - "label": "Field" + "label": "Field", + "mandatory_depends_on": "eval:doc.action == \"Update Settings\" && doc.validate_action" }, { - "depends_on": "eval:doc.action == \"Update Settings\"", + "depends_on": "eval:doc.action == \"Update Settings\" && doc.validate_action", "description": "Use % for any non empty value.", "fieldname": "value_to_validate", "fieldtype": "Data", - "label": "Value to Validate" + "label": "Value to Validate", + "mandatory_depends_on": "eval:doc.action == \"Update Settings\" && doc.validate_action" }, { "depends_on": "eval:doc.action == \"View Report\"", @@ -127,10 +137,54 @@ "fieldtype": "Data", "label": "Report Reference Doctype", "read_only": 1 + }, + { + "default": "0", + "depends_on": "eval:doc.action == \"Create Entry\" || doc.action == \"Update Settings\" || doc.action == \"Create Entry\" || doc.action == \"Show Form Tour\"", + "fetch_from": "reference_document.issingle", + "fieldname": "is_single", + "fieldtype": "Check", + "label": "Is Single" + }, + { + "depends_on": "eval:doc.action == \"Go to Page\"", + "description": "Example: #Tree/Account", + "fieldname": "path", + "fieldtype": "Data", + "label": "Path", + "mandatory_depends_on": "eval:doc.action == \"Go to Page\"" + }, + { + "depends_on": "eval:doc.action == \"Go to Page\"", + "fieldname": "callback_title", + "fieldtype": "Data", + "label": "Callback Title" + }, + { + "depends_on": "eval:doc.action == \"Go to Page\"", + "description": "This will be shown in a modal after routing", + "fieldname": "callback_message", + "fieldtype": "Small Text", + "label": "Callback Message" + }, + { + "default": "1", + "depends_on": "eval:doc.action == \"Update Settings\"", + "fieldname": "validate_action", + "fieldtype": "Check", + "label": "Validate Field" + }, + { + "default": "0", + "depends_on": "eval:doc.action == \"Create Entry\"", + "description": "Show full form instead of a quick entry modal", + "fieldname": "show_full_form", + "fieldtype": "Check", + "label": "Show Full Form?" } ], "links": [], - "modified": "2020-05-04 12:53:19.276952", + "modified": "2020-05-14 15:10:05.627706", "modified_by": "Administrator", "module": "Desk", "name": "Onboarding Step", diff --git a/frappe/desk/doctype/onboarding_step/onboarding_step.py b/frappe/desk/doctype/onboarding_step/onboarding_step.py index e1cc5dfba4..8086acbb2a 100644 --- a/frappe/desk/doctype/onboarding_step/onboarding_step.py +++ b/frappe/desk/doctype/onboarding_step/onboarding_step.py @@ -10,3 +10,7 @@ class OnboardingStep(Document): def before_export(self, doc): doc.is_complete = 0 doc.is_skipped = 0 + + def validate(self): + if self.action == "Go to Page": + self.is_mandatory = 0 diff --git a/frappe/desk/notifications.py b/frappe/desk/notifications.py index 109dd25f4f..4a1302788b 100644 --- a/frappe/desk/notifications.py +++ b/frappe/desk/notifications.py @@ -212,7 +212,10 @@ def get_notification_config(): def get_filters_for(doctype): '''get open filters for doctype''' config = get_notification_config() - return config.get("for_doctype").get(doctype, {}) + doctype_config = config.get("for_doctype").get(doctype, {}) + filters = doctype_config if not isinstance(doctype_config, string_types) else None + + return filters @frappe.whitelist() @frappe.read_only() diff --git a/frappe/desk/page/setup_wizard/install_fixtures.py b/frappe/desk/page/setup_wizard/install_fixtures.py index 6917ef0426..60e1f3242a 100644 --- a/frappe/desk/page/setup_wizard/install_fixtures.py +++ b/frappe/desk/page/setup_wizard/install_fixtures.py @@ -14,6 +14,7 @@ def install(): update_global_search_doctypes() setup_email_linking() sync_dashboards() + add_unsubscribe() @frappe.whitelist() def update_genders(): @@ -37,3 +38,15 @@ def setup_email_linking(): "email_id": "email_linking@example.com", }) doc.insert(ignore_permissions=True, ignore_if_duplicate=True) + +def add_unsubscribe(): + email_unsubscribe = [ + {"email": "admin@example.com", "global_unsubscribe": 1}, + {"email": "guest@example.com", "global_unsubscribe": 1} + ] + + for unsubscribe in email_unsubscribe: + if not frappe.get_all("Email Unsubscribe", filters=unsubscribe): + doc = frappe.new_doc("Email Unsubscribe") + doc.update(unsubscribe) + doc.insert(ignore_permissions=True) diff --git a/frappe/desk/page/user_profile/user_profile.js b/frappe/desk/page/user_profile/user_profile.js index ff1e906cff..c43ff27ba3 100644 --- a/frappe/desk/page/user_profile/user_profile.js +++ b/frappe/desk/page/user_profile/user_profile.js @@ -108,21 +108,6 @@ class UserProfile { }); } - get_years_since_creation() { - //Get years since user account created - this.user_creation = frappe.boot.user.creation; - let creation_year = this.get_year(this.user_creation); - let current_year = this.get_year(frappe.datetime.now_date()); - let years_list = []; - for (var year = current_year; year >= creation_year; year--) { - years_list.push(year); - } - return years_list; - } - - get_year(date_str) { - return date_str.substring(0, date_str.indexOf('-')); - } render_line_chart() { this.line_chart_filters = [['Energy Point Log', 'user', '=', this.user_id, false]]; @@ -246,8 +231,8 @@ class UserProfile { create_heatmap_chart_filters() { let filters = [ { - label: this.get_year(frappe.datetime.now_date()), - options: this.get_years_since_creation(), + label: frappe.dashboard_utils.get_year(frappe.datetime.now_date()), + options: frappe.dashboard_utils.get_years_since_creation(frappe.boot.user.creation), action: (selected_item) => { this.update_heatmap_data(frappe.datetime.obj_to_str(selected_item)); } diff --git a/frappe/email/doctype/document_follow/test_document_follow.py b/frappe/email/doctype/document_follow/test_document_follow.py index 4f1a8733cc..1208a6c5c1 100644 --- a/frappe/email/doctype/document_follow/test_document_follow.py +++ b/frappe/email/doctype/document_follow/test_document_follow.py @@ -14,7 +14,7 @@ class TestDocumentFollow(unittest.TestCase): event_doc = get_event() event_doc.description = "This is a test description for sending mail" - event_doc.save() + event_doc.save(ignore_version=False) doc = document_follow.follow_document("Event", event_doc.name , user.name, force=True) self.assertEquals(doc.user, user.name) @@ -45,12 +45,12 @@ def get_event(): return doc def get_user(): - doc = frappe.new_doc("User") - doc.email = "test@docsub.com" - doc.first_name = "Test" - doc.last_name = "User" - doc.send_welcome_email = 0 - doc.document_follow_notify = 1 - doc.document_follow_frequency = "Hourly" - doc.insert() - return doc \ No newline at end of file + doc = frappe.new_doc("User") + doc.email = "test@docsub.com" + doc.first_name = "Test" + doc.last_name = "User" + doc.send_welcome_email = 0 + doc.document_follow_notify = 1 + doc.document_follow_frequency = "Hourly" + doc.insert() + return doc \ No newline at end of file diff --git a/frappe/email/doctype/newsletter/newsletter..json b/frappe/email/doctype/newsletter/newsletter..json new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/email/doctype/newsletter/newsletter.json b/frappe/email/doctype/newsletter/newsletter.json index 719d51c176..01f75be954 100644 --- a/frappe/email/doctype/newsletter/newsletter.json +++ b/frappe/email/doctype/newsletter/newsletter.json @@ -17,7 +17,7 @@ "subject", "message", "send_unsubscribe_link", - "send_attachements", + "send_attachments", "published", "route", "test_the_newsletter", @@ -73,12 +73,6 @@ "fieldtype": "Check", "label": "Send Unsubscribe Link" }, - { - "default": "0", - "fieldname": "send_attachements", - "fieldtype": "Check", - "label": "Send Attachements" - }, { "default": "0", "fieldname": "published", @@ -127,6 +121,12 @@ { "fieldname": "column_break_2", "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "send_attachments", + "fieldtype": "Check", + "label": "Send Attachments" } ], "has_web_view": 1, @@ -135,7 +135,7 @@ "is_published_field": "published", "links": [], "max_attachments": 3, - "modified": "2020-03-02 06:26:51.622521", + "modified": "2020-05-12 18:09:40.137138", "modified_by": "Administrator", "module": "Email", "name": "Newsletter", diff --git a/frappe/email/doctype/newsletter/newsletter.py b/frappe/email/doctype/newsletter/newsletter.py index 2469569892..2dccfbead4 100755 --- a/frappe/email/doctype/newsletter/newsletter.py +++ b/frappe/email/doctype/newsletter/newsletter.py @@ -67,7 +67,7 @@ class Newsletter(WebsiteGenerator): frappe.db.auto_commit_on_many_writes = True attachments = [] - if self.send_attachements: + if self.send_attachments: files = frappe.get_all("File", fields=["name"], filters={"attached_to_doctype": "Newsletter", "attached_to_name": self.name}, order_by="creation desc") diff --git a/frappe/exceptions.py b/frappe/exceptions.py index 9a1c1fb0b0..5a1181f31e 100644 --- a/frappe/exceptions.py +++ b/frappe/exceptions.py @@ -49,6 +49,11 @@ class Redirect(Exception): class CSRFTokenError(Exception): http_status_code = 400 + +class TooManyRequestsError(Exception): + http_status_code = 429 + + class ImproperDBConfigurationError(Exception): """ Used when frappe detects that database or tables are not properly diff --git a/frappe/frappeclient.py b/frappe/frappeclient.py index 8611c21720..919c334e51 100644 --- a/frappe/frappeclient.py +++ b/frappe/frappeclient.py @@ -15,6 +15,9 @@ class AuthError(Exception): class SiteExpiredError(Exception): pass +class SiteUnreachableError(Exception): + pass + class FrappeException(Exception): pass @@ -53,9 +56,16 @@ class FrappeClient(object): if r.status_code==200 and r.json().get('message') in ("Logged In", "No App"): return r.json() + elif r.status_code == 502: + raise SiteUnreachableError else: - if json.loads(r.text).get('exc_type') == "SiteExpiredError": - raise SiteExpiredError + try: + error = json.loads(r.text) + if error.get('exc_type') == "SiteExpiredError": + raise SiteExpiredError + except json.decoder.JSONDecodeError: + error = r.text + print(error) raise AuthError def setup_key_authentication_headers(self): diff --git a/frappe/integrations/doctype/google_contacts/google_contacts.py b/frappe/integrations/doctype/google_contacts/google_contacts.py index 0e28c1306c..5874c79108 100644 --- a/frappe/integrations/doctype/google_contacts/google_contacts.py +++ b/frappe/integrations/doctype/google_contacts/google_contacts.py @@ -218,7 +218,7 @@ def insert_contacts_to_google_contacts(doc, method=None): emailAddresses = [{"value": email_id.email_id} for email_id in doc.email_ids] try: - contact = google_contacts.people().createContact(parent='people/me', body={"names": [names],"phoneNumbers": phoneNumbers, + contact = google_contacts.people().createContact(body={"names": [names],"phoneNumbers": phoneNumbers, "emailAddresses": emailAddresses}).execute() frappe.db.set_value("Contact", doc.name, "google_contacts_id", contact.get("resourceName")) except HttpError as err: diff --git a/frappe/integrations/frappe_providers/__init__.py b/frappe/integrations/frappe_providers/__init__.py new file mode 100644 index 0000000000..0b689478d2 --- /dev/null +++ b/frappe/integrations/frappe_providers/__init__.py @@ -0,0 +1,14 @@ +# imports - standard imports +import sys + +# imports - module imports +from frappe.integrations.frappe_providers.frappecloud import frappecloud_migrator + + +def migrate_to(local_site, frappe_provider): + if frappe_provider in ("frappe.cloud", "frappecloud.com"): + frappe_provider = "frappecloud.com" + return frappecloud_migrator(local_site, frappe_provider) + else: + print("{} is not supported yet".format(frappe_provider)) + sys.exit(1) diff --git a/frappe/integrations/frappe_providers/frappecloud.py b/frappe/integrations/frappe_providers/frappecloud.py new file mode 100644 index 0000000000..4f33c990f9 --- /dev/null +++ b/frappe/integrations/frappe_providers/frappecloud.py @@ -0,0 +1,268 @@ +# imports - standard imports +import getpass +import json +import re +import sys + +# imports - third party imports +import click +from html2text import html2text +import requests + +# imports - module imports +import frappe +import frappe.utils.backups +from frappe.utils import get_installed_apps_info +from frappe.utils.commands import render_table, add_line_after + + +def get_new_site_options(): + site_options_sc = session.post(options_url) + + if site_options_sc.ok: + site_options = site_options_sc.json()["message"] + return site_options + else: + print("Couldn't retrive New site information: {}".format(site_options_sc.status_code)) + + +def is_valid_subdomain(subdomain): + if len(subdomain) < 5: + print("Subdomain too short. Use 5 or more characters") + return False + matched = re.match("^[a-z0-9][a-z0-9-]*[a-z0-9]$", subdomain) + if matched: + return True + print("Subdomain contains invalid characters. Use lowercase characters, numbers and hyphens") + + +def is_subdomain_available(subdomain): + res = session.post(site_exists_url, {"subdomain": subdomain}) + if res.ok: + available = not res.json()["message"] + if not available: + print("Subdomain already exists! Try another one") + + return available + + +def render_plan_table(plans_list): + plans_table = [] + + # title row + visible_headers = ["name", "cpu_time_per_day"] + plans_table.append(["Plan", "CPU Time"]) + + # all rows + for plan in plans_list: + plan, cpu_time = [plan[header] for header in visible_headers] + plans_table.append([plan, "{} hour{}/day".format(cpu_time, "" if cpu_time < 2 else "s")]) + + render_table(plans_table) + + +@add_line_after +def choose_plan(plans_list): + print("{} plans available".format(len(plans_list))) + available_plans = [plan["name"] for plan in plans_list] + render_plan_table(plans_list) + + while True: + input_plan = click.prompt("Select Plan").strip() + if input_plan in available_plans: + print("{} Plan selected ✅".format(input_plan)) + return input_plan + else: + print("Invalid Selection ❌") + + +@add_line_after +def check_app_compat(available_group): + is_compat = True + incompatible_apps, filtered_apps, branch_msgs = [], [], [] + existing_group = [(app["app_name"], app["branch"]) for app in get_installed_apps_info()] + print("Checking availability of existing app group") + + for (app, branch) in existing_group: + info = [ (a["name"], a["branch"]) for a in available_group["apps"] if a["scrubbed"] == app ] + if info: + app_title, available_branch = info[0] + + if branch != available_branch: + print("⚠️ App {}:{} => {}".format(app, branch, available_branch)) + branch_msgs.append([app, branch, available_branch]) + filtered_apps.append(app_title) + is_compat = False + + else: + print("✅ App {}:{}".format(app, branch)) + filtered_apps.append(app_title) + + else: + incompatible_apps.append(app) + print("❌ App {}:{}".format(app, branch)) + is_compat = False + + start_msg = "\nSelecting this group will " + incompatible_apps = ("\n\nDrop the following apps:\n" + "\n".join(incompatible_apps)) if incompatible_apps else "" + branch_change = ("\n\nUpgrade the following apps:\n" + "\n".join(["{}: {} => {}".format(*x) for x in branch_msgs])) if branch_msgs else "" + changes = (incompatible_apps + branch_change) or "be perfect for you :)" + warning_message = start_msg + changes + print(warning_message) + + return is_compat, filtered_apps + + +def render_group_table(app_groups): + # title row + app_groups_table = [["#", "App Group", "Apps"]] + + # all rows + for idx, app_group in enumerate(app_groups): + apps_list = ", ".join(["{}:{}".format(app["scrubbed"], app["branch"]) for app in app_group["apps"]]) + row = [idx + 1, app_group["name"], apps_list] + app_groups_table.append(row) + + render_table(app_groups_table) + + +@add_line_after +def filter_apps(app_groups): + render_group_table(app_groups) + + while True: + app_group_index = click.prompt("Select App Group Number", type=int) - 1 + try: + if app_group_index == -1: + raise IndexError + selected_group = app_groups[app_group_index] + except IndexError: + print("Invalid Selection ❌") + continue + + is_compat, filtered_apps = check_app_compat(selected_group) + + if is_compat or click.confirm("Continue anyway?"): + print("App Group {} selected! ✅".format(selected_group["name"])) + break + + return selected_group["name"], filtered_apps + +@add_line_after +def create_session(): + # take user input from STDIN + username = click.prompt("Username").strip() + password = getpass.unix_getpass() + + auth_credentials = {"usr": username, "pwd": password} + + session = requests.Session() + login_sc = session.post(login_url, auth_credentials) + + if login_sc.ok: + print("Authorization Successful! ✅") + session.headers.update({"X-Press-Team": username}) + return session + else: + print("Authorization Failed with Error Code {}".format(login_sc.status_code)) + + +@add_line_after +def get_subdomain(domain): + while True: + subdomain = click.prompt("Enter subdomain").strip() + if is_valid_subdomain(subdomain) and is_subdomain_available(subdomain): + print("Site Domain: {}.{}".format(subdomain, domain)) + return subdomain + + +@add_line_after +def upload_backup(local_site): + # take backup + files_session = {} + print("Taking backup for site {}".format(local_site)) + odb = frappe.utils.backups.new_backup(ignore_files=False, force=True) + + # upload files + for x, (file_type, file_path) in enumerate([ + ("database", odb.backup_path_db), + ("public", odb.backup_path_files), + ("private", odb.backup_path_private_files) + ]): + file_upload_response = session.post(files_url, data={}, files={ + "file": open(file_path, "rb"), + "is_private": 1, + "folder": "Home", + "method": "press.api.site.upload_backup", + "type": file_type + }) + print("Uploading files ({}/3)".format(x+1), end="\r") + if file_upload_response.ok: + files_session[file_type] = file_upload_response.json()["message"] + else: + print("Upload failed for: {}".format(file_path)) + + files_uploaded = { k: v["file_url"] for k, v in files_session.items() } + print("Uploaded backup files! ✅") + + return files_uploaded + + +def frappecloud_migrator(local_site, remote_site): + global login_url, upload_url, files_url, options_url, site_exists_url, session + + login_url = "https://{}/api/method/login".format(remote_site) + upload_url = "https://{}/api/method/press.api.site.new".format(remote_site) + files_url = "https://{}/api/method/upload_file".format(remote_site) + options_url = "https://{}/api/method/press.api.site.options_for_new".format(remote_site) + site_exists_url = "https://{}/api/method/press.api.site.exists".format(remote_site) + + print("Frappe Cloud credentials @ {}".format(remote_site)) + + # get credentials + auth user + start session + session = create_session() + + if session: + # connect to site db + frappe.init(site=local_site) + frappe.connect() + + # get new site options + site_options = get_new_site_options() + + # set preferences from site options + subdomain = get_subdomain(site_options["domain"]) + plan = choose_plan(site_options["plans"]) + + app_groups = site_options["groups"] + selected_group, filtered_apps = filter_apps(app_groups) + files_uploaded = upload_backup(local_site) + + # push to frappe_cloud + payload = json.dumps({ + "site": { + "apps": filtered_apps, + "files": files_uploaded, + "group": selected_group, + "name": subdomain, + "plan": plan + } + }) + + session.headers.update({"Content-Type": "application/json; charset=utf-8"}) + site_creation_request = session.post(upload_url, payload) + frappe.destroy() + + if site_creation_request.ok: + site_url = site_creation_request.json()["message"] + print("Your site {} is being migrated ✨".format(local_site)) + print("View your site dashboard at {}/dashboard/#/sites/{}".format(remote_site, site_url)) + print("Your site URL: {}".format(site_url)) + else: + print("Request failed with error code {}".format(site_creation_request.status_code)) + reason = html2text(site_creation_request.text) + print(reason) + sys.exit(1) + + else: + sys.exit(1) diff --git a/frappe/migrate.py b/frappe/migrate.py index 094abbe099..9ec23d8ae7 100644 --- a/frappe/migrate.py +++ b/frappe/migrate.py @@ -5,11 +5,13 @@ from __future__ import unicode_literals import json import os +import sys import frappe import frappe.translate import frappe.modules.patch_handler import frappe.model.sync from frappe.utils.fixtures import sync_fixtures +from frappe.utils.connections import check_connection from frappe.utils.dashboard import sync_dashboards from frappe.cache_manager import clear_global_cache from frappe.desk.notifications import clear_notifications @@ -19,6 +21,7 @@ from frappe.modules.utils import sync_customizations from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs from frappe.utils import global_search + def migrate(verbose=True, rebuild_website=False, skip_failing=False): '''Migrate all apps to the latest version, will: - run before migrate hooks @@ -32,6 +35,19 @@ def migrate(verbose=True, rebuild_website=False, skip_failing=False): - run after migrate hooks ''' + service_status = check_connection(redis_services=["redis_cache"]) + if False in service_status.values(): + for service in service_status: + if not service_status.get(service, True): + print("{} service is not running.".format(service)) + print("""Cannot run bench migrate without the services running. +If you are running bench in development mode, make sure that bench is running: + +$ bench start + +Otherwise, check the server logs and ensure that all the required services are running.""") + sys.exit(1) + touched_tables_file = frappe.get_site_path('touched_tables.json') if os.path.exists(touched_tables_file): os.remove(touched_tables_file) @@ -67,6 +83,9 @@ def migrate(verbose=True, rebuild_website=False, skip_failing=False): # add static pages to global search global_search.update_global_search_for_all_web_pages() + # updating installed applications data + frappe.get_single('Installed Applications').update_versions() + #run after_migrate hooks for app in frappe.get_installed_apps(): for fn in frappe.get_hooks('after_migrate', app_name=app): diff --git a/frappe/model/document.py b/frappe/model/document.py index 65cb6073b7..843cb421fe 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -297,8 +297,7 @@ class Document(BaseDocument): if ignore_permissions!=None: self.flags.ignore_permissions = ignore_permissions - if ignore_version!=None: - self.flags.ignore_version = ignore_version + self.flags.ignore_version = frappe.flags.in_test if ignore_version is None else ignore_version if self.get("__islocal") or not self.get("name"): self.insert() @@ -1339,4 +1338,4 @@ def check_doctype_has_consumers(doctype): if len(event_consumers) and event_consumers[0]: return True - return False \ No newline at end of file + return False diff --git a/frappe/model/meta.py b/frappe/model/meta.py index 7bf93d1968..c8fd1a2ac2 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -443,34 +443,41 @@ class Meta(Document): def add_doctype_links(self, data): '''add `links` child table in standard link dashboard format''' + dashboard_links = [] + if hasattr(self, 'links') and self.links: - if not data.transactions: - # init groups - data.transactions = [] - data.non_standard_fieldnames = {} + dashboard_links.extend(self.links) - for link in self.links: - link.added = False - for group in data.transactions: - group = frappe._dict(group) - # group found - if link.group and group.label == link.group: - if link.link_doctype not in group.get('items'): - group.get('items').append(link.link_doctype) - link.added = True + if frappe.get_all("Custom Link", {"document_type": self.name}): + dashboard_links.extend(frappe.get_doc("Custom Link", self.name).links) - if not link.added: - # group not found, make a new group - data.transactions.append(dict( - label = link.group, - items = [link.link_doctype] - )) + if not data.transactions: + # init groups + data.transactions = [] + data.non_standard_fieldnames = {} - if link.link_fieldname != data.fieldname: - if data.fieldname: - data.non_standard_fieldnames[link.link_doctype] = link.link_fieldname - else: - data.fieldname = link.link_fieldname + for link in dashboard_links: + link.added = False + for group in data.transactions: + group = frappe._dict(group) + # group found + if link.group and group.label == link.group: + if link.link_doctype not in group.get('items'): + group.get('items').append(link.link_doctype) + link.added = True + + if not link.added: + # group not found, make a new group + data.transactions.append(dict( + label = link.group, + items = [link.link_doctype] + )) + + if link.link_fieldname != data.fieldname: + if data.fieldname: + data.non_standard_fieldnames[link.link_doctype] = link.link_fieldname + else: + data.fieldname = link.link_fieldname def get_row_template(self): diff --git a/frappe/model/sync.py b/frappe/model/sync.py index 320cc24677..b7d9d4d548 100644 --- a/frappe/model/sync.py +++ b/frappe/model/sync.py @@ -51,7 +51,7 @@ def sync_for(app_name, force=0, sync_everything = False, verbose=False, reset_pe ("desk", "onboarding_permission"), ("desk", "onboarding_step"), ("desk", "onboarding_step_map"), - ("desk", "onboarding"), + ("desk", "module_onboarding"), ("desk", "desk_card"), ("desk", "desk_chart"), ("desk", "desk_shortcut"), @@ -85,7 +85,7 @@ def get_doc_files(files, start_path, force=0, sync_everything = False, verbose=F document_types = ['doctype', 'page', 'report', 'dashboard_chart_source', 'print_format', 'website_theme', 'web_form', 'web_template', 'notification', 'print_style', 'data_migration_mapping', 'data_migration_plan', 'desk_page', - 'onboarding_step', 'onboarding'] + 'onboarding_step', 'module_onboarding'] for doctype in document_types: doctype_path = os.path.join(start_path, doctype) diff --git a/frappe/modules/import_file.py b/frappe/modules/import_file.py index cddef4f910..27649b8da9 100644 --- a/frappe/modules/import_file.py +++ b/frappe/modules/import_file.py @@ -13,7 +13,7 @@ ignore_values = { "Print Format": ["disabled"], "Notification": ["enabled"], "Print Style": ["disabled"], - "Onboarding": ['is_complete'], + "Module Onboarding": ['is_complete'], "Onboarding Step": ['is_complete', 'is_skipped'] } diff --git a/frappe/monitor.py b/frappe/monitor.py index b056286cf9..6802a59584 100644 --- a/frappe/monitor.py +++ b/frappe/monitor.py @@ -81,6 +81,12 @@ class Monitor: self.data.request.status_code = response.status_code self.data.request.response_length = int(response.headers.get("Content-Length", 0)) + if hasattr(frappe.local, "rate_limiter"): + limiter = frappe.local.rate_limiter + self.data.request.counter = limiter.counter + if limiter.rejected: + self.data.request.reset = limiter.reset + self.store() except Exception: traceback.print_exc() diff --git a/frappe/patches.txt b/frappe/patches.txt index b73f2aea83..8ab9418e6c 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -279,3 +279,5 @@ frappe.patches.v13_0.migrate_translation_column_data frappe.patches.v13_0.set_read_times frappe.patches.v13_0.remove_web_view frappe.patches.v13_0.remove_tailwind_from_page_builder +frappe.patches.v13_0.rename_onboarding +frappe.patches.v13_0.email_unsubscribe diff --git a/frappe/patches/v13_0/email_unsubscribe.py b/frappe/patches/v13_0/email_unsubscribe.py new file mode 100644 index 0000000000..69ed1be948 --- /dev/null +++ b/frappe/patches/v13_0/email_unsubscribe.py @@ -0,0 +1,13 @@ +import frappe + +def execute(): + email_unsubscribe = [ + {"email": "admin@example.com", "global_unsubscribe": 1}, + {"email": "guest@example.com", "global_unsubscribe": 1} + ] + + for unsubscribe in email_unsubscribe: + if not frappe.get_all("Email Unsubscribe", filters=unsubscribe): + doc = frappe.new_doc("Email Unsubscribe") + doc.update(unsubscribe) + doc.insert(ignore_permissions=True) \ No newline at end of file diff --git a/frappe/patches/v13_0/rename_onboarding.py b/frappe/patches/v13_0/rename_onboarding.py new file mode 100644 index 0000000000..c506c6076e --- /dev/null +++ b/frappe/patches/v13_0/rename_onboarding.py @@ -0,0 +1,10 @@ +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +from __future__ import unicode_literals +import frappe + +def execute(): + if frappe.db.exists("DocType", "Onboarding"): + frappe.rename_doc("DocType", "Onboarding", "Module Onboarding", ignore_if_exists=True) + diff --git a/frappe/public/build.json b/frappe/public/build.json index df3d71f537..30cb2adf87 100755 --- a/frappe/public/build.json +++ b/frappe/public/build.json @@ -109,7 +109,9 @@ "public/less/chat.less", "public/less/filters.less", "public/less/social.less", - "node_modules/frappe-charts/dist/frappe-charts.min.css" + "node_modules/frappe-charts/dist/frappe-charts.min.css", + "node_modules/driver.js/dist/driver.min.css", + "public/less/driver.less" ], "css/frappe-rtl.css": [ "public/css/bootstrap-rtl.css", @@ -241,6 +243,7 @@ "public/js/frappe/utils/energy_point_utils.js", "public/js/frappe/utils/dashboard_utils.js", "public/js/frappe/ui/chart.js", + "public/js/frappe/ui/driver.js", "public/js/frappe/barcode_scanner/index.js" ], "css/form.min.css": [ diff --git a/frappe/public/js/frappe/chat.js b/frappe/public/js/frappe/chat.js index f54b9e5cbe..6b723d508c 100644 --- a/frappe/public/js/frappe/chat.js +++ b/frappe/public/js/frappe/chat.js @@ -2259,14 +2259,19 @@ class extends Component { ) : null, h("div","", h("div", { class: "panel-title" }, - h("div", { class: "cursor-pointer", onclick: () => { frappe.set_route(item.route) }}, + h("div", { class: "cursor-pointer", onclick: () => { + frappe.session.user !== "Guest" ? + frappe.set_route(item.route) : null; + }}, h(frappe.Chat.Widget.MediaProfile, { ...item }) ) ) ), - h("div", { class: popper ? "col-xs-1" : "col-xs-3" }, + h("div", { class: popper ? "col-xs-2" : "col-xs-3" }, h("div", { class: "text-right" }, - + frappe._.is_mobile() && h(frappe.components.Button, { class: "frappe-chat-close", onclick: props.toggle }, + h(frappe.components.Octicon, { type: "x" }) + ) ) ) ) diff --git a/frappe/public/js/frappe/data_import/import_preview.js b/frappe/public/js/frappe/data_import/import_preview.js index 1900a1f789..27d81b75b7 100644 --- a/frappe/public/js/frappe/data_import/import_preview.js +++ b/frappe/public/js/frappe/data_import/import_preview.js @@ -81,7 +81,7 @@ frappe.data_import.ImportPreview = class ImportPreview { `; return { id: frappe.utils.get_random(6), - name: col.header_title || df.label, + name: col.header_title || (df ? df.label : 'Untitled Column'), content: column_title, skip_import: true, editable: false, diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index a5853d96f5..bad7c877fc 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -464,9 +464,9 @@ frappe.ui.form.Form = class FrappeForm { } run_after_load_hook() { - if (frappe.route_options.after_load) { - let route_callback = frappe.route_options.after_load; - delete frappe.route_options.after_load; + if (frappe.route_hooks.after_load) { + let route_callback = frappe.route_hooks.after_load; + delete frappe.route_hooks.after_load; route_callback(this); } @@ -580,9 +580,9 @@ frappe.ui.form.Form = class FrappeForm { me.script_manager.trigger("after_save"); - if (frappe.route_options.after_save) { - let route_callback = frappe.route_options.after_save; - delete frappe.route_options.after_save; + if (frappe.route_hooks.after_save) { + let route_callback = frappe.route_hooks.after_save; + delete frappe.route_hooks.after_save; route_callback(me); } @@ -651,6 +651,12 @@ frappe.ui.form.Form = class FrappeForm { callback && callback(); me.script_manager.trigger("on_submit") .then(() => resolve(me)); + if (frappe.route_hooks.after_submit) { + let route_callback = frappe.route_hooks.after_submit; + delete frappe.route_hooks.after_submit; + + route_callback(me); + } } }, btn, () => me.handle_save_fail(btn, on_error), resolve); }); @@ -1556,6 +1562,41 @@ frappe.ui.form.Form = class FrappeForm { $el.find('input, select, textarea').focus(); }, 1000); } + + show_tour(on_finish) { + if (!Array.isArray(frappe.tour[this.doctype])) { + return; + } + + const driver = new frappe.Driver({ + overlayClickNext: true, + keyboardControl: true, + nextBtnText: 'Next', + prevBtnText: 'Previous', + opacity: 0.25, + onNext: () => { + if (!driver.hasNextStep()) { + on_finish && on_finish(); + } + } + }); + + this.layout.sections.forEach(section => section.collapse(false)); + + let steps = frappe.tour[this.doctype].map(step => { + let field = this.get_docfield(step.fieldname); + return { + element: `.frappe-control[title='${step.fieldname}']`, + popover: { + title: step.title || field.label, + description: step.description + } + }; + }); + + driver.defineSteps(steps); + driver.start(); + } }; frappe.validated = 0; diff --git a/frappe/public/js/frappe/form/layout.js b/frappe/public/js/frappe/form/layout.js index 5aeb29b1ed..d6106255a0 100644 --- a/frappe/public/js/frappe/form/layout.js +++ b/frappe/public/js/frappe/form/layout.js @@ -599,12 +599,15 @@ frappe.ui.form.Section = Class.extend({ if(this.df.cssClass) { this.wrapper.addClass(this.df.cssClass); } + if (this.df.hide_border) { + this.wrapper.toggleClass("hide-border", true); + } } - // for bc this.body = $('
').appendTo(this.wrapper); }, + make_head: function() { var me = this; if(!this.df.collapsible) { @@ -663,9 +666,11 @@ frappe.ui.form.Section = Class.extend({ } }); }, + is_collapsed() { return this.body.hasClass('hide'); }, + has_missing_mandatory: function() { var missing_mandatory = false; for (var j=0, l=this.fields_list.length; j < l; j++) { diff --git a/frappe/public/js/frappe/form/quick_entry.js b/frappe/public/js/frappe/form/quick_entry.js index 9996389a4e..68444c8a3b 100644 --- a/frappe/public/js/frappe/form/quick_entry.js +++ b/frappe/public/js/frappe/form/quick_entry.js @@ -107,7 +107,7 @@ frappe.ui.form.QuickEntryForm = Class.extend({ }); this.register_primary_action(); - this.render_edit_in_full_page_link(); + !this.force && this.render_edit_in_full_page_link(); // ctrl+enter to save this.dialog.wrapper.keydown(function(e) { if((e.ctrlKey || e.metaKey) && e.which==13) { @@ -213,8 +213,15 @@ frappe.ui.form.QuickEntryForm = Class.extend({ me.dialog.doc = r.message; if (frappe._from_link) { frappe.ui.form.update_calling_link(me.dialog.doc); + } else { + if (me.after_insert) { + me.after_insert(me.dialog.doc); + } else { + me.open_form_if_not_list(); + } } - cur_frm.reload_doc(); + + cur_frm && cur_frm.reload_doc(); } }); }, diff --git a/frappe/public/js/frappe/model/model.js b/frappe/public/js/frappe/model/model.js index b87dad1d36..663850d08c 100644 --- a/frappe/public/js/frappe/model/model.js +++ b/frappe/public/js/frappe/model/model.js @@ -268,6 +268,11 @@ $.extend(frappe.model, { return frappe.boot.single_types.indexOf(doctype) != -1; }, + is_tree: function(doctype) { + if (!doctype) return false; + return frappe.boot.treeviews.indexOf(doctype) != -1; + }, + can_import: function(doctype, frm) { // system manager can always import if(frappe.user_roles.includes("System Manager")) return true; diff --git a/frappe/public/js/frappe/provide.js b/frappe/public/js/frappe/provide.js index 1dacc4dd47..d4d0fdffb8 100644 --- a/frappe/public/js/frappe/provide.js +++ b/frappe/public/js/frappe/provide.js @@ -35,6 +35,7 @@ frappe.provide('locals.DocType'); // for listviews frappe.provide("frappe.listview_settings"); +frappe.provide("frappe.tour"); frappe.provide("frappe.listview_parent_route"); // constants diff --git a/frappe/public/js/frappe/router.js b/frappe/public/js/frappe/router.js index 06bd6a3bd9..f3f3285245 100644 --- a/frappe/public/js/frappe/router.js +++ b/frappe/public/js/frappe/router.js @@ -12,6 +12,7 @@ frappe.route_history = []; frappe.view_factory = {}; frappe.view_factories = []; frappe.route_options = null; +frappe.route_hooks = {}; frappe.route = function() { diff --git a/frappe/public/js/frappe/ui/driver.js b/frappe/public/js/frappe/ui/driver.js new file mode 100644 index 0000000000..98ed49ec05 --- /dev/null +++ b/frappe/public/js/frappe/ui/driver.js @@ -0,0 +1,3 @@ +import Driver from 'driver.js'; + +frappe.Driver = Driver; \ No newline at end of file diff --git a/frappe/public/js/frappe/utils/common.js b/frappe/public/js/frappe/utils/common.js index 1cdabf23e0..9ff4ade761 100644 --- a/frappe/public/js/frappe/utils/common.js +++ b/frappe/public/js/frappe/utils/common.js @@ -276,7 +276,7 @@ frappe.utils.sanitise_redirect = (url) => { // check for base domain only if the url is absolute // return true for relative url (except protocol-relative urls) - return is_absolute(url) ? domain(location.href) !== domain(url) : true; + return is_absolute(url) ? domain(location.href) !== domain(url) : false; } })(); diff --git a/frappe/public/js/frappe/utils/dashboard_utils.js b/frappe/public/js/frappe/utils/dashboard_utils.js index a1628be34a..d1621a3e15 100644 --- a/frappe/public/js/frappe/utils/dashboard_utils.js +++ b/frappe/public/js/frappe/utils/dashboard_utils.js @@ -82,5 +82,21 @@ frappe.dashboard_utils = { ).then(settings => { return settings; }); + }, + + get_years_since_creation(creation) { + //Get years since user account created + let creation_year = this.get_year(creation); + let current_year = this.get_year(frappe.datetime.now_date()); + let years_list = []; + for (var year = current_year; year >= creation_year; year--) { + years_list.push(year); + } + return years_list; + }, + + get_year(date_str) { + return date_str.substring(0, date_str.indexOf('-')); } + }; \ No newline at end of file diff --git a/frappe/public/js/frappe/utils/pretty_date.js b/frappe/public/js/frappe/utils/pretty_date.js index ef235ed3b1..7618d58829 100644 --- a/frappe/public/js/frappe/utils/pretty_date.js +++ b/frappe/public/js/frappe/utils/pretty_date.js @@ -76,6 +76,7 @@ window.comment_when = function(datetime, mini) { + prettyDate(datetime, mini) + ''; }; frappe.datetime.comment_when = comment_when; +frappe.datetime.prettyDate = prettyDate; frappe.datetime.refresh_when = function() { if (jQuery) { diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js index 7eff0b8e24..7d2c20c693 100644 --- a/frappe/public/js/frappe/utils/utils.js +++ b/frappe/public/js/frappe/utils/utils.js @@ -250,7 +250,8 @@ Object.assign(frappe.utils, { regExp = /^\w+$/; break; case "email": - regExp = /^((([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+(\.([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+)*)|((\x22)((((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(([\x01-\x08\x0b\x0c\x0e-\x1f\x7f]|\x21|[\x23-\x5b]|[\x5d-\x7e]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(\\([\x01-\x09\x0b\x0c\x0d-\x7f]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))))*(((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(\x22)))@((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))$/i; + // from https://emailregex.com/ + regExp = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; break; case "url": regExp = /^(https?|s?ftp):\/\/(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i; diff --git a/frappe/public/js/frappe/views/desktop/desktop.js b/frappe/public/js/frappe/views/desktop/desktop.js index 5956a6310d..51add61f07 100644 --- a/frappe/public/js/frappe/views/desktop/desktop.js +++ b/frappe/public/js/frappe/views/desktop/desktop.js @@ -294,7 +294,7 @@ class DesktopPage { make_charts() { return frappe.dashboard_utils.get_dashboard_settings().then(settings => { - let chart_config = settings.chart_config? JSON.parse(settings.chart_config): {}; + let chart_config = settings.chart_config ? JSON.parse(settings.chart_config): {}; if (this.data.charts.items) { this.data.charts.items.map(chart => { chart.chart_settings = chart_config[chart.chart_name] || {}; @@ -306,6 +306,7 @@ class DesktopPage { container: this.page, type: "chart", columns: 1, + hidden: Boolean(this.onboarding_widget), options: { allow_sorting: this.allow_customization, allow_create: this.allow_customization, diff --git a/frappe/public/js/frappe/views/reports/query_report.js b/frappe/public/js/frappe/views/reports/query_report.js index 5105494862..e79e43ae02 100644 --- a/frappe/public/js/frappe/views/reports/query_report.js +++ b/frappe/public/js/frappe/views/reports/query_report.js @@ -330,8 +330,8 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { evaluate_depends_on_value(expression, filter_label) { let out = null; - let filters = this.get_filter_values(); - if (filters) { + let doc = this.get_filter_values(); + if (doc) { if (typeof expression === 'boolean') { out = expression; } else if (expression.substr(0, 5) == 'eval:') { @@ -341,7 +341,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { frappe.throw(__(`Invalid "depends_on" expression set in filter ${filter_label}`)); } } else { - var value = filters[expression]; + var value = doc[expression]; if ($.isArray(value)) { out = !!value.length; } else { diff --git a/frappe/public/js/frappe/views/reports/report_utils.js b/frappe/public/js/frappe/views/reports/report_utils.js index a8149b9134..7b1205482f 100644 --- a/frappe/public/js/frappe/views/reports/report_utils.js +++ b/frappe/public/js/frappe/views/reports/report_utils.js @@ -20,7 +20,7 @@ frappe.report_utils = { return { data: { - labels: labels, + labels: labels.length? labels: null, datasets: datasets }, truncateLegends: 1, diff --git a/frappe/public/js/frappe/views/reports/report_view.js b/frappe/public/js/frappe/views/reports/report_view.js index 856061f1f0..f7513611d1 100644 --- a/frappe/public/js/frappe/views/reports/report_view.js +++ b/frappe/public/js/frappe/views/reports/report_view.js @@ -1020,7 +1020,7 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView { name: __('Totals Row'), content: totals[col.id], format: value => { - return frappe.format(value, col.docfield, { always_show_decimals: true }); + return frappe.format(value, col.docfield, { always_show_decimals: true }, data[0]); } } }) diff --git a/frappe/public/js/frappe/widgets/chart_widget.js b/frappe/public/js/frappe/widgets/chart_widget.js index a50acfcd9d..e5378cf2ab 100644 --- a/frappe/public/js/frappe/widgets/chart_widget.js +++ b/frappe/public/js/frappe/widgets/chart_widget.js @@ -40,6 +40,10 @@ export default class ChartWidget extends Widget { setup_container() { this.body.empty(); + if (this.chart_doc.type == 'Heatmap') { + this.setup_heatmap_container(); + } + this.loading = $( `
${__( "Loading..." @@ -57,9 +61,16 @@ export default class ChartWidget extends Widget { this.chart_wrapper = $(`
`); this.chart_wrapper.appendTo(this.body); + this.$heatmap_legend = null; this.set_chart_title(); } + setup_heatmap_container() { + this.widget.addClass('heatmap-chart'); + this.widget.removeClass('full-width').addClass('full-width'); + this.width = 'Full'; + } + set_summary() { if (!this.$summary) { this.$summary = $(`
`).hide(); @@ -104,54 +115,7 @@ export default class ChartWidget extends Widget { } render_time_series_filters() { - let filters = [ - { - label: this.chart_settings.timespan || this.chart_doc.timespan, - options: [ - "Select Date Range", - "Last Year", - "Last Quarter", - "Last Month", - "Last Week" - ], - action: selected_item => { - this.selected_timespan = selected_item; - - if (this.selected_timespan === "Select Date Range") { - this.render_date_range_fields(); - } else { - this.selected_from_date = null; - this.selected_to_date = null; - if (this.date_field_wrapper) { - this.date_field_wrapper.hide(); - - // Title maybe hidden becuase of date range fields - // in half width chart - this.title_field.show(); - this.head.css('flex-direction', "row"); - } - - this.save_chart_config_for_user({ - 'timespan': this.selected_timespan, - 'from_date': null, - 'to_date': null - - }); - this.fetch_and_update_chart(); - } - } - }, - { - label: this.chart_settings.time_interval || this.chart_doc.time_interval, - options: ["Yearly", "Quarterly", "Monthly", "Weekly", "Daily"], - action: selected_item => { - this.selected_time_interval = selected_item; - this.save_chart_config_for_user({'time_interval': this.selected_time_interval}); - this.fetch_and_update_chart(); - } - } - ]; - + let filters = this.get_time_series_filters(); frappe.dashboard_utils.render_chart_filters( filters, "chart-actions", @@ -160,12 +124,77 @@ export default class ChartWidget extends Widget { ); } + get_time_series_filters() { + let filters; + if (this.chart_doc.type == 'Heatmap') { + filters = [{ + label: this.chart_settings.heatmap_year || this.chart_doc.heatmap_year, + options: frappe.dashboard_utils.get_years_since_creation(frappe.boot.user.creation), + action: selected_item => { + this.selected_heatmap_year = selected_item; + this.save_chart_config_for_user({'heatmap_year': this.selected_heatmap_year}); + this.fetch_and_update_chart(); + } + }]; + } else { + filters = [ + { + label: this.chart_settings.timespan || this.chart_doc.timespan, + options: [ + "Select Date Range", + "Last Year", + "Last Quarter", + "Last Month", + "Last Week" + ], + action: selected_item => { + this.selected_timespan = selected_item; + + if (this.selected_timespan === "Select Date Range") { + this.render_date_range_fields(); + } else { + this.selected_from_date = null; + this.selected_to_date = null; + if (this.date_field_wrapper) { + this.date_field_wrapper.hide(); + + // Title maybe hidden becuase of date range fields + // in half width chart + this.title_field.show(); + this.head.css('flex-direction', "row"); + } + + this.save_chart_config_for_user({ + 'timespan': this.selected_timespan, + 'from_date': null, + 'to_date': null + + }); + this.fetch_and_update_chart(); + } + } + }, + { + label: this.chart_settings.time_interval || this.chart_doc.time_interval, + options: ["Yearly", "Quarterly", "Monthly", "Weekly", "Daily"], + action: selected_item => { + this.selected_time_interval = selected_item; + this.save_chart_config_for_user({'time_interval': this.selected_time_interval}); + this.fetch_and_update_chart(); + } + } + ]; + } + return filters; + } + fetch_and_update_chart() { this.args = { timespan: this.selected_timespan || this.chart_settings.timespan, time_interval: this.selected_time_interval || this.chart_settings.time_interval, from_date: this.selected_from_date || this.chart_settings.from_date, - to_date: this.selected_to_date || this.chart_settings.to_date + to_date: this.selected_to_date || this.chart_settings.to_date, + heatmap_year: this.selected_heatmap_year || this.chart_settings.heatmap_year, }; this.fetch(this.filters, true, this.args).then(data => { @@ -274,7 +303,7 @@ export default class ChartWidget extends Widget { }, { label: __("Reset Chart"), - action: "action-list", + action: "action-reset", handler: () => { this.reset_chart(); delete this.dashboard_chart; @@ -332,15 +361,12 @@ export default class ChartWidget extends Widget { } ]; } else { - fields = filters.filter(f => { - if (f.on_change && !f.reqd) { - return false; - } - if (f.get_query || f.get_data) { - f.read_only = 1; - } - return f.fieldname; - }); + fields = filters + .filter(df => df.fieldname) + .map(df => { + Object.assign(df, df.dashboard_config || {}); + return df; + }); } } else { fields = [ @@ -384,6 +410,8 @@ export default class ChartWidget extends Widget { } dialog.show(); + //Set query report object so that it can be used while fetching filter values in the report + frappe.query_report = new frappe.views.QueryReport({'filters': dialog.fields_list}); dialog.set_values(this.filters); } @@ -391,6 +419,9 @@ export default class ChartWidget extends Widget { this.save_chart_config_for_user(null, 1); this.chart_settings = {}; this.filters = null; + this.selected_time_interval = null; + this.selected_timespan = null; + this.selected_heatmap_year = null; } save_chart_config_for_user(config, reset=0) { @@ -458,58 +489,25 @@ export default class ChartWidget extends Widget { time_interval: args && args.time_interval ? args.time_interval : null, timespan: args && args.timespan ? args.timespan : null, from_date: args && args.from_date ? args.from_date : null, - to_date: args && args.to_date ? args.to_date : null + to_date: args && args.to_date ? args.to_date : null, + heatmap_year: args && args.heatmap_year ? args.heatmap_year : null, }; } return frappe.xcall(method, args); } render() { - const chart_type_map = { - Line: "line", - Bar: "bar", - Percentage: "percentage", - Pie: "pie", - Donut: "donut" - }; - - let colors = []; - - if (this.chart_doc.y_axis.length) { - this.chart_doc.y_axis.map(field => { - colors.push(field.color); - }); - } else if (["Line", "Bar"].includes(this.chart_doc.type)) { - colors = [this.chart_doc.color || []]; - } - - if (!this.data || !this.data.labels.length || !Object.keys(this.data).length) { + if (!this.data || !this.data.labels || !Object.keys(this.data).length) { this.chart_wrapper.hide(); this.loading.hide(); - this.$summary.hide(); + this.$summary && this.$summary.hide(); this.empty.show(); } else { this.loading.hide(); this.empty.hide(); this.chart_wrapper.show(); - let chart_args = { - data: this.data, - type: chart_type_map[this.chart_doc.type], - colors: colors, - height: this.height, - axisOptions: { - xIsSeries: this.chart_doc.timeseries, - shortenYAxisNumbers: 1 - } - }; - - if (this.chart_doc.custom_options) { - let custom_options = JSON.parse(this.chart_doc.custom_options); - for (let key in custom_options) { - chart_args[key] = custom_options[key]; - } - } + const chart_args = this.get_chart_args(); if (!this.dashboard_chart) { this.dashboard_chart = new frappe.Chart( @@ -519,7 +517,93 @@ export default class ChartWidget extends Widget { } else { this.dashboard_chart.update(this.data); } + this.width == "Full" && this.summary && this.set_summary(); + this.chart_doc.type == 'Heatmap' && this.render_heatmap_legend(); + } + } + + get_chart_args() { + let colors = this.get_chart_colors(); + + const chart_type_map = { + Line: "line", + Bar: "bar", + Percentage: "percentage", + Pie: "pie", + Donut: "donut", + Heatmap: "heatmap" + }; + + let chart_args = { + data: this.data, + type: chart_type_map[this.chart_doc.type], + colors: colors, + height: this.height, + axisOptions: { + xIsSeries: this.chart_doc.timeseries, + shortenYAxisNumbers: 1 + } + }; + + if (this.chart_doc.type == "Heatmap") { + const heatmap_year = parseInt(this.selected_heatmap_year || this.chart_settings.heatmap_year || this.chart_doc.heatmap_year); + chart_args.data.start = new Date(`${heatmap_year}-01-01`); + chart_args.data.end = new Date(`${heatmap_year+1}-01-01`); + } + + let set_options = (options) => { + let custom_options = JSON.parse(options); + for (let key in custom_options) { + chart_args[key] = custom_options[key]; + } + }; + + if (this.custom_options) { + set_options(this.custom_options); + } + + if (this.chart_doc.custom_options) { + set_options(this.chart_doc.custom_options); + } + + return chart_args; + } + + get_chart_colors() { + let colors = []; + if (this.chart_doc.y_axis.length) { + this.chart_doc.y_axis.map(field => { + colors.push(field.color); + }); + } else if (["Line", "Bar"].includes(this.chart_doc.type)) { + colors = [this.chart_doc.color || "light-blue"]; + } else if (this.chart_doc.type == "Heatmap") { + colors = []; + } + + return colors; + } + + render_heatmap_legend() { + if (!this.$heatmap_legend && this.widget.width() > 991) { + this.$heatmap_legend = + $(` +
+
    +
  • +
  • +
  • +
  • +
  • +
+
+
${__("Less")}
+
${__("More")}
+
+
+ `); + this.body.append(this.$heatmap_legend); } } @@ -542,6 +626,10 @@ export default class ChartWidget extends Widget { let saved_filters = this.chart_settings.filters || null; this.filters = saved_filters || this.filters || JSON.parse(this.chart_doc.filters_json || "[]"); + + if (this.chart_doc.type == 'Heatmap' && !this.chart_doc.heatmap_year) { + this.chart_doc.heatmap_year = frappe.dashboard_utils.get_year(frappe.datetime.now_date()); + } } get_settings() { diff --git a/frappe/public/js/frappe/widgets/number_card_widget.js b/frappe/public/js/frappe/widgets/number_card_widget.js index cda17e08bc..77cb8a59c2 100644 --- a/frappe/public/js/frappe/widgets/number_card_widget.js +++ b/frappe/public/js/frappe/widgets/number_card_widget.js @@ -119,7 +119,8 @@ export default class NumberCardWidget extends Widget { get_formatted_number() { const based_on_df = frappe.meta.get_docfield(this.card_doc.document_type, this.card_doc.aggregate_function_based_on); - const shortened_number = shorten_number(this.number); + const default_country = frappe.sys_defaults.country; + const shortened_number = shorten_number(this.number, default_country); let number_parts = shortened_number.split(' '); const symbol = number_parts[1] || ''; diff --git a/frappe/public/js/frappe/widgets/onboarding_widget.js b/frappe/public/js/frappe/widgets/onboarding_widget.js index 78305edd5d..821824a2d2 100644 --- a/frappe/public/js/frappe/widgets/onboarding_widget.js +++ b/frappe/public/js/frappe/widgets/onboarding_widget.js @@ -25,7 +25,7 @@ export default class OnboardingWidget extends Widget { if (step.is_skipped) { status = "skipped"; - icon_class = "fa-times-circle-o"; + icon_class = "fa-check-circle-o"; } if (step.is_complete) { @@ -56,9 +56,17 @@ export default class OnboardingWidget extends Widget { // Setup actions let actions = { "Watch Video": () => this.show_video(step), - "Create Entry": () => this.show_quick_entry(step), + "Create Entry": () => { + if (step.show_full_form) { + this.create_entry(step); + } else { + this.show_quick_entry(step); + } + }, + "Show Form Tour": () => this.show_form_tour(step), "Update Settings": () => this.update_settings(step), "View Report": () => this.open_report(step), + "Go to Page": () => this.go_to_page(step), }; $step.find("#title").on("click", actions[step.action]); @@ -67,6 +75,24 @@ export default class OnboardingWidget extends Widget { return $step; } + go_to_page(step) { + frappe.set_route(step.path).then(() => { + if (step.callback_message) { + let msg_dialog = frappe.msgprint({ + message: __(step.callback_message), + title: __(step.callback_title), + primary_action: { + action: () => { + msg_dialog.hide(); + }, + label: () => __("Continue"), + }, + wide: true, + }); + } + }); + } + open_report(step) { let route = generate_route({ name: step.reference_report, @@ -74,7 +100,7 @@ export default class OnboardingWidget extends Widget { is_query_report: ["Query Report", "Script Report"].includes( step.report_type ), - doctype: step.report_reference_doctype + doctype: step.report_reference_doctype, }); let current_route = frappe.get_route(); @@ -85,8 +111,10 @@ export default class OnboardingWidget extends Widget { title: __(step.reference_report), primary_action: { action: () => { + frappe.set_route(current_route).then(() => { + this.mark_complete(step); + }); msg_dialog.hide(); - this.mark_complete(step); }, label: () => __("Continue"), }, @@ -105,15 +133,48 @@ export default class OnboardingWidget extends Widget { }); } + show_form_tour(step) { + let route; + if (step.is_single) { + route = `Form/${step.reference_document}`; + } else { + route = `Form/${step.reference_document}/New ${step.reference_document}`; + } + + let current_route = frappe.get_route(); + + frappe.route_hooks = {}; + frappe.route_hooks.after_load = (frm) => { + frm.show_tour(() => { + let msg_dialog = frappe.msgprint({ + message: __("Let's take you back to onboarding"), + title: __("Great Job"), + primary_action: { + action: () => { + frappe.set_route(current_route).then(() => { + this.mark_complete(step); + }); + msg_dialog.hide(); + }, + label: () => __("Continue"), + }, + }); + }); + }; + + frappe.set_route(route); + } + update_settings(step) { let current_route = frappe.get_route(); - frappe.route_options = {}; - frappe.route_options.after_load = (frm) => { + frappe.route_hooks = {}; + frappe.route_hooks.after_load = (frm) => { frm.scroll_to_field(step.field); + frm.doc.__unsaved = true; }; - frappe.route_options.after_save = (frm) => { + frappe.route_hooks.after_save = (frm) => { let success = false; let args = {}; @@ -168,6 +229,44 @@ export default class OnboardingWidget extends Widget { frappe.set_route("Form", step.reference_document); } + create_entry(step) { + let current_route = frappe.get_route(); + + frappe.route_hooks = {}; + let callback = () => { + frappe.msgprint({ + message: __("You're doing great, let's take you back to the onboarding page."), + title: __("Good Work 🎉"), + primary_action: { + action: () => { + frappe.set_route(current_route).then(() => { + this.mark_complete(step); + }); + }, + label: __("Continue"), + }, + }); + + frappe.msg_dialog.custom_onhide = () => { + this.mark_complete(step); + }; + }; + + if (step.is_submittable) { + frappe.route_hooks.after_save = () => { + frappe.msgprint({ + message: __("Submit this document to complete this step."), + title: __("Great") + }); + }; + frappe.route_hooks.after_submit = callback; + } else { + frappe.route_hooks.after_save = callback; + } + + frappe.set_route(`Form/${step.reference_document}/New ${step.reference_document} 1`); + } + show_quick_entry(step) { let current_route = frappe.get_route_str(); frappe.ui.form.make_quick_entry( @@ -185,7 +284,7 @@ export default class OnboardingWidget extends Widget { }); }, label: __("Continue"), - } + }, }); frappe.msg_dialog.custom_onhide = () => { @@ -235,8 +334,10 @@ export default class OnboardingWidget extends Widget { update_step_status(step, status, value, callback) { let icon_class = { is_complete: "fa-check-circle-o", - is_skipped: "fa-times-circle-o", + is_skipped: "fa-check-circle-o", }; + // Clear any hooks + frappe.route_hooks = {}; frappe .call("frappe.desk.desktop.update_onboarding_step", { @@ -358,4 +459,4 @@ export default class OnboardingWidget extends Widget { }); dismiss.appendTo(this.action_area); } -} \ No newline at end of file +} diff --git a/frappe/public/js/frappe/widgets/utils.js b/frappe/public/js/frappe/widgets/utils.js index 59067bd9a0..c92bdc1b5f 100644 --- a/frappe/public/js/frappe/widgets/utils.js +++ b/frappe/public/js/frappe/widgets/utils.js @@ -8,7 +8,9 @@ function generate_route(item) { if (item.link) { route = strip(item.link, "#"); } else if (type === "doctype") { - if (frappe.model.is_single(item.doctype)) { + if (frappe.model.is_tree(item.doctype)) { + route = "Tree/" + item.doctype; + } else if (frappe.model.is_single(item.doctype)) { route = "Form/" + item.doctype; } else { if (item.filters) { @@ -22,6 +24,8 @@ function generate_route(item) { route = "List/" + item.doctype + "/Report/" + item.name; } else if (type === "page") { route = item.name; + } else if (type === "dashboard") { + route = "dashboard/" + item.name; } route = "#" + route; @@ -123,19 +127,44 @@ function go_to_list_with_filters(doctype, filters) { }); } -function shorten_number(number) { +function shorten_number(number, country) { + country = country || ''; + const number_system = get_number_system(country); let x = Math.abs(Math.round(number)); - - switch (true) { - case x >= 1.0e+12: - return Math.round(number/1.0e+12) + " T"; - case x >= 1.0e+9: - return Math.round(number/1.0e+9) + " B"; - case x >= 1.0e+6: - return Math.round(number/1.0e+6) + " M"; - default: - return number.toFixed(); + for (const map of number_system) { + if (x >= map.divisor) { + return Math.round(number/map.divisor) + ' ' + map.symbol; + } } + return number.toFixed(); +} + +function get_number_system(country) { + let number_system_map = { + 'India': + [{ + divisor: 1.0e+7, + symbol: 'Cr' + }, + { + divisor: 1.0e+5, + symbol: 'Lakh' + }], + '': + [{ + divisor: 1.0e+12, + symbol: 'T' + }, + { + divisor: 1.0e+9, + symbol: 'B' + }, + { + divisor: 1.0e+6, + symbol: 'M' + }] + }; + return number_system_map[country]; } export { generate_route, generate_grid, build_summary_item, go_to_list_with_filters, shorten_number }; \ No newline at end of file diff --git a/frappe/public/js/frappe/widgets/widget_dialog.js b/frappe/public/js/frappe/widgets/widget_dialog.js index 31215a40c3..5c44533b37 100644 --- a/frappe/public/js/frappe/widgets/widget_dialog.js +++ b/frappe/public/js/frappe/widgets/widget_dialog.js @@ -145,7 +145,7 @@ class ShortcutDialog extends WidgetDialog { fieldname: "type", label: "Type", reqd: 1, - options: "DocType\nReport\nPage", + options: "DocType\nReport\nPage\nDashboard", onchange: () => { if (this.dialog.get_value("type") == "DocType") { this.dialog.fields_dict.link_to.get_query = () => { diff --git a/frappe/public/js/frappe/widgets/widget_group.js b/frappe/public/js/frappe/widgets/widget_group.js index 8c8dd02968..e82cbc6edf 100644 --- a/frappe/public/js/frappe/widgets/widget_group.js +++ b/frappe/public/js/frappe/widgets/widget_group.js @@ -52,6 +52,7 @@ export default class WidgetGroup {
`); this.widget_area = widget_area; + if (this.hidden) this.widget_area.hide(); this.title_area = widget_area.find(".widget-group-title"); this.control_area = widget_area.find(".widget-group-control"); this.body = widget_area.find(".widget-group-body"); @@ -96,7 +97,7 @@ export default class WidgetGroup { } customize() { - this.widget_area.show(); + if (!this.hidden) this.widget_area.show(); this.widgets_list.forEach((wid) => { wid.customize(this.options); }); diff --git a/frappe/public/less/desktop.less b/frappe/public/less/desktop.less index 1e64533079..eef0b29875 100644 --- a/frappe/public/less/desktop.less +++ b/frappe/public/less/desktop.less @@ -293,6 +293,75 @@ } } + &.dashboard-widget-box.heatmap-chart { + min-height: 0px; + height: 180px; + + .widget-footer { + display: none; + } + + .widget-control { + z-index: 1; + } + + .frappe-chart .chart-legend { + display: none; + } + + .chart-loading-state { + height: 160px !important; + } + + .widget-body { + display: flex; + max-height: 100%; + margin: auto; + margin-top: -15px; + + .chart-container { + height: 100%; + .frappe-chart { + height: 100%; + } + } + + .heatmap-legend { + display: flex; + margin: 45px 20px 0 20px; + + .legend-colors { + padding-left: 1; + padding-left: 15px; + list-style: none; + } + + li { + width: 10px; + height: 10px; + margin: 5px; + } + + .legend-label { + color: #555b51; + font-size: 11px; + margin-left: 15px; + line-height: 1.6em; + } + + @media (max-width: 991px) { + display: none; + } + } + } + } + + @media (max-width: 768px) { + &.dashboard-widget-box.heatmap-chart { + display: none; + } + } + &.onboarding-widget-box { margin-bottom: 50px; margin-top: 10px; diff --git a/frappe/public/less/driver.less b/frappe/public/less/driver.less new file mode 100644 index 0000000000..d331b92e24 --- /dev/null +++ b/frappe/public/less/driver.less @@ -0,0 +1,76 @@ +@import "frappe/public/less/variables.less"; + +div#driver-popover-item { + .driver-popover-footer { + display: block; + margin-top: 12px; + + button { + // Edited + padding: 1px 5px; + font-size: 12px; + line-height: 1.5; + border-radius: 3px; + display: inline-block; + margin-bottom: 0; + font-weight: normal; + text-align: center; + white-space: nowrap; + vertical-align: middle; + text-shadow: none !important; + -ms-touch-action: manipulation; + touch-action: manipulation; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + background-image: none; + border: 1px solid transparent; + } + + .driver-close-btn { + // Edited + float: left; + color: inherit; + background-color: #f0f4f7; + border-color: transparent; + } + + .driver-navigation-btns { + // Edited + .driver-prev-btn { + color: inherit; + background-color: #f0f4f7; + border-color: transparent; + } + + .driver-next-btn { + color: #fff; + background-color: #5e64ff; + border-color: #444bff; + } + } + } + .driver-popover-title { + // Edited + font: 18px/normal sans-serif; + margin: 0 0 5px; + font-weight: 500; + display: block; + position: relative; + line-height: 1.5; + zoom: 1; + } + .driver-popover-description { + // Edited + margin-bottom: 0; + font: 12px/normal sans-serif; + line-height: 1.5; + color: @text-muted; + font-weight: 400; + zoom: 1; + } +} + + diff --git a/frappe/public/less/form.less b/frappe/public/less/form.less index 8e43b05122..df0334c14f 100644 --- a/frappe/public/less/form.less +++ b/frappe/public/less/form.less @@ -314,11 +314,20 @@ h6.uppercase, .h6.uppercase { } } -.form-section:not(:last-child), +.hide-border { + border-top: none !important; + padding-top: 0px; +} + +.form-section:not(:first-child) { + border-top: 1px solid @border-color; +} + .form-inner-toolbar { border-bottom: 1px solid @border-color; } + .empty-section { display: none !important; } diff --git a/frappe/rate_limiter.py b/frappe/rate_limiter.py new file mode 100644 index 0000000000..e29b2b3061 --- /dev/null +++ b/frappe/rate_limiter.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +from __future__ import unicode_literals + +from datetime import datetime +import frappe +from frappe import _ +from frappe.utils import cint +from werkzeug.wrappers import Response + + +def apply(): + rate_limit = frappe.conf.rate_limit + if rate_limit: + frappe.local.rate_limiter = RateLimiter(rate_limit["limit"], rate_limit["window"]) + frappe.local.rate_limiter.apply() + + +def update(): + if hasattr(frappe.local, "rate_limiter"): + frappe.local.rate_limiter.update() + + +def respond(): + if hasattr(frappe.local, "rate_limiter"): + return frappe.local.rate_limiter.respond() + + +class RateLimiter: + def __init__(self, limit, window): + self.limit = int(limit * 1000000) + self.window = window + + self.start = datetime.utcnow() + timestamp = int(frappe.utils.now_datetime().timestamp()) + + self.window_number, self.spent = divmod(timestamp, self.window) + self.key = frappe.cache().make_key(f"rate-limit-counter-{self.window_number}") + self.counter = cint(frappe.cache().get(self.key)) + self.remaining = max(self.limit - self.counter, 0) + self.reset = self.window - self.spent + + self.end = None + self.duration = None + self.rejected = False + + def apply(self): + if self.counter > self.limit: + self.rejected = True + self.reject() + + def reject(self): + raise frappe.TooManyRequestsError + + def update(self): + self.end = datetime.utcnow() + self.duration = int((self.end - self.start).total_seconds() * 1000000) + + pipeline = frappe.cache().pipeline() + pipeline.incrby(self.key, self.duration) + pipeline.expire(self.key, self.window) + pipeline.execute() + + def headers(self): + headers = { + "X-RateLimit-Reset": self.reset, + "X-RateLimit-Limit": self.limit, + "X-RateLimit-Remaining": self.remaining, + } + if self.rejected: + headers["Retry-After"] = self.reset + else: + headers["X-RateLimit-Used"] = self.duration + + return headers + + def respond(self): + if self.rejected: + return Response(_("Too Many Requests"), status=429) diff --git a/frappe/social/doctype/energy_point_settings/energy_point_settings.py b/frappe/social/doctype/energy_point_settings/energy_point_settings.py index 737aab587c..7299eef916 100644 --- a/frappe/social/doctype/energy_point_settings/energy_point_settings.py +++ b/frappe/social/doctype/energy_point_settings/energy_point_settings.py @@ -12,7 +12,7 @@ class EnergyPointSettings(Document): pass def is_energy_point_enabled(): - return frappe.get_cached_value('Energy Point Settings', None, 'enabled') + return frappe.db.get_single_value('Energy Point Settings', 'enabled', True) def allocate_review_points(): settings = frappe.get_single('Energy Point Settings') diff --git a/frappe/tests/test_rate_limiter.py b/frappe/tests/test_rate_limiter.py new file mode 100644 index 0000000000..292b521460 --- /dev/null +++ b/frappe/tests/test_rate_limiter.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +from __future__ import unicode_literals +import unittest +import frappe +import time +import frappe.rate_limiter +from frappe.rate_limiter import RateLimiter +from frappe.utils import cint +from werkzeug.wrappers import Response + + +class TestRateLimiter(unittest.TestCase): + def setUp(self): + pass + + def test_apply_with_limit(self): + frappe.conf.rate_limit = {"window": 86400, "limit": 1} + frappe.rate_limiter.apply() + + self.assertTrue(hasattr(frappe.local, "rate_limiter")) + self.assertIsInstance(frappe.local.rate_limiter, RateLimiter) + + frappe.cache().delete(frappe.local.rate_limiter.key) + delattr(frappe.local, "rate_limiter") + + def test_apply_without_limit(self): + frappe.conf.rate_limit = None + frappe.rate_limiter.apply() + + self.assertFalse(hasattr(frappe.local, "rate_limiter")) + + def test_respond_over_limit(self): + limiter = RateLimiter(0.01, 86400) + time.sleep(0.01) + limiter.update() + + frappe.conf.rate_limit = {"window": 86400, "limit": 0.01} + self.assertRaises(frappe.TooManyRequestsError, frappe.rate_limiter.apply) + frappe.rate_limiter.update() + + response = frappe.rate_limiter.respond() + + self.assertIsInstance(response, Response) + self.assertEqual(response.status_code, 429) + + headers = frappe.local.rate_limiter.headers() + self.assertIn("Retry-After", headers) + self.assertNotIn("X-RateLimit-Used", headers) + self.assertIn("X-RateLimit-Reset", headers) + self.assertIn("X-RateLimit-Limit", headers) + self.assertIn("X-RateLimit-Remaining", headers) + self.assertTrue(int(headers["X-RateLimit-Reset"]) <= 86400) + self.assertEqual(int(headers["X-RateLimit-Limit"]), 10000) + self.assertEqual(int(headers["X-RateLimit-Remaining"]), 0) + + frappe.cache().delete(limiter.key) + frappe.cache().delete(frappe.local.rate_limiter.key) + delattr(frappe.local, "rate_limiter") + + def test_respond_under_limit(self): + frappe.conf.rate_limit = {"window": 86400, "limit": 0.01} + frappe.rate_limiter.apply() + frappe.rate_limiter.update() + response = frappe.rate_limiter.respond() + self.assertEqual(response, None) + + frappe.cache().delete(frappe.local.rate_limiter.key) + delattr(frappe.local, "rate_limiter") + + def test_headers_under_limit(self): + frappe.conf.rate_limit = {"window": 86400, "limit": 0.01} + frappe.rate_limiter.apply() + frappe.rate_limiter.update() + headers = frappe.local.rate_limiter.headers() + self.assertNotIn("Retry-After", headers) + self.assertIn("X-RateLimit-Reset", headers) + self.assertTrue(int(headers["X-RateLimit-Reset"] < 86400)) + self.assertEqual(int(headers["X-RateLimit-Used"]), frappe.local.rate_limiter.duration) + self.assertEqual(int(headers["X-RateLimit-Limit"]), 10000) + self.assertEqual(int(headers["X-RateLimit-Remaining"]), 10000) + + frappe.cache().delete(frappe.local.rate_limiter.key) + delattr(frappe.local, "rate_limiter") + + def test_reject_over_limit(self): + limiter = RateLimiter(0.01, 86400) + time.sleep(0.01) + limiter.update() + + limiter = RateLimiter(0.01, 86400) + self.assertRaises(frappe.TooManyRequestsError, limiter.apply) + + frappe.cache().delete(limiter.key) + + def test_do_not_reject_under_limit(self): + limiter = RateLimiter(0.01, 86400) + time.sleep(0.01) + limiter.update() + + limiter = RateLimiter(0.02, 86400) + self.assertEqual(limiter.apply(), None) + + frappe.cache().delete(limiter.key) + + def test_update_method(self): + limiter = RateLimiter(0.01, 86400) + time.sleep(0.01) + limiter.update() + + self.assertEqual(limiter.duration, cint(frappe.cache().get(limiter.key))) + + frappe.cache().delete(limiter.key) diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index a4c2c4bb70..0a04db2c3e 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -103,7 +103,8 @@ class BackupGenerator: cmd_string = """tar -cf %s %s""" % (backup_path, files_path) err, out = frappe.utils.execute_in_shell(cmd_string) - print('Backed up files', os.path.abspath(backup_path)) + if verbose: + print('Backed up files', os.path.abspath(backup_path)) def take_dump(self): import frappe.utils @@ -151,7 +152,6 @@ def get_backup(): This function is executed when the user clicks on Toos > Download Backup """ - #if verbose: print frappe.db.cur_db_name + " " + conf.db_password delete_temp_backups() odb = BackupGenerator(frappe.conf.db_name, frappe.conf.db_name,\ frappe.conf.db_password, db_host = frappe.db.host) diff --git a/frappe/utils/commands.py b/frappe/utils/commands.py new file mode 100644 index 0000000000..99322b50ba --- /dev/null +++ b/frappe/utils/commands.py @@ -0,0 +1,42 @@ +import functools +import requests +from terminaltables import AsciiTable + + +@functools.lru_cache(maxsize=1024) +def get_first_party_apps(): + """Get list of all apps under orgs: frappe. erpnext from GitHub""" + apps = [] + for org in ["frappe", "erpnext"]: + req = requests.get(f"https://api.github.com/users/{org}/repos", {"type": "sources", "per_page": 200}) + if req.ok: + apps.extend([x["name"] for x in req.json()]) + return apps + + +def render_table(data): + print(AsciiTable(data).table) + + +def add_line_after(function): + """Adds an extra line to STDOUT after the execution of a function this decorates""" + def empty_line(*args, **kwargs): + result = function(*args, **kwargs) + print() + return result + return empty_line + + +def log(message, colour=''): + """Coloured log outputs to STDOUT""" + colours = { + "nc": '\033[0m', + "blue": '\033[94m', + "green": '\033[92m', + "yellow": '\033[93m', + "red": '\033[91m', + "silver": '\033[90m' + } + colour = colours.get(colour, "") + end_line = '\033[0m' + print(colour + message + end_line) diff --git a/frappe/utils/connections.py b/frappe/utils/connections.py new file mode 100644 index 0000000000..6bd24d57ec --- /dev/null +++ b/frappe/utils/connections.py @@ -0,0 +1,44 @@ +import socket + +from six.moves.urllib.parse import urlparse +from frappe import get_conf + +config = get_conf() +REDIS_KEYS = ('redis_cache', 'redis_queue', 'redis_socketio') + + +def is_open(ip, port, timeout=10): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout) + try: + s.connect((ip, int(port))) + s.shutdown(socket.SHUT_RDWR) + return True + except socket.error: + return False + finally: + s.close() + + +def check_database(): + db_type = config.get("db_type", "mariadb") + db_host = config.get("db_host", "localhost") + db_port = config.get("db_port", 3306 if db_type == "mariadb" else 5342) + return {db_type: is_open(db_host, db_port)} + + +def check_redis(redis_services=None): + services = redis_services or REDIS_KEYS + status = {} + for conn in services: + redis_url = urlparse(config.get(conn)).netloc + redis_host, redis_port = redis_url.split(":") + status[conn] = is_open(redis_host, redis_port) + return status + + +def check_connection(redis_services=None): + service_status = {} + service_status.update(check_database()) + service_status.update(check_redis(redis_services)) + return service_status diff --git a/frappe/utils/dashboard.py b/frappe/utils/dashboard.py index bb835d6561..f06f9272b8 100644 --- a/frappe/utils/dashboard.py +++ b/frappe/utils/dashboard.py @@ -41,6 +41,7 @@ def generate_and_cache_results(args, function, cache_key, chart): to_date = args.to_date or None, time_interval = args.time_interval or None, timespan = args.timespan or None, + heatmap_year = args.heatmap_year or None ) except TypeError as e: if str(e) == "'NoneType' object is not iterable": diff --git a/frappe/utils/data.py b/frappe/utils/data.py index 58c74a905d..9796aa3c4a 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -213,6 +213,19 @@ def get_datetime_str(datetime_obj): datetime_obj = get_datetime(datetime_obj) return datetime_obj.strftime(DATETIME_FORMAT) +def get_date_str(date_obj): + if isinstance(date_obj, string_types): + date_obj = get_datetime(date_obj) + return date_obj.strftime(DATE_FORMAT) + +def get_time_str(timedelta_obj): + if isinstance(timedelta_obj, string_types): + timedelta_obj = to_timedelta(timedelta_obj) + + hours, remainder = divmod(timedelta_obj.seconds, 3600) + minutes, seconds = divmod(remainder, 60) + return "{0}:{1}:{2}".format(hours, minutes, seconds) + def get_user_date_format(): """Get the current user date format. The result will be cached.""" if getattr(frappe.local, "user_date_format", None) is None: diff --git a/frappe/utils/global_search.py b/frappe/utils/global_search.py index 3c4b9583f8..0272ae16f4 100644 --- a/frappe/utils/global_search.py +++ b/frappe/utils/global_search.py @@ -81,10 +81,10 @@ def rebuild_for_doctype(doctype): return filters meta = frappe.get_meta(doctype) - + if cint(meta.issingle) == 1: return - + if cint(meta.istable) == 1: parent_doctypes = frappe.get_all("DocField", fields="parent", filters={ "fieldtype": ["in", frappe.model.table_fields], @@ -506,15 +506,13 @@ def web_search(text, scope=None, start=0, limit=20): mariadb_conditions = postgres_conditions = ' '.join([published_condition, scope_condition]) # https://mariadb.com/kb/en/library/full-text-index-overview/#in-boolean-mode - text = '"{}"'.format(text) - mariadb_conditions += 'MATCH(`content`) AGAINST (%(text)s IN BOOLEAN MODE)' - postgres_conditions += 'TO_TSVECTOR("content") @@ PLAINTO_TSQUERY(%(text)s)' + mariadb_conditions += 'MATCH(`content`) AGAINST ({} IN BOOLEAN MODE)'.format(frappe.db.escape('+' + text + '*')) + postgres_conditions += 'TO_TSVECTOR("content") @@ PLAINTO_TSQUERY({})'.format(frappe.db.escape(text)) values = { "scope": "".join([scope, "%"]) if scope else '', "limit": limit, - "start": start, - "text": text + "start": start } result = frappe.db.multisql({ diff --git a/frappe/website/doctype/website_settings/website_settings.js b/frappe/website/doctype/website_settings/website_settings.js index be294258f4..7fbb785acf 100644 --- a/frappe/website/doctype/website_settings/website_settings.js +++ b/frappe/website/doctype/website_settings/website_settings.js @@ -95,4 +95,17 @@ frappe.ui.form.on('Top Bar Item', { label: function(frm, doctype, name) { frm.events.set_parent_options(frm, doctype, name); }, -}); \ No newline at end of file +}); + +frappe.tour['Website Settings'] = [ + { + fieldname: "enable_view_tracking", + title: __("Enable Tracking Page Views"), + description: __("Checking this will enable tracking page views for blogs, web pages, etc."), + }, + { + fieldname: "disable_signup", + title: __("Disable Signup for your site"), + description: __("Check this if you don't want users to sign up for an account on your site. Users won't get desk access unless you explicitly provide it."), + } +]; \ No newline at end of file diff --git a/frappe/website/onboarding/website/website.json b/frappe/website/module_onboarding/website/website.json similarity index 95% rename from frappe/website/onboarding/website/website.json rename to frappe/website/module_onboarding/website/website.json index ee4403557b..b849a809ed 100644 --- a/frappe/website/onboarding/website/website.json +++ b/frappe/website/module_onboarding/website/website.json @@ -6,7 +6,7 @@ ], "creation": "2020-04-26 13:03:30.405135", "docstatus": 0, - "doctype": "Onboarding", + "doctype": "Module Onboarding", "documentation_url": "https://docs.erpnext.com/docs/user/manual/en/website", "idx": 0, "is_complete": 0, diff --git a/frappe/website/render.py b/frappe/website/render.py index ae84d85e5b..c1bca3f5c5 100644 --- a/frappe/website/render.py +++ b/frappe/website/render.py @@ -7,8 +7,10 @@ from frappe import _ import frappe.sessions from frappe.utils import cstr import os, mimetypes, json +import re import six +from bs4 import BeautifulSoup from six import iteritems from werkzeug.wrappers import Response from werkzeug.routing import Map, Rule, NotFound @@ -128,12 +130,35 @@ def build_response(path, data, http_status_code, headers=None): response.headers["X-Page-Name"] = path.encode("ascii", errors="xmlcharrefreplace") response.headers["X-From-Cache"] = frappe.local.response.from_cache or False + add_preload_headers(response) if headers: for key, val in iteritems(headers): response.headers[key] = val.encode("ascii", errors="xmlcharrefreplace") return response + +def add_preload_headers(response): + try: + preload = [] + soup = BeautifulSoup(response.data, "lxml") + for elem in soup.find_all('script', src=re.compile(".*")): + preload.append(("script", elem.get("src"))) + + for elem in soup.find_all('link', rel="stylesheet"): + preload.append(("style", elem.get("href"))) + + links = [] + for type, link in preload: + links.append("; rel=preload; as={}".format(link.lstrip("/"), type)) + + if links: + response.headers["Link"] = ",".join(links) + except Exception: + import traceback + traceback.print_exc() + + def render_page_by_language(path): translated_languages = frappe.get_hooks("translated_languages_for_website") user_lang = guess_language(translated_languages) @@ -180,6 +205,8 @@ def build(path): return build_page(path) else: raise + except Exception: + raise def build_page(path): if not getattr(frappe.local, "path", None): diff --git a/frappe/website/website_theme/standard/standard.json b/frappe/website/website_theme/standard/standard.json index 6135d51a8e..9365d5be27 100644 --- a/frappe/website/website_theme/standard/standard.json +++ b/frappe/website/website_theme/standard/standard.json @@ -18,4 +18,4 @@ "theme": "Standard", "theme_scss": "$enable-shadows: false;\n$enable-gradients: false;\n$enable-rounded: true;\n\n// Bootstrap Variable Overrides\n\n\n@import \"frappe/public/scss/website\";\n\n\n\n// Custom Theme\n", "theme_url": "/assets/css/standard_style.css" -} \ No newline at end of file +} diff --git a/frappe/www/list.py b/frappe/www/list.py index c48cb40d4f..313505b729 100644 --- a/frappe/www/list.py +++ b/frappe/www/list.py @@ -168,7 +168,7 @@ def get_list_context(context, doctype, web_form_name=None): list_context = update_context_from_module(web_form.get_web_form_module(), list_context) # get path from '/templates/' folder of the doctype - if not list_context.row_template: + if not meta.custom and not list_context.row_template: list_context.row_template = meta.get_row_template() return list_context diff --git a/frappe/www/login.py b/frappe/www/login.py index b1abd6a2c4..65952f0154 100644 --- a/frappe/www/login.py +++ b/frappe/www/login.py @@ -20,7 +20,7 @@ def get_context(context): if frappe.session.user != "Guest": if not redirect_to: - redirect_to = "/" if frappe.session.data.user_type=="Website User" else "/desk" + redirect_to = "/me" if frappe.session.data.user_type=="Website User" else "/desk" frappe.local.flags.redirect_location = redirect_to raise frappe.Redirect @@ -96,4 +96,4 @@ def login_via_token(login_token): frappe.local.form_dict.sid = sid frappe.local.login_manager = LoginManager() - redirect_post_login(desk_user = frappe.db.get_value("User", frappe.session.user, "user_type")=="System User") \ No newline at end of file + redirect_post_login(desk_user = frappe.db.get_value("User", frappe.session.user, "user_type")=="System User") diff --git a/package.json b/package.json index bc96a01082..477c7a4857 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "bootstrap": "^4.4.1", "cookie": "^0.4.0", "cssnano": "^4.1.10", + "driver.js": "^0.9.8", "express": "^4.17.1", "fast-deep-equal": "^2.0.1", "frappe-charts": "^1.3.2", diff --git a/requirements.txt b/requirements.txt index f05e0f3870..431f216afa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -59,6 +59,7 @@ semantic-version==2.8.4 six==1.14.0 sqlparse==0.2.4 stripe==2.40.0 +terminaltables==3.1.0 unittest-xml-reporting==2.5.2 urllib3==1.25.8 watchdog==0.8.0 diff --git a/setup.py b/setup.py index c70e138cd2..515e9448c2 100644 --- a/setup.py +++ b/setup.py @@ -58,5 +58,6 @@ setup( cmdclass = \ { 'clean': CleanCommand - } + }, + python_requires='>=3.6' ) diff --git a/yarn.lock b/yarn.lock index 207007cf6a..d858972218 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1730,6 +1730,11 @@ double-ended-queue@^2.1.0-0: resolved "https://registry.yarnpkg.com/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz#103d3527fd31528f40188130c841efdd78264e5c" integrity sha1-ED01J/0xUo9AGIEwyEHv3XgmTlw= +driver.js@^0.9.8: + version "0.9.8" + resolved "https://registry.yarnpkg.com/driver.js/-/driver.js-0.9.8.tgz#4b327f4537b1c9b9fb19419de86174be821ae32a" + integrity sha512-bczjyKdX6XmFyCDkwtRmlaORDwfBk1xXmRO0CAe5VwNQTM98aWaG2LAIiIdTe53iV/B7W5lXlIy2xYtf0JRb7Q== + duplexer3@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2"