diff --git a/cypress.json b/cypress.json index ae0c45c3ae..97ac41bb61 100644 --- a/cypress.json +++ b/cypress.json @@ -2,6 +2,6 @@ "baseUrl": "http://test_site_ui:8000", "projectId": "92odwv", "adminPassword": "admin", - "defaultCommandTimeout": 10000, + "defaultCommandTimeout": 20000, "pageLoadTimeout": 15000 } diff --git a/cypress/integration/control_link.js b/cypress/integration/control_link.js index 658a7fe320..0dc7d5b88e 100644 --- a/cypress/integration/control_link.js +++ b/cypress/integration/control_link.js @@ -1,7 +1,11 @@ context('Control Link', () => { - beforeEach(() => { + before(() => { cy.login(); cy.visit('/desk#workspace/Website'); + }); + + beforeEach(() => { + cy.visit('/desk#workspace/Website'); cy.create_records({ doctype: 'ToDo', description: 'this is a test todo for link' @@ -30,7 +34,7 @@ context('Control Link', () => { cy.get('.frappe-control[data-fieldname=link] input').focus().as('input'); cy.wait('@search_link'); - cy.get('@input').type('todo for link'); + cy.get('@input').type('todo for link', { delay: 200 }); cy.wait('@search_link'); cy.get('.frappe-control[data-fieldname=link] ul').should('be.visible'); cy.get('.frappe-control[data-fieldname=link] input').type('{enter}', { delay: 100 }); 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/cypress/integration/relative_filters.js b/cypress/integration/relative_filters.js index 986c5ce342..411ede62fa 100644 --- a/cypress/integration/relative_filters.js +++ b/cypress/integration/relative_filters.js @@ -1,7 +1,6 @@ context('Relative Timeframe', () => { beforeEach(() => { cy.login(); - cy.visit('/desk#workspace/Website'); }); before(() => { cy.login(); 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/assignment_rule/assignment_rule.py b/frappe/automation/doctype/assignment_rule/assignment_rule.py index 0a5d85636f..bf45347c4f 100644 --- a/frappe/automation/doctype/assignment_rule/assignment_rule.py +++ b/frappe/automation/doctype/assignment_rule/assignment_rule.py @@ -44,7 +44,7 @@ class AssignmentRule(Document): user = self.get_user() assign_to.add(dict( - assign_to = user, + assign_to = [user], doctype = doc.get('doctype'), name = doc.get('name'), description = frappe.render_template(self.description, doc), 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..e615cc49fa 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -106,14 +106,22 @@ def load_desktop_data(bootinfo): from frappe.desk.desktop import get_desk_sidebar_items bootinfo.allowed_modules = get_modules_from_all_apps_for_user() bootinfo.allowed_workspaces = get_desk_sidebar_items(True) + bootinfo.dashboards = frappe.get_all("Dashboard") -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 +192,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/contacts/doctype/contact/contact.py b/frappe/contacts/doctype/contact/contact.py index 99068dcf6d..4cf209541c 100644 --- a/frappe/contacts/doctype/contact/contact.py +++ b/frappe/contacts/doctype/contact/contact.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import frappe -from frappe.utils import cstr, has_gravatar +from frappe.utils import cstr, has_gravatar, cint from frappe import _ from frappe.model.document import Document from frappe.core.doctype.dynamic_link.dynamic_link import deduplicate_dynamic_links @@ -133,7 +133,7 @@ def get_default_contact(doctype, name): dl.parenttype = "Contact"''', (doctype, name)) if out: - return sorted(out, key = functools.cmp_to_key(lambda x,y: cmp(y[1], x[1])))[0][0] + return sorted(out, key = functools.cmp_to_key(lambda x,y: cmp(cint(y[1]), cint(x[1]))))[0][0] else: return None 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/website/web_template/section_with_left_image/__init__.py b/frappe/core/doctype/installed_applications/__init__.py similarity index 100% rename from frappe/website/web_template/section_with_left_image/__init__.py rename to frappe/core/doctype/installed_applications/__init__.py 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.css b/frappe/core/page/dashboard/dashboard.css index e69de29bb2..b319cc1ed2 100644 --- a/frappe/core/page/dashboard/dashboard.css +++ b/frappe/core/page/dashboard/dashboard.css @@ -0,0 +1,5 @@ +.restricted-button { + cursor: default; + position: relative; + right: -5px; +} \ No newline at end of file diff --git a/frappe/core/page/dashboard/dashboard.js b/frappe/core/page/dashboard/dashboard.js index 222a31a863..f17bc1e0b5 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,11 +21,18 @@ 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"); this.page = wrapper.page; + + this.page.set_title_sub( + $(``) + ); } show() { 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..6ca101c3a8 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 @@ -134,15 +159,18 @@ class Workspace: } def get_cards(self): - cards = self.doc.cards + get_custom_reports_and_doctypes(self.doc.module) + cards = self.doc.cards + if not self.doc.hide_custom: + cards = cards + get_custom_reports_and_doctypes(self.doc.module) + if len(self.extended_cards): cards = cards + self.extended_cards 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 +277,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 +322,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..e2be095fce 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,19 @@ 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']); - } else { - frm.set_df_property('type', 'options', ['Line', 'Bar']); - } - 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 +291,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 +351,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..4bab76337f 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,18 +124,18 @@ "label": "Chart Options" }, { + "default": "Line", "fieldname": "type", "fieldtype": "Select", "label": "Type", - "options": "Line\nBar\nPercentage\nPie\nDonut", - "reqd": 1 + "options": "Line\nBar\nPercentage\nPie\nDonut\nHeatmap" }, { "fieldname": "column_break_2", "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" @@ -228,10 +229,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-16 15:03:02.455395", "modified_by": "Administrator", "module": "Desk", "name": "Dashboard Chart", diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py index 417ef2ba82..ab1863ca0b 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 @@ -107,11 +110,11 @@ def create_dashboard_chart(args): doc.insert(ignore_permissions=True) return doc - @frappe.whitelist() def create_report_chart(args): - create_dashboard_chart(args) + doc = create_dashboard_chart(args) args = frappe.parse_json(args) + args.chart_name = doc.chart_name if args.dashboard: add_chart_to_dashboard(json.dumps(args)) @@ -134,7 +137,6 @@ def get_chart_config(chart, filters, timespan, timegrain, from_date, to_date): to_date = datetime.datetime.now() doctype = chart.document_type - unit_function = get_unit_function(doctype, chart.based_on, timegrain) datefield = chart.based_on aggregate_function = get_aggregate_function(chart.chart_type) value_field = chart.value_based_on or '1' @@ -144,26 +146,21 @@ def get_chart_config(chart, filters, timespan, timegrain, from_date, to_date): filters.append([doctype, datefield, '>=', from_date, False]) filters.append([doctype, datefield, '<=', to_date, False]) - data = frappe.db.get_all( + data = frappe.db.get_list( doctype, fields = [ - 'extract(year from `tab{doctype}`.{datefield}) as _year'.format(doctype=doctype, datefield=datefield), - '{} as _unit'.format(unit_function), + '{} as _unit'.format(datefield), '{aggregate_function}({value_field})'.format(aggregate_function=aggregate_function, value_field=value_field), ], filters = filters, - group_by = '_year, _unit', - order_by = '_year asc, _unit asc', + group_by = '_unit', + order_by = '_unit asc', as_list = True, ignore_ifnull = True ) + result = get_result(data, timegrain, from_date, to_date) - # result given as year, unit -> convert it to end of period of that unit - result = convert_to_dates(data, timegrain) - - # add missing data points for periods where there was no result - result = add_missing_values(result, timegrain, timespan, from_date, to_date) chart_config = { "labels": [formatdate(r[0].strftime('%Y-%m-%d')) for r in result], "datasets": [{ @@ -174,6 +171,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): @@ -182,7 +214,7 @@ def get_group_by_chart_config(chart, filters): group_by_field = chart.group_by_based_on doctype = chart.document_type - data = frappe.db.get_all( + data = frappe.db.get_list( doctype, fields = [ '{} as name'.format(group_by_field), @@ -223,75 +255,22 @@ def get_aggregate_function(chart_type): }[chart_type] -def convert_to_dates(data, timegrain): - """ Converts individual dates within data to the end of period """ - result = [] - for d in data: - if d[2] != 0: - if timegrain == 'Daily': - result.append([add_to_date('{:d}-01-01'.format(int(d[0])), days = d[1] - 1), d[2]]) - elif timegrain == 'Weekly': - result.append([add_to_date(add_to_date('{:d}-01-01'.format(int(d[0])), weeks = d[1] + 1), days = -1), d[2]]) - elif timegrain == 'Monthly': - result.append([add_to_date(add_to_date('{:d}-01-01'.format(int(d[0])), months=d[1]), days = -1), d[2]]) - elif timegrain == 'Quarterly': - result.append([add_to_date(add_to_date('{:d}-01-01'.format(int(d[0])), months=d[1] * 3), days = -1), d[2]]) - elif timegrain == 'Yearly': - result.append([add_to_date(add_to_date('{:d}-01-01'.format(int(d[0])), months=12), days = -1), d[2]]) - result[-1][0] = getdate(result[-1][0]) - - return result - -def get_unit_function(doctype, datefield, timegrain): - unit_function = '' - if timegrain=='Daily': - if frappe.db.db_type == 'mariadb': - unit_function = 'dayofyear(`tab{doctype}`.{datefield})'.format( - doctype=doctype, datefield=datefield) - else: - unit_function = 'extract(doy from `tab{doctype}`.{datefield})'.format( - doctype=doctype, datefield=datefield) - - else: - unit_function = 'extract({unit} from `tab{doctype}`.{datefield})'.format( - unit = timegrain[:-2].lower(), doctype=doctype, datefield=datefield) - - return unit_function - -def add_missing_values(data, timegrain, timespan, from_date, to_date): - # add missing intervals +def get_result(data, timegrain, from_date, to_date): + start_date = getdate(from_date) + end_date = getdate(to_date) result = [] - if timespan != 'All Time': - first_expected_date = get_period_ending(from_date, timegrain) - # fill out data before the first data point - first_data_point_date = data[0][0] if data else getdate(add_to_date(to_date, days=1)) - while first_data_point_date > first_expected_date: - result.append([first_expected_date, 0.0]) - first_expected_date = get_next_expected_date(first_expected_date, timegrain) + while start_date <= end_date: + next_date = get_next_expected_date(start_date, timegrain) + result.append([next_date, 0.0]) + start_date = next_date - # fill data points and missing points - for i, d in enumerate(data): - result.append(d) - - next_expected_date = get_next_expected_date(d[0], timegrain) - - if i < len(data)-1: - next_date = data[i+1][0] - else: - # already reached at end of data, see if we need any more dates - next_date = getdate(nowdate()) - - # if next data point is earler than the expected date - # need to fill out missing data points - while next_date > next_expected_date: - # fill missing value - result.append([next_expected_date, 0.0]) - next_expected_date = get_next_expected_date(next_expected_date, timegrain) - - # add date for the last period (if missing) - if result and get_period_ending(to_date, timegrain) > result[-1][0]: - result.append([get_period_ending(to_date, timegrain), 0.0]) + data_index = 0 + if data: + for i, d in enumerate(result): + while data_index < len(data) and getdate(data[data_index][0]) <= d[0]: + d[1] += data[data_index][1] + data_index += 1 return result @@ -320,17 +299,12 @@ def get_period_ending(date, timegrain): return getdate(date) def get_week_ending(date): - # fun fact: week ends on the day before 1st Jan of the year. - # for 2019 it is Monday + # week starts on monday + from datetime import timedelta + start = date - timedelta(days = date.weekday()) + end = start + timedelta(days=6) - week_of_the_year = int(date.strftime('%U')) - - if week_of_the_year == 52: - date = add_to_date(date, years=1) - # first day of next week - date = add_to_date('{}-01-01'.format(date.year), weeks = (week_of_the_year%52) + 1) - # last day of this week - return add_to_date(date, days=-1) + return end def get_month_ending(date): month_of_the_year = int(date.strftime('%m')) @@ -397,11 +371,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/dashboard_chart/test_dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py index 4425c4fd45..dfc6edbf58 100644 --- a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py @@ -17,10 +17,9 @@ class TestDashboardChart(unittest.TestCase): self.assertEqual(get_period_ending('2019-04-10', 'Daily'), getdate('2019-04-10')) - # fun fact: week ends on the day before 1st Jan of the year. - # for 2019 it is Monday + # week starts on monday self.assertEqual(get_period_ending('2019-04-10', 'Weekly'), - getdate('2019-04-15')) + getdate('2019-04-14')) self.assertEqual(get_period_ending('2019-04-10', 'Monthly'), getdate('2019-04-30')) @@ -133,6 +132,34 @@ class TestDashboardChart(unittest.TestCase): frappe.db.rollback() + def test_weekly_dashboard_chart(self): + insert_test_records() + + if frappe.db.exists('Dashboard Chart', 'Test Weekly Dashboard Chart'): + frappe.delete_doc('Dashboard Chart', 'Test Weekly Dashboard Chart') + + frappe.get_doc(dict( + doctype = 'Dashboard Chart', + chart_name = 'Test Weekly Dashboard Chart', + chart_type = 'Sum', + document_type = 'Communication', + based_on = 'communication_date', + value_based_on = 'rating', + timespan = 'Select Date Range', + time_interval = 'Weekly', + from_date = datetime(2018, 12, 30), + to_date = datetime(2019, 1, 15), + filters_json = '[]', + timeseries = 1 + )).insert() + + result = get(chart_name ='Test Weekly Dashboard Chart', refresh = 1) + + self.assertEqual(result.get('datasets')[0].get('values'), [200.0, 400.0, 0.0]) + self.assertEqual(result.get('labels'), [formatdate('2019-01-06'), formatdate('2019-01-13'), formatdate('2019-01-20')]) + + frappe.db.rollback() + def test_group_by_chart_type(self): if frappe.db.exists('Dashboard Chart', 'Test Group By Dashboard Chart'): frappe.delete_doc('Dashboard Chart', 'Test Group By Dashboard Chart') @@ -155,17 +182,16 @@ class TestDashboardChart(unittest.TestCase): frappe.db.rollback() - def test_dashboard_with_single_doctype(self): - if frappe.db.exists('Dashboard Chart', 'Test Single DocType In Dashboard Chart'): - frappe.delete_doc('Dashboard Chart', 'Test Single DocType In Dashboard Chart') +def insert_test_records(): + create_new_communication(datetime(2019, 1, 10), 100) + create_new_communication(datetime(2019, 1, 6), 200) + create_new_communication(datetime(2019, 1, 8), 300) - chart_doc = frappe.get_doc(dict( - doctype = 'Dashboard Chart', - chart_name = 'Test Single DocType In Dashboard Chart', - chart_type = 'Count', - document_type = 'System Settings', - group_by_based_on = 'Created On', - filters_json = '{}', - )) - - self.assertRaises(frappe.ValidationError, chart_doc.insert) +def create_new_communication(date, rating): + communication = { + 'doctype': 'Communication', + 'subject': 'Test Communication', + 'rating': rating, + 'communication_date': date + } + frappe.get_doc(communication).insert() 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..2b8aea5e6c 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", @@ -21,6 +21,7 @@ "disable_user_customization", "pin_to_top", "pin_to_bottom", + "hide_custom", "section_break_2", "charts_label", "charts", @@ -170,7 +171,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 +189,18 @@ "fieldname": "onboarding", "fieldtype": "Link", "label": "Onboarding", - "options": "Onboarding" + "options": "Module Onboarding" + }, + { + "default": "0", + "description": "Checking this will hide custom doctypes and reports cards in Links section", + "fieldname": "hide_custom", + "fieldtype": "Check", + "label": "Hide Custom DocTypes and Reports" } ], "links": [], - "modified": "2020-04-26 12:21:46.205079", + "modified": "2020-05-18 19:17:27.206646", "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/event/test_event.py b/frappe/desk/doctype/event/test_event.py index 6d1e865a45..dcfb38bd08 100644 --- a/frappe/desk/doctype/event/test_event.py +++ b/frappe/desk/doctype/event/test_event.py @@ -71,7 +71,7 @@ class TestEvent(unittest.TestCase): ev = frappe.get_doc(self.test_records[0]).insert() add({ - "assign_to": "test@example.com", + "assign_to": ["test@example.com"], "doctype": "Event", "name": ev.name, "description": "Test Assignment" @@ -83,7 +83,7 @@ class TestEvent(unittest.TestCase): # add another one add({ - "assign_to": self.test_user, + "assign_to": [self.test_user], "doctype": "Event", "name": ev.name, "description": "Test Assignment" 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 96% rename from frappe/desk/doctype/onboarding/onboarding.json rename to frappe/desk/doctype/module_onboarding/module_onboarding.json index b1d563a9dc..0667ddf6ad 100644 --- a/frappe/desk/doctype/onboarding/onboarding.json +++ b/frappe/desk/doctype/module_onboarding/module_onboarding.json @@ -90,10 +90,10 @@ } ], "links": [], - "modified": "2020-05-01 19:37:21.492405", + "modified": "2020-05-18 19:42:39.738869", "modified_by": "Administrator", "module": "Desk", - "name": "Onboarding", + "name": "Module Onboarding", "owner": "Administrator", "permissions": [ { @@ -118,6 +118,7 @@ "share": 1 } ], + "read_only": 1, "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 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/notification_log/test_notification_log.py b/frappe/desk/doctype/notification_log/test_notification_log.py index fe7d56c081..e59aee30c9 100644 --- a/frappe/desk/doctype/notification_log/test_notification_log.py +++ b/frappe/desk/doctype/notification_log/test_notification_log.py @@ -13,7 +13,7 @@ class TestNotificationLog(unittest.TestCase): user = get_user() assign_task({ - "assign_to": user, + "assign_to": [user], "doctype": 'ToDo', "name": todo.name, "description": todo.description diff --git a/frappe/desk/doctype/number_card/number_card.py b/frappe/desk/doctype/number_card/number_card.py index 64f517bffc..6bb9c7d45c 100644 --- a/frappe/desk/doctype/number_card/number_card.py +++ b/frappe/desk/doctype/number_card/number_card.py @@ -70,7 +70,7 @@ def get_result(doc, to_date=None): if to_date: filters.append([doc.document_type, 'creation', '<', to_date, False]) - res = frappe.db.get_all(doc.document_type, fields=fields, filters=filters) + res = frappe.db.get_list(doc.document_type, fields=fields, filters=filters) number = res[0]['result'] if res else 0 return cint(number) 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..365a1c7d21 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-18 19:42:30.435604", "modified_by": "Administrator", "module": "Desk", "name": "Onboarding Step", @@ -159,6 +213,7 @@ } ], "quick_entry": 1, + "read_only": 1, "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 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/form/assign_to.py b/frappe/desk/form/assign_to.py index 76c7caa63d..a916cbca82 100644 --- a/frappe/desk/form/assign_to.py +++ b/frappe/desk/form/assign_to.py @@ -11,6 +11,7 @@ from frappe.desk.doctype.notification_log.notification_log import enqueue_create get_title, get_title_html import frappe.utils import frappe.share +import json class DuplicateToDoError(frappe.ValidationError): pass @@ -19,17 +20,17 @@ def get(args=None): if not args: args = frappe.local.form_dict - return frappe.get_all('ToDo', fields = ['owner', 'description'], filters = dict( + return frappe.get_all('ToDo', fields=['owner', 'name'], filters=dict( reference_type = args.get('doctype'), reference_name = args.get('name'), status = ('!=', 'Cancelled') - ), limit = 5) + ), limit=5) @frappe.whitelist() def add(args=None): """add in someone's to do list args = { - "assign_to": , + "assign_to": [], "doctype": , "name": , "description": , @@ -40,56 +41,68 @@ def add(args=None): if not args: args = frappe.local.form_dict - if frappe.db.sql("""SELECT `owner` - FROM `tabToDo` - WHERE `reference_type`=%(doctype)s - AND `reference_name`=%(name)s - AND `status`='Open' - AND `owner`=%(assign_to)s""", args): - frappe.throw(_("Already in user's To Do list"), DuplicateToDoError) - else: - from frappe.utils import nowdate + users_with_duplicate_todo = [] + shared_with_users = [] - if not args.get('description'): - args['description'] = _('Assignment for {0} {1}').format(args['doctype'], args['name']) - - d = frappe.get_doc({ - "doctype":"ToDo", - "owner": args['assign_to'], + for assign_to in frappe.parse_json(args.get("assign_to")): + filters = { "reference_type": args['doctype'], "reference_name": args['name'], - "description": args.get('description'), - "priority": args.get("priority", "Medium"), "status": "Open", - "date": args.get('date', nowdate()), - "assigned_by": args.get('assigned_by', frappe.session.user), - 'assignment_rule': args.get('assignment_rule') - }).insert(ignore_permissions=True) + "owner": assign_to + } - # set assigned_to if field exists - if frappe.get_meta(args['doctype']).get_field("assigned_to"): - frappe.db.set_value(args['doctype'], args['name'], "assigned_to", args['assign_to']) + if frappe.get_all("ToDo", filters=filters): + users_with_duplicate_todo.append(assign_to) + else: + from frappe.utils import nowdate - doc = frappe.get_doc(args['doctype'], args['name']) + if not args.get('description'): + args['description'] = _('Assignment for {0} {1}').format(args['doctype'], args['name']) - # if assignee does not have permissions, share - if not frappe.has_permission(doc=doc, user=args['assign_to']): - frappe.share.add(doc.doctype, doc.name, args['assign_to']) - frappe.msgprint(_('Shared with user {0} with read access').format(args['assign_to']), alert=True) + d = frappe.get_doc({ + "doctype": "ToDo", + "owner": assign_to, + "reference_type": args['doctype'], + "reference_name": args['name'], + "description": args.get('description'), + "priority": args.get("priority", "Medium"), + "status": "Open", + "date": args.get('date', nowdate()), + "assigned_by": args.get('assigned_by', frappe.session.user), + 'assignment_rule': args.get('assignment_rule') + }).insert(ignore_permissions=True) - # make this document followed by assigned user - follow_document(args['doctype'], args['name'], args['assign_to']) + # set assigned_to if field exists + if frappe.get_meta(args['doctype']).get_field("assigned_to"): + frappe.db.set_value(args['doctype'], args['name'], "assigned_to", assign_to) - # notify - notify_assignment(d.assigned_by, d.owner, d.reference_type, d.reference_name, action='ASSIGN',\ - description=args.get("description")) + doc = frappe.get_doc(args['doctype'], args['name']) + + # if assignee does not have permissions, share + if not frappe.has_permission(doc=doc, user=assign_to): + frappe.share.add(doc.doctype, doc.name, assign_to) + shared_with_users.append(assign_to) + + # make this document followed by assigned user + follow_document(args['doctype'], args['name'], assign_to) + + # notify + notify_assignment(d.assigned_by, d.owner, d.reference_type, d.reference_name, action='ASSIGN', + description=args.get("description")) + + if shared_with_users: + user_list = format_message_for_assign_to(shared_with_users) + frappe.msgprint(_("Shared with the following Users with Read access:{0}").format(user_list, alert=True)) + + if users_with_duplicate_todo: + user_list = format_message_for_assign_to(users_with_duplicate_todo) + frappe.msgprint(_("Already in the following Users ToDo list:{0}").format(user_list, alert=True)) return get(args) @frappe.whitelist() def add_multiple(args=None): - import json - if not args: args = frappe.local.form_dict @@ -183,3 +196,5 @@ def notify_assignment(assigned_by, owner, doc_type, doc_name, action='CLOSE', enqueue_create_notification(owner, notification_doc) +def format_message_for_assign_to(users): + return "

" + "
".join(users) \ No newline at end of file diff --git a/frappe/desk/form/linked_with.py b/frappe/desk/form/linked_with.py index 6c679bf312..72917d0341 100644 --- a/frappe/desk/form/linked_with.py +++ b/frappe/desk/form/linked_with.py @@ -13,7 +13,7 @@ from frappe.modules import load_doctype_module @frappe.whitelist() -def get_submitted_linked_docs(doctype, name, docs=None): +def get_submitted_linked_docs(doctype, name, docs=None, linked=None): """ Get all nested submitted linked doctype linkinfo @@ -31,12 +31,26 @@ def get_submitted_linked_docs(doctype, name, docs=None): if not docs: docs = [] + if not linked: + linked = {} + linkinfo = get_linked_doctypes(doctype) linked_docs = get_linked_docs(doctype, name, linkinfo) link_count = 0 for link_doctype, link_names in linked_docs.items(): + if link_doctype not in linked: + linked[link_doctype] = [] + for link in link_names: + if link['name'] == name: + continue + + if linked and name in linked[link_doctype]: + continue + + linked[link_doctype].append(link['name']) + docinfo = link.update({"doctype": link_doctype}) validated_doc = validate_linked_doc(docinfo) @@ -47,7 +61,7 @@ def get_submitted_linked_docs(doctype, name, docs=None): if link.name in [doc.get("name") for doc in docs]: continue - links = get_submitted_linked_docs(link_doctype, link.name, docs) + links = get_submitted_linked_docs(link_doctype, link.name, docs, linked) docs.append({ "doctype": link_doctype, "name": link.name, 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/desk/query_report.py b/frappe/desk/query_report.py index 74e841f107..0edfd57d4f 100644 --- a/frappe/desk/query_report.py +++ b/frappe/desk/query_report.py @@ -62,8 +62,16 @@ def generate_report_result(report, filters=None, user=None, custom_columns=None) ljust_list(res, 6) if report.custom_columns: + # Original query columns, needed to reorder data as per custom columns + query_columns = columns + # Reordered columns columns = json.loads(report.custom_columns) + + if report.report_type == 'Query Report': + result = reorder_data_for_custom_columns(columns, query_columns, result) + result = add_data_to_custom_columns(columns, result) + if custom_columns: result = add_data_to_custom_columns(custom_columns, result) @@ -208,6 +216,23 @@ def add_data_to_custom_columns(columns, result): return data +def reorder_data_for_custom_columns(custom_columns, columns, result): + reordered_result = [] + columns = [col.split(":")[0] for col in columns] + + for res in result: + r = [] + for col in custom_columns: + try: + idx = columns.index(col.get("label")) + r.append(res[idx]) + except ValueError: + pass + + reordered_result.append(r) + + return reordered_result + def get_prepared_report_result(report, filters, dn="", user=None): latest_report_data = {} doc = None 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..0c5ec75597 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -437,40 +437,47 @@ class Meta(Document): if not self.custom: for hook in frappe.get_hooks("override_doctype_dashboards", {}).get(self.name, []): - data = frappe.get_attr(hook)(data=data) + data = frappe._dict(frappe.get_attr(hook)(data=data)) return data 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..d164258c42 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -278,4 +278,8 @@ frappe.patches.v13_0.set_path_for_homepage_in_web_page_view 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.set_unique_for_page_view frappe.patches.v13_0.remove_tailwind_from_page_builder +frappe.patches.v13_0.rename_onboarding +frappe.patches.v13_0.email_unsubscribe +execute:frappe.delete_doc("Web Template", "Section with Left Image", force=1) 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/patches/v13_0/set_unique_for_page_view.py b/frappe/patches/v13_0/set_unique_for_page_view.py new file mode 100644 index 0000000000..2a084e52e3 --- /dev/null +++ b/frappe/patches/v13_0/set_unique_for_page_view.py @@ -0,0 +1,6 @@ +import frappe + +def execute(): + frappe.reload_doc('website', 'doctype', 'web_page_view', force=True) + site_url = frappe.utils.get_site_url(frappe.local.site) + frappe.db.sql("""UPDATE `tabWeb Page View` set is_unique=1 where referrer LIKE '%{0}%'""".format(site_url)) diff --git a/frappe/public/build.json b/frappe/public/build.json index df3d71f537..997a3092ad 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,12 +243,15 @@ "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": [ "public/less/form_grid.less" ], "js/form.min.js": [ + "public/js/frappe/form/templates/address_list.html", + "public/js/frappe/form/templates/contact_list.html", "public/js/frappe/form/templates/print_layout.html", "public/js/frappe/form/templates/users_in_sidebar.html", "public/js/frappe/form/templates/set_sharing.html", diff --git a/frappe/public/css/email.css b/frappe/public/css/email.css index 92ac433fd2..40c6149927 100644 --- a/frappe/public/css/email.css +++ b/frappe/public/css/email.css @@ -1,82 +1,64 @@ /* csslint ignore:start */ - /* palette colors*/ - body { line-height: 1.5; color: #36414c; } - p { margin: 1em 0 !important; } - hr { border-top: 1px solid #d1d8dd; } - .body-table { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; } - .body-table td { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; } - .email-header, .email-body, .email-footer { width: 100% !important; min-width: 100% !important; } - .email-body { font-size: 14px; } - .email-footer { border-top: 1px solid #d1d8dd; font-size: 12px; } - .email-header { border: 1px solid #d1d8dd; border-radius: 4px 4px 0 0; } - .email-header .brand-image { width: 24px; height: 24px; display: block; } - .email-header-title { font-weight: bold; } - .body-table.has-header .email-body { border: 1px solid #d1d8dd; border-radius: 0 0 4px 4px; border-top: none; } - .body-table.has-header .email-footer { border-top: none; } - .email-footer-container { margin-top: 30px; } - .email-footer-container > div:not(:last-child) { margin-bottom: 5px; } - .email-unsubscribe a { color: #8d99a6; text-decoration: underline; } - .btn { text-decoration: none; padding: 7px 10px; @@ -84,24 +66,20 @@ hr { border: 1px solid; border-radius: 3px; } - .btn.btn-default { color: #fff; background-color: #f0f4f7; border-color: transparent; } - .btn.btn-primary { color: #fff; background-color: #5e64ff; border-color: #444bff; } - .table { width: 100%; border-collapse: collapse; } - .table td, .table th { padding: 8px; @@ -110,68 +88,53 @@ hr { border-top: 1px solid #d1d8dd; text-align: left; } - .table th { font-weight: bold; } - .table > thead > tr > th { vertical-align: middle; border-bottom: 2px solid #d1d8dd; } - .table > thead:first-child > tr:first-child > th { border-top: none; } - .table.table-bordered { border: 1px solid #d1d8dd; } - .table.table-bordered td, .table.table-bordered th { border: 1px solid #d1d8dd; } - .more-info { font-size: 80% !important; color: #8d99a6 !important; border-top: 1px solid #ebeff2; padding-top: 10px; } - .text-right { text-align: right !important; } - .text-center { text-align: center !important; } - .text-muted { color: #8d99a6 !important; } - .text-extra-muted { color: #d1d8dd !important; } - .text-regular { font-size: 14px; } - .text-medium { font-size: 12px; } - .text-small { font-size: 10px; } - .text-bold { font-weight: bold; } - .indicator { width: 8px; height: 8px; @@ -180,43 +143,33 @@ hr { display: inline-block; margin-right: 5px; } - .indicator.indicator-blue { background-color: #5e64ff; } - .indicator.indicator-green { background-color: #98d85b; } - .indicator.indicator-orange { background-color: #ffa00a; } - .indicator.indicator-red { background-color: #ff5858; } - .indicator.indicator-yellow { background-color: #feef72; } - .screenshot { box-shadow: 0px 3px 6px rgba(0, 0, 0, 0.1); border: 1px solid #d1d8dd; margin: 8px 0; max-width: 100%; } - .list-unstyled { list-style-type: none; padding: 0; } - /* auto email report */ - .report-title { margin-bottom: 20px; } - /* csslint ignore:end */ 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/controls/multiselect_pills.js b/frappe/public/js/frappe/form/controls/multiselect_pills.js index 8796c95eaa..6190204357 100644 --- a/frappe/public/js/frappe/form/controls/multiselect_pills.js +++ b/frappe/public/js/frappe/form/controls/multiselect_pills.js @@ -129,7 +129,8 @@ frappe.ui.form.ControlMultiSelectPills = frappe.ui.form.ControlAutocomplete.exte get_data() { let data; if(this.df.get_data) { - data = this.df.get_data(); + let txt = this.$input.val(); + data = this.df.get_data(txt); if (data && data.then) { data.then((r) => { this.set_data(r); 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/multi_select_dialog.js b/frappe/public/js/frappe/form/multi_select_dialog.js index cdd385a6ea..41b87e0207 100644 --- a/frappe/public/js/frappe/form/multi_select_dialog.js +++ b/frappe/public/js/frappe/form/multi_select_dialog.js @@ -1,110 +1,62 @@ -// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -// MIT License. See license.txt - -frappe.ui.form.MultiSelectDialog = Class.extend({ - init: function(opts) { - /* Options: doctype, target, setters, get_query, action */ - $.extend(this, opts); - +frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { + constructor(opts) { + /* Options: doctype, target, setters, get_query, action, add_filters_group, data_fields, primary_action_label */ + Object.assign(this, opts); var me = this; - if(this.doctype!="[Select]") { - frappe.model.with_doctype(this.doctype, function(r) { + if (this.doctype != "[Select]") { + frappe.model.with_doctype(this.doctype, function () { me.make(); }); } else { this.make(); } - }, - make: function() { - let me = this; + } + make() { + let me = this; this.page_length = 20; this.start = 0; + let fields = this.get_primary_filters(); - let fields = [ - { - fieldtype: "Data", - label: __("Search Term"), - fieldname: "search_term" - }, - { - fieldtype: "Column Break" - } - ]; - let count = 0; - if(!this.date_field) { - this.date_field = "transaction_date"; - } - - // setters can be defined as a dict or a list of fields - // setters define the additional filters that get applied - // for selection - - // CASE 1: DocType name and fieldname is the same, example "customer" and "customer" - // setters define the filters applied in the modal - // if the fieldnames and doctypes are consistently named, - // pass a dict with the setter key and value, for example - // {customer: [customer_name]} - - // CASE 2: if the fieldname of the target is different, - // then pass a list of fields with appropriate fieldname - - if($.isArray(this.setters)) { - for (let df of this.setters) { - fields.push(df, {fieldtype: "Column Break"}); - } - } else { - Object.keys(this.setters).forEach(function(setter) { - fields.push({ - fieldtype: me.target.fields_dict[setter].df.fieldtype, - label: me.target.fields_dict[setter].df.label, - fieldname: setter, - options: me.target.fields_dict[setter].df.options, - default: me.setters[setter] - }); - if (count++ < Object.keys(me.setters).length) { - fields.push({fieldtype: "Column Break"}); - } - }); - } - + // Make results area fields = fields.concat([ - { - "fieldname":"date_range", - "label": __("Date Range"), - "fieldtype": "DateRange", - }, - { fieldtype: "Section Break" }, { fieldtype: "HTML", fieldname: "results_area" }, - { fieldtype: "Button", fieldname: "more_btn", label: __("More"), - click: function(){ - me.start += 20; - frappe.flags.auto_scroll = true; - me.get_results(); + { + fieldtype: "Button", fieldname: "more_btn", label: __("More"), + click: () => { + this.start += 20; + this.get_results(); } } ]); - let doctype_plural = !this.doctype.endsWith('y') ? this.doctype + 's' - : this.doctype.slice(0, -1) + 'ies'; + // Custom Data Fields + if (this.data_fields) { + fields.push({ fieldtype: "Section Break" }); + fields = fields.concat(this.data_fields); + } + + let doctype_plural = this.doctype.plural(); + this.dialog = new frappe.ui.Dialog({ - title: __("Select {0}", [(this.doctype=='[Select]') ? __("value") : __(doctype_plural)]), + title: __("Select {0}", [(this.doctype == '[Select]') ? __("value") : __(doctype_plural)]), fields: fields, - primary_action_label: __("Get Items"), + primary_action_label: this.primary_action_label || __("Get Items"), secondary_action_label: __("Make {0}", [me.doctype]), - primary_action: function() { - me.action(me.get_checked_values(), me.args); + primary_action: function () { + let filters_data = me.get_custom_filters(); + me.action(me.get_checked_values(), cur_dialog.get_values(), me.args, filters_data); }, - secondary_action: function(e) { + secondary_action: function (e) { // If user wants to close the modal if (e) { frappe.route_options = {}; - if($.isArray(me.setters)) { + if (Array.isArray(me.setters)) { for (let df of me.setters) { frappe.route_options[df.fieldname] = me.dialog.fields_dict[df.fieldname].get_value() || undefined; } } else { - Object.keys(me.setters).forEach(function(setter) { + Object.keys(me.setters).forEach(function (setter) { frappe.route_options[setter] = me.dialog.fields_dict[setter].get_value() || undefined; }); } @@ -114,6 +66,10 @@ frappe.ui.form.MultiSelectDialog = Class.extend({ } }); + if (this.add_filters_group) { + this.make_filter_area(); + } + this.$parent = $(this.dialog.body); this.$wrapper = this.dialog.fields_dict.results_area.$wrapper.append(`
`); @@ -126,9 +82,89 @@ frappe.ui.form.MultiSelectDialog = Class.extend({ this.bind_events(); this.get_results(); this.dialog.show(); - }, + } - bind_events: function() { + get_primary_filters() { + let fields = []; + + let columns = new Array(3); + + // Hack for three column layout + // To add column break + columns[0] = [ + { + fieldtype: "Data", + label: __("Search"), + fieldname: "search_term" + } + ]; + columns[1] = []; + columns[2] = []; + + Object.keys(this.setters).forEach((setter, index) => { + let df_prop = frappe.meta.docfield_map[this.doctype][setter]; + + // Index + 1 to start filling from index 1 + // Since Search is a standrd field already pushed + columns[(index + 1) % 3].push({ + fieldtype: df_prop.fieldtype, + label: df_prop.label, + fieldname: setter, + options: df_prop.options, + default: this.setters[setter] + }); + }); + + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/seal + if (Object.seal) { + Object.seal(columns); + // now a is a fixed-size array with mutable entries + } + + fields = [ + ...columns[0], + { fieldtype: "Column Break" }, + ...columns[1], + { fieldtype: "Column Break" }, + ...columns[2], + { fieldtype: "Section Break", fieldname: "primary_filters_sb" } + ]; + + if (this.add_filters_group) { + fields.push( + { + fieldtype: 'HTML', + fieldname: 'filter_area', + } + ); + } + + return fields; + } + + make_filter_area() { + this.filter_group = new frappe.ui.FilterGroup({ + parent: this.dialog.get_field('filter_area').$wrapper, + doctype: this.doctype, + on_change: () => { + this.get_results(); + } + }); + } + + get_custom_filters() { + if (this.add_filters_group && this.filter_group) { + return this.filter_group.get_filters().reduce((acc, filter) => { + return Object.assign(acc, { + [filter[1]]: [filter[2], filter[3]] + }); + }, {}); + } else { + return []; + } + } + + bind_events() { let me = this; this.$results.on('click', '.list-item-container', function (e) { @@ -136,48 +172,44 @@ frappe.ui.form.MultiSelectDialog = Class.extend({ $(this).find(':checkbox').trigger('click'); } }); + this.$results.on('click', '.list-item--head :checkbox', (e) => { this.$results.find('.list-item-container .list-row-check') .prop("checked", ($(e.target).is(':checked'))); }); - this.$parent.find('.input-with-feedback').on('change', (e) => { + this.$parent.find('.input-with-feedback').on('change', () => { frappe.flags.auto_scroll = false; this.get_results(); }); - this.$parent.find('[data-fieldname="date_range"]').on('blur', (e) => { - frappe.flags.auto_scroll = false; - this.get_results(); - }); - - this.$parent.find('[data-fieldname="search_term"]').on('input', (e) => { + this.$parent.find('[data-fieldtype="Data"]').on('input', () => { var $this = $(this); clearTimeout($this.data('timeout')); - $this.data('timeout', setTimeout(function() { + $this.data('timeout', setTimeout(function () { frappe.flags.auto_scroll = false; me.empty_list(); me.get_results(); }, 300)); }); - }, + } - get_checked_values: function() { + get_checked_values() { // Return name of checked value. - return this.$results.find('.list-item-container').map(function() { - if ($(this).find('.list-row-check:checkbox:checked').length > 0 ) { + return this.$results.find('.list-item-container').map(function () { + if ($(this).find('.list-row-check:checkbox:checked').length > 0) { return $(this).attr('data-item-name'); } }).get(); - }, + } - get_checked_items: function() { + get_checked_items() { // Return checked items with all the column values. let checked_values = this.get_checked_values(); return this.results.filter(res => checked_values.includes(res.name)); - }, + } - make_list_row: function(result={}) { + make_list_row(result = {}) { var me = this; // Make a head row by default (if result not passed) let head = Object.keys(result).length === 0; @@ -185,26 +217,17 @@ frappe.ui.form.MultiSelectDialog = Class.extend({ let contents = ``; let columns = ["name"]; - if($.isArray(this.setters)) { - for (let df of this.setters) { - columns.push(df.fieldname); - } - } else { - columns = columns.concat(Object.keys(this.setters)); - } - columns.push("Date"); + columns = columns.concat(Object.keys(this.setters)); - columns.forEach(function(column) { + columns.forEach(function (column) { contents += `
${ - head ? `${__(frappe.model.unscrub(column))}` - - : (column !== "name" ? `${__(result[column])}` - : ` - ${__(result[column])}`) - } + head ? `${__(frappe.model.unscrub(column))}` + : (column !== "name" ? `${__(result[column] || '')}` + : ` + ${__(result[column] || '')}`)}
`; - }) + }); let $row = $(`
@@ -215,10 +238,12 @@ frappe.ui.form.MultiSelectDialog = Class.extend({ head ? $row.addClass('list-item--head') : $row = $(`
`).append($row); - return $row; - }, - render_result_list: function(results, more = 0, empty=true) { + $(".modal-dialog .list-item--head").css("z-index", 0); + return $row; + } + + render_result_list(results, more = 0, empty = true) { var me = this; var more_btn = me.dialog.fields_dict.more_btn.$wrapper; @@ -240,44 +265,44 @@ frappe.ui.form.MultiSelectDialog = Class.extend({ }); if (frappe.flags.auto_scroll) { - this.$results.animate({scrollTop: me.$results.prop('scrollHeight')}, 500); + this.$results.animate({ scrollTop: me.$results.prop('scrollHeight') }, 500); } - }, + } - empty_list: function() { + empty_list() { + // Store all checked items let checked = this.get_checked_items().map(item => { return { ...item, checked: true - } + }; }); + + // Remove **all** items this.$results.find('.list-item-container').remove(); + + // Rerender checked items this.render_result_list(checked, 0, false); - }, + } - get_results: function() { + get_results() { let me = this; + let filters = this.get_query ? this.get_query().filters : {} || {}; + let filter_fields = []; - let filters = this.get_query ? this.get_query().filters : {}; - let filter_fields = [me.date_field]; - if($.isArray(this.setters)) { - for (let df of this.setters) { - filters[df.fieldname] = me.dialog.fields_dict[df.fieldname].get_value() || undefined; - me.args[df.fieldname] = filters[df.fieldname]; - filter_fields.push(df.fieldname); - } - } else { - Object.keys(this.setters).forEach(function(setter) { - filters[setter] = me.dialog.fields_dict[setter].get_value() || undefined; + Object.keys(this.setters).forEach(function (setter) { + var value = me.dialog.fields_dict[setter].get_value(); + if (me.dialog.fields_dict[setter].df.fieldtype == "Data" && value) { + filters[setter] = ["like", "%" + value + "%"]; + } else { + filters[setter] = value || undefined; me.args[setter] = filters[setter]; filter_fields.push(setter); - }); - } + } + }); - let date_val = this.dialog.fields_dict["date_range"].get_value(); - if(date_val) { - filters[this.date_field] = ['between', date_val]; - } + let filter_group = this.get_custom_filters(); + Object.assign(filters, filter_group); let args = { doctype: me.doctype, @@ -288,13 +313,13 @@ frappe.ui.form.MultiSelectDialog = Class.extend({ page_length: this.page_length + 1, query: this.get_query ? this.get_query().query : '', as_dict: 1 - } + }; frappe.call({ type: "GET", - method:'frappe.desk.search.search_widget', + method: 'frappe.desk.search.search_widget', no_spinner: true, args: args, - callback: function(r) { + callback: function (r) { let more = 0; me.results = []; if (r.values.length) { @@ -302,30 +327,13 @@ frappe.ui.form.MultiSelectDialog = Class.extend({ r.values.pop(); more = 1; } - r.values.forEach(function(result) { - if(me.date_field in result) { - result["Date"] = result[me.date_field] - } + r.values.forEach(function (result) { result.checked = 0; - result.parsed_date = Date.parse(result["Date"]); me.results.push(result); }); - me.results.map( (result) => { - result["Date"] = frappe.format(result["Date"], {"fieldtype":"Date"}); - }) - - me.results.sort((a, b) => { - return a.parsed_date - b.parsed_date; - }); - - // Preselect oldest entry - if (me.start < 1 && r.values.length === 1) { - me.results[0].checked = 1; - } } me.render_result_list(me.results, more); } }); - }, - -}); \ No newline at end of file + } +}; \ No newline at end of file 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/form/sidebar/assign_to.js b/frappe/public/js/frappe/form/sidebar/assign_to.js index 61d1789518..95ceb246e6 100644 --- a/frappe/public/js/frappe/form/sidebar/assign_to.js +++ b/frappe/public/js/frappe/form/sidebar/assign_to.js @@ -87,23 +87,17 @@ frappe.ui.form.AssignTo = Class.extend({ if(!me.assign_to) { me.assign_to = new frappe.ui.form.AssignToDialog({ - obj: me, - method: 'frappe.desk.form.assign_to.add', + method: "frappe.desk.form.assign_to.add", doctype: me.frm.doctype, docname: me.frm.docname, - callback: function(r) { + frm: me.frm, + callback: function (r) { me.render(r.message); } }); } me.assign_to.dialog.clear(); - - if(me.frm.meta.title_field) { - me.assign_to.dialog.set_value("description", me.frm.doc[me.frm.meta.title_field]) - } - me.assign_to.dialog.show(); - me.assign_to = null; }, remove: function(owner) { var me = this; @@ -130,81 +124,126 @@ frappe.ui.form.AssignTo = Class.extend({ frappe.ui.form.AssignToDialog = Class.extend({ init: function(opts){ - var me = this - var dialog = new frappe.ui.Dialog({ - title: __('Add to To Do'), - fields: [ - { fieldtype: 'Link', fieldname: 'assign_to', options: 'User', label: __("Assign To"), reqd: true, filters: { 'user_type': 'System User' }}, - { fieldtype: 'Check', fieldname: 'myself', label: __("Assign to me"), "default": 0 }, - { fieldtype: 'Small Text', fieldname: 'description', label: __("Comment") }, - { fieldtype: 'Section Break' }, - { fieldtype: 'Column Break' }, - { fieldtype: 'Date', fieldname: 'date', label: __("Complete By") }, - { fieldtype: 'Column Break' }, - { fieldtype: 'Select', fieldname: 'priority', label: __("Priority"), - options: [ - { value: 'Low', label: __('Low') }, - { value: 'Medium', label: __('Medium') }, - { value: 'High', label: __('High') } - ], - // Pick up priority from the source document, if it exists and is available in ToDo - 'default': ["Low", "Medium", "High"].includes(opts.obj.frm && opts.obj.frm.doc.priority - ? opts.obj.frm.doc.priority : 'Medium') - }, - ], - primary_action: function() { frappe.ui.add_assignment(opts, this) }, - primary_action_label: __("Add") - }) - $.extend(me, dialog); + $.extend(this, opts); - me.dialog = dialog; - - me.dialog.fields_dict.assign_to.get_query = "frappe.core.doctype.user.user.user_query"; - - var myself = me.dialog.get_input("myself").on("click", function() { - me.toggle_myself(this); - }); - me.toggle_myself(myself); - }, - toggle_myself: function(myself) { - var me = this; - if($(myself).prop("checked")) { - me.dialog.set_value("assign_to", frappe.session.user); - me.dialog.get_field("notify").$wrapper.toggle(false); - me.dialog.get_field("assign_to").$wrapper.toggle(false); - } else { - me.dialog.set_value("assign_to", ""); - me.dialog.get_field("assign_to").$wrapper.toggle(true); - } + this.make(); + this.set_description_from_doc(); }, + make: function() { + let me = this; -}); + me.dialog = new frappe.ui.Dialog({ + title: __('Add to ToDo'), + fields: me.get_fields(), + primary_action_label: __("Add"), + primary_action: function() { + let args = me.dialog.get_values(); -frappe.ui.add_assignment = function(opts, dialog) { - var assign_to = dialog.fields_dict.assign_to.get_value(); - var args = dialog.get_values(); - if(args && assign_to) { - dialog.set_message('Assigning...'); - return frappe.call({ - method: opts.method, - args: $.extend(args, { - doctype: opts.doctype, - name: opts.docname, - assign_to: assign_to, - bulk_assign: opts.bulk_assign || false, - re_assign: opts.re_assign || false - }), - btn: dialog.get_primary_btn(), - callback: function(r) { - if(!r.exc) { - if(opts.callback){ - opts.callback(r); - } - dialog && dialog.hide(); - } else { - dialog.clear_message(); + if (args && args.assign_to) { + me.dialog.set_message("Assigning..."); + + frappe.call({ + method: me.method, + args: $.extend(args, { + doctype: me.doctype, + name: me.docname, + assign_to: args.assign_to, + bulk_assign: me.bulk_assign || false, + re_assign: me.re_assign || false + }), + btn: me.dialog.get_primary_btn(), + callback: function(r) { + if (!r.exc) { + if (me.callback) { + me.callback(r); + } + me.dialog && me.dialog.hide(); + } else { + me.dialog.clear_message(); + } + }, + }); } }, }); + }, + assign_to_me: function() { + let me = this; + let assign_to = []; + + if (me.dialog.get_value("assign_to_me")) { + assign_to.push(frappe.session.user); + } + + me.dialog.set_value("assign_to", assign_to); + }, + set_description_from_doc: function() { + let me = this; + + if (me.frm && me.frm.meta.title_field) { + me.dialog.set_value("description", me.frm.doc[me.frm.meta.title_field]); + } + }, + get_fields: function() { + let me = this; + + return [ + { + fieldtype: 'MultiSelectPills', + fieldname: 'assign_to', + label: __("Assign To"), + reqd: true, + get_data: function(txt) { + return frappe.db.get_link_options("User", txt, {user_type: "System User", enabled: 1}); + } + }, + { + label: __("Assign to me"), + fieldtype: 'Check', + fieldname: 'assign_to_me', + default: 0, + onchange: () => me.assign_to_me() + }, + { + label: __("Comment"), + fieldtype: 'Small Text', + fieldname: 'description' + }, + { + fieldtype: 'Section Break' + }, + { + fieldtype: 'Column Break' + }, + { + label: __("Complete By"), + fieldtype: 'Date', + fieldname: 'date' + }, + { + fieldtype: 'Column Break' + }, + { + label: __("Priority"), + fieldtype: 'Select', + fieldname: 'priority', + options: [ + { + value: 'Low', + label: __('Low') + }, + { + value: 'Medium', + label: __('Medium') + }, + { + value: 'High', + label: __('High') + } + ], + // Pick up priority from the source document, if it exists and is available in ToDo + default: ["Low", "Medium", "High"].includes(me.frm && me.frm.doc.priority ? me.frm.doc.priority : 'Medium') + } + ]; } -} +}); diff --git a/frappe/public/js/frappe/form/templates/address_list.html b/frappe/public/js/frappe/form/templates/address_list.html new file mode 100644 index 0000000000..0f967b67a0 --- /dev/null +++ b/frappe/public/js/frappe/form/templates/address_list.html @@ -0,0 +1,22 @@ +
+{% for(var i=0, l=addr_list.length; i +

+ {%= i+1 %}. {%= addr_list[i].address_title %}{% if(addr_list[i].address_type!="Other") { %} + ({%= __(addr_list[i].address_type) %}){% } %} + {% if(addr_list[i].is_primary_address) { %} + ({%= __("Primary") %}){% } %} + {% if(addr_list[i].is_shipping_address) { %} + ({%= __("Shipping") %}){% } %} + + + {%= __("Edit") %} +

+

{%= addr_list[i].display %}

+
+{% } %} +{% if(!addr_list.length) { %} +

{%= __("No address added yet.") %}

+{% } %} +

\ No newline at end of file diff --git a/frappe/public/js/frappe/form/templates/contact_list.html b/frappe/public/js/frappe/form/templates/contact_list.html new file mode 100644 index 0000000000..7e6969163b --- /dev/null +++ b/frappe/public/js/frappe/form/templates/contact_list.html @@ -0,0 +1,54 @@ +
+{% for(var i=0, l=contact_list.length; i +

+ {%= contact_list[i].first_name %} {%= contact_list[i].last_name %} + {% if(contact_list[i].is_primary_contact) { %} + ({%= __("Primary") %}) + {% } %} + {% if(contact_list[i].designation){ %} + – {%= contact_list[i].designation %} + {% } %} + + {%= __("Edit") %} +

+ {% if (contact_list[i].phones || contact_list[i].email_ids) { %} +

+ {% if(contact_list[i].phone) { %} + {%= __("Phone") %}: {%= contact_list[i].phone %} ({%= __("Primary") %})
+ {% endif %} + {% if(contact_list[i].mobile_no) { %} + {%= __("Mobile No") %}: {%= contact_list[i].mobile_no %} ({%= __("Primary") %})
+ {% endif %} + {% if(contact_list[i].phone_nos) { %} + {% for(var j=0, k=contact_list[i].phone_nos.length; j + {% } %} + {% endif %} +

+

+ {% if(contact_list[i].email_id) { %} + {%= __("Email") %}: {%= contact_list[i].email_id %} ({%= __("Primary") %})
+ {% endif %} + {% if(contact_list[i].email_ids) { %} + {% for(var j=0, k=contact_list[i].email_ids.length; j + {% } %} + {% endif %} +

+ {% endif %} +

+ {% if (contact_list[i].address) { %} + {%= __("Address") %}: {%= contact_list[i].address %}
+ {% endif %} +

+
+{% } %} +{% if(!contact_list.length) { %} +

{%= __("No contacts added yet.") %}

+{% } %} +

+

\ No newline at end of file diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index af9a3c0221..dd9362d664 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -139,7 +139,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { show_restricted_list_indicator_if_applicable() { const match_rules_list = frappe.perm.get_match_rules(this.doctype); if (match_rules_list.length) { - this.restricted_list = $(``) + this.restricted_list = $(``) .prepend('') .click(() => this.show_restrictions(match_rules_list)) .appendTo(this.page.page_form); 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/recorder/RecorderDetail.vue b/frappe/public/js/frappe/recorder/RecorderDetail.vue index 68269ad0f4..53b3c8720b 100644 --- a/frappe/public/js/frappe/recorder/RecorderDetail.vue +++ b/frappe/public/js/frappe/recorder/RecorderDetail.vue @@ -114,8 +114,8 @@ export default { {label: "Time", slug: "time", sortable: true}, ], query: { - sort: "time", - order: "asc", + sort: "duration", + order: "desc", filters: {}, pagination: { limit: 20, diff --git a/frappe/public/js/frappe/recorder/RequestDetail.vue b/frappe/public/js/frappe/recorder/RequestDetail.vue index 60795076ec..ac349d7937 100644 --- a/frappe/public/js/frappe/recorder/RequestDetail.vue +++ b/frappe/public/js/frappe/recorder/RequestDetail.vue @@ -79,7 +79,7 @@
-
+
SQL Query #{{ call.index }} @@ -216,8 +216,8 @@ export default { {label: "Exact Copies", slug: "exact_copies", sortable: true}, ], query: { - sort: "index", - order: "asc", + sort: "duration", + order: "desc", pagination: { limit: 20, page: 1, 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/ui/group_by/group_by.js b/frappe/public/js/frappe/ui/group_by/group_by.js index 831bafa8e8..5053ad71f4 100644 --- a/frappe/public/js/frappe/ui/group_by/group_by.js +++ b/frappe/public/js/frappe/ui/group_by/group_by.js @@ -241,6 +241,7 @@ frappe.ui.GroupBy = class { this.order_by = ''; this.group_by = null; + this.report_view.group_by = null; this.aggregate_function = null; this.aggregate_on = null; $(".groupby").val(""); diff --git a/frappe/public/js/frappe/ui/toolbar/about.js b/frappe/public/js/frappe/ui/toolbar/about.js index 13ba4836fb..87462916c2 100644 --- a/frappe/public/js/frappe/ui/toolbar/about.js +++ b/frappe/public/js/frappe/ui/toolbar/about.js @@ -9,6 +9,12 @@ frappe.ui.misc.about = function() { Website: https://frappe.io

\

\ Source: https://github.com/frappe

\ +

\ + Linkedin: https://linkedin.com/company/frappe-tech

\ +

\ + Facebook: https://facebook.com/erpnext

\ +

\ + Twitter: https://twitter.com/erpnext

\
\

Installed Apps

\
Loading versions...
\ diff --git a/frappe/public/js/frappe/ui/toolbar/awesome_bar.js b/frappe/public/js/frappe/ui/toolbar/awesome_bar.js index 0595fb6219..3e59986928 100644 --- a/frappe/public/js/frappe/ui/toolbar/awesome_bar.js +++ b/frappe/public/js/frappe/ui/toolbar/awesome_bar.js @@ -177,6 +177,7 @@ frappe.search.AwesomeBar = Class.extend({ frappe.search.utils.get_reports(txt), frappe.search.utils.get_pages(txt), frappe.search.utils.get_workspaces(txt), + frappe.search.utils.get_dashboards(txt), frappe.search.utils.get_recent_pages(txt || ""), frappe.search.utils.get_executables(txt) ); diff --git a/frappe/public/js/frappe/ui/toolbar/search_utils.js b/frappe/public/js/frappe/ui/toolbar/search_utils.js index 9a03360d59..6161368766 100644 --- a/frappe/public/js/frappe/ui/toolbar/search_utils.js +++ b/frappe/public/js/frappe/ui/toolbar/search_utils.js @@ -288,7 +288,7 @@ frappe.search.utils = { var out = []; frappe.boot.allowed_workspaces.forEach(function(item) { var level = me.fuzzy_search(keywords, item.name); - if(level > 0) { + if (level > 0) { var ret = { type: "Workspace", label: __("Open {0}", [me.bolden_match_part(__(item.name), keywords)]), @@ -303,6 +303,26 @@ frappe.search.utils = { return out; }, + get_dashboards: function(keywords) { + var me = this; + var out = []; + frappe.boot.dashboards.forEach(function(item) { + var level = me.fuzzy_search(keywords, item.name); + if (level > 0) { + var ret = { + type: "Dashboard", + label: __("{0} Dashboard", [me.bolden_match_part(__(item.name), keywords)]), + value: __("{0} Dashboard", [__(item.name)]), + index: level, + route: ["dashboard", item.name] + }; + + out.push(ret); + } + }); + return out; + }, + get_global_results: function(keywords, start, limit, doctype = "") { var me = this; function get_results_sets(data) { @@ -490,6 +510,11 @@ frappe.search.utils = { fetch_type: "Nav", results: sort_uniques(this.get_workspaces(keywords)) }, + { + title: "Dashboard", + fetch_type: "Nav", + results: sort_uniques(this.get_dashboards(keywords)) + }, { title: "Setup", fetch_type: "Nav", 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..50109f010d 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; @@ -822,3 +823,115 @@ if (!Array.prototype.uniqBy) { } }); } + +// Pluralize +String.prototype.plural = function(revert) { + const plural = { + "(quiz)$": "$1zes", + "^(ox)$": "$1en", + "([m|l])ouse$": "$1ice", + "(matr|vert|ind)ix|ex$": "$1ices", + "(x|ch|ss|sh)$": "$1es", + "([^aeiouy]|qu)y$": "$1ies", + "(hive)$": "$1s", + "(?:([^f])fe|([lr])f)$": "$1$2ves", + "(shea|lea|loa|thie)f$": "$1ves", + sis$: "ses", + "([ti])um$": "$1a", + "(tomat|potat|ech|her|vet)o$": "$1oes", + "(bu)s$": "$1ses", + "(alias)$": "$1es", + "(octop)us$": "$1i", + "(ax|test)is$": "$1es", + "(us)$": "$1es", + "([^s]+)$": "$1s", + }; + + const singular = { + "(quiz)zes$": "$1", + "(matr)ices$": "$1ix", + "(vert|ind)ices$": "$1ex", + "^(ox)en$": "$1", + "(alias)es$": "$1", + "(octop|vir)i$": "$1us", + "(cris|ax|test)es$": "$1is", + "(shoe)s$": "$1", + "(o)es$": "$1", + "(bus)es$": "$1", + "([m|l])ice$": "$1ouse", + "(x|ch|ss|sh)es$": "$1", + "(m)ovies$": "$1ovie", + "(s)eries$": "$1eries", + "([^aeiouy]|qu)ies$": "$1y", + "([lr])ves$": "$1f", + "(tive)s$": "$1", + "(hive)s$": "$1", + "(li|wi|kni)ves$": "$1fe", + "(shea|loa|lea|thie)ves$": "$1f", + "(^analy)ses$": "$1sis", + "((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$": + "$1$2sis", + "([ti])a$": "$1um", + "(n)ews$": "$1ews", + "(h|bl)ouses$": "$1ouse", + "(corpse)s$": "$1", + "(us)es$": "$1", + s$: "", + }; + + const irregular = { + move: "moves", + foot: "feet", + goose: "geese", + sex: "sexes", + child: "children", + man: "men", + tooth: "teeth", + person: "people", + }; + + const uncountable = [ + "sheep", + "fish", + "deer", + "moose", + "series", + "species", + "money", + "rice", + "information", + "equipment", + ]; + + // save some time in the case that singular and plural are the same + if (uncountable.indexOf(this.toLowerCase()) >= 0) return this; + + // check for irregular forms + let word; + let pattern; + let replace; + for (word in irregular) { + if (revert) { + pattern = new RegExp(irregular[word] + "$", "i"); + replace = word; + } else { + pattern = new RegExp(word + "$", "i"); + replace = irregular[word]; + } + if (pattern.test(this)) return this.replace(pattern, replace); + } + + let array; + if (revert) array = singular; + else array = plural; + + // check for matches using regular expressions + let reg; + for (reg in array) { + pattern = new RegExp(reg, "i"); + + if (pattern.test(this)) return this.replace(pattern, array[reg]); + } + + return this; +}; \ No newline at end of file diff --git a/frappe/public/js/frappe/utils/web_page_block.js b/frappe/public/js/frappe/utils/web_page_block.js index bcf821bcfa..db655df98b 100644 --- a/frappe/public/js/frappe/utils/web_page_block.js +++ b/frappe/public/js/frappe/utils/web_page_block.js @@ -1,23 +1,38 @@ -frappe.ui.form.on('Web Page Block', { +frappe.ui.form.on("Web Page Block", { edit_values(frm, cdt, cdn) { let row = frm.selected_doc; - frappe.model.with_doc('Web Template', row.web_template).then(doc => { + frappe.model.with_doc("Web Template", row.web_template).then((doc) => { let d = new frappe.ui.Dialog({ - title: __('Edit Values'), - fields: doc.fields, + title: __("Edit Values"), + fields: doc.fields.map((df) => { + if (df.fieldtype == "Section Break") { + df.collapsible = 1; + } + return df; + }), primary_action(values) { frappe.model.set_value( cdt, cdn, - 'web_template_values', + "web_template_values", JSON.stringify(values) ); d.hide(); - } + }, }); - let values = JSON.parse(row.web_template_values || '{}'); + let values = JSON.parse(row.web_template_values || "{}"); d.set_values(values); d.show(); + + d.sections.forEach((sect) => { + let fields_with_value = sect.fields_list.filter( + (field) => values[field.df.fieldname] + ); + + if (fields_with_value.length) { + sect.collapse(false); + } + }); }); - } -}); + }, +}); \ No newline at end of file diff --git a/frappe/public/js/frappe/views/dashboard/dashboard_view.js b/frappe/public/js/frappe/views/dashboard/dashboard_view.js index 13c44d2130..83f45da5be 100644 --- a/frappe/public/js/frappe/views/dashboard/dashboard_view.js +++ b/frappe/public/js/frappe/views/dashboard/dashboard_view.js @@ -41,7 +41,13 @@ frappe.views.DashboardView = class DashboardView extends frappe.views.ListView { this.$dashboard_page = this.$page.find('.layout-main-section-wrapper').addClass('dashboard-page'); this.$page.find('.page-form').empty().html( `
-
${dashboard_name}
+
+ ${dashboard_name} +
+
${__('Customize')}
${__('Reset')} 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..17e61c4f89 100644 --- a/frappe/public/js/frappe/views/reports/report_view.js +++ b/frappe/public/js/frappe/views/reports/report_view.js @@ -113,7 +113,9 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView { } else { this.save_report_settings(); } - this.init_chart(); + if (!this.group_by) { + this.init_chart(); + } } set_dirty_state_for_custom_report() { @@ -177,9 +179,13 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView { this.render_count(); this.setup_columns(); - if (this.chart) { + if (this.group_by) { + this.$charts_wrapper.addClass('hidden'); + } else if (this.chart) { + this.$charts_wrapper.removeClass('hidden'); this.refresh_charts(); } + if (this.datatable && !force) { this.datatable.refresh(this.get_data(this.data), this.columns); return; @@ -1020,7 +1026,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]); } } }) @@ -1252,7 +1258,8 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView { }, { label: __('Toggle Sidebar'), - action: () => this.toggle_side_bar() + action: () => this.toggle_side_bar(), + shortcut: 'Ctrl+K', }, { label: __('Pick Columns'), diff --git a/frappe/public/js/frappe/web_form/web_form.js b/frappe/public/js/frappe/web_form/web_form.js index 81b219aece..4dc1a50bc4 100644 --- a/frappe/public/js/frappe/web_form/web_form.js +++ b/frappe/public/js/frappe/web_form/web_form.js @@ -29,6 +29,7 @@ export default class WebForm extends frappe.ui.FieldGroup { // webform client script frappe.init_client_script && frappe.init_client_script(); + frappe.web_form.events.trigger('after_load'); this.after_load && this.after_load(); } @@ -136,6 +137,7 @@ export default class WebForm extends frappe.ui.FieldGroup { if (!response.exc) { // Success this.handle_success(response.message); + frappe.web_form.events.trigger('after_save'); this.after_save && this.after_save(); } }, 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/dashboard_view.less b/frappe/public/less/dashboard_view.less index 874e4e2e36..ab78fa5b2a 100644 --- a/frappe/public/less/dashboard_view.less +++ b/frappe/public/less/dashboard_view.less @@ -1,48 +1,60 @@ .dashboard-page { - .dashboard-view { - min-height: calc(100vh - 284px); - padding: 20px 20px 0 20px; + .dashboard-view { + min-height: calc(100vh - 284px); + padding: 20px 20px 0 20px; - .new-widget { - text-align: center; - } + .new-widget { + text-align: center; + } - .new-chart-widget { - min-height: 200px; - } + .new-chart-widget { + min-height: 200px; + } - .new-number-card-widget { - min-height: 110px; - } - } + .new-number-card-widget { + min-height: 110px; + } + } - .empty-dashboard { - margin-top: 45px; - } + .empty-dashboard { + margin-top: 45px; + } - .page-form { - height: 50px; + .page-form { + height: 50px; - .dashboard-header { - padding: 10px; - display: flex; - justify-content: space-between; - width: 100%; - } + .dashboard-header { + padding: 10px; + display: flex; + justify-content: space-between; + width: 100%; - .customize-dashboard { - font-size: 13px; - cursor: pointer; - } + .header-title { + line-height: 1.5em; + vertical-align: text-bottom; + } - .customize-options { - display: none; - cursor: pointer; + .restricted-button { + cursor: default; + position: relative; + right: 5px; + top: -3px; + } + } - .customize-option:hover { - text-decoration: underline; - } - } - } + .customize-dashboard { + font-size: 13px; + cursor: pointer; + } + + .customize-options { + display: none; + cursor: pointer; + + .customize-option:hover { + text-decoration: underline; + } + } + } } diff --git a/frappe/public/less/desk.less b/frappe/public/less/desk.less index 6d44fc5192..b0fb60b6a3 100644 --- a/frappe/public/less/desk.less +++ b/frappe/public/less/desk.less @@ -316,6 +316,23 @@ li.user-progress { } } +.restricted-button { + padding: 0px 10px; + border: 1px solid @yellow; + height: 25px; + font-weight: 600; + border-radius: 5px; + background: lightyellow; + color: @text-light; + margin: auto 5px auto auto; + font-size: @text-small; + outline: 0; + .octicon-lock { + padding-right: 5px; + font-size: 12px; + } +} + /* on small screens, show only icons on top */ @media (max-width: 767px) { .module-view-layout .nav-stacked > li { @@ -1170,3 +1187,13 @@ body.no-sidebar { font-size: 20px; } } + +.new-version-log { + .new-version-links { + padding: 5px 0px; + } + + &:not(:last-child) { + margin-bottom: 1em; + } +} \ No newline at end of file diff --git a/frappe/public/less/desktop.less b/frappe/public/less/desktop.less index 1e64533079..0b17d75861 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; @@ -319,10 +388,11 @@ .widget-body { margin-top: 20px; + padding-right: 200px; &.grid { display: grid; - grid-template-columns: 1fr 1fr 1fr; + grid-template-columns: 1fr 1fr; grid-auto-flow: column; &.grid-rows-2 { 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/public/less/form_grid.less b/frappe/public/less/form_grid.less index 86482686d1..5cb04a252c 100644 --- a/frappe/public/less/form_grid.less +++ b/frappe/public/less/form_grid.less @@ -247,14 +247,23 @@ } } -.form-in-grid { +.base-grid() { background-color: white; - z-index: 1021; position: relative; + .transition(opacity .2s ease) +} + +.form-in-grid { overflow: hidden; height: 0; opacity: 0; - .transition(opacity .2s ease) + z-index: 1021; + .base-grid(); +} + +.recorder-form-in-grid { + z-index: 0; + .base-grid(); } .grid-row-open .form-in-grid { diff --git a/frappe/public/less/list.less b/frappe/public/less/list.less index 3294e24bf3..639e67e3a7 100644 --- a/frappe/public/less/list.less +++ b/frappe/public/less/list.less @@ -298,26 +298,10 @@ input.list-check-all, input.list-row-checkbox { .awesomplete > ul { min-width: 300px; } - .restricted-list { - padding: 0px 10px; - border: 1px solid @yellow; - height: 25px; - font-weight: 600; - border-radius: 5px; - background: lightyellow; - color: @text-light; - margin: auto 5px auto auto; - font-size: @text-small; - outline: 0; - .octicon { - padding-right: 5px; - font-size: 12px; - } - } } .frappe-rtl { - .restricted-list { + .restricted-button { margin: auto auto auto 5px; direction: ltr; } diff --git a/frappe/public/scss/markdown.scss b/frappe/public/scss/markdown.scss index 440a4cfe88..595b7f96a3 100644 --- a/frappe/public/scss/markdown.scss +++ b/frappe/public/scss/markdown.scss @@ -115,3 +115,10 @@ border-radius: 0.375rem; } } + +// apply margin on first h1 if container is full width without top margin +main:not(.my-5) .from-markdown { + h1:first-child { + margin-top: 5rem; + } +} diff --git a/frappe/public/scss/page-builder.scss b/frappe/public/scss/page-builder.scss index f792209c24..a028e34158 100644 --- a/frappe/public/scss/page-builder.scss +++ b/frappe/public/scss/page-builder.scss @@ -250,3 +250,35 @@ left: 16.67%; } } + +.testimonial { + text-align: center; +} + +.testimonial-logo img { + display: inline-block; + max-width: 10rem; + max-height: 2.5rem; +} + +.testimonial-content { + margin-left: auto; + margin-right: auto; + margin-top: 2rem; + max-width: 52rem; + font-size: $font-size-2xl; + font-weight: 500; +} + +.testimonial-by { + font-size: $font-size-lg; + margin-top: 2rem; + + &:before { + content: '—' + } +} + +.split-section-content { + margin-top: 2rem; +} diff --git a/frappe/public/scss/sidebar.scss b/frappe/public/scss/sidebar.scss new file mode 100644 index 0000000000..72f64a912e --- /dev/null +++ b/frappe/public/scss/sidebar.scss @@ -0,0 +1,18 @@ +.web-sidebar { + padding-top: 2rem; + position: sticky; + top: 0; +} + +.sidebar-item a { + display: block; + padding: 0.25rem 0; + font-size: $font-size-sm; + color: $gray-700; + text-decoration: none; + font-weight: 500; +} + +.sidebar-item a.active { + color: $primary; +} diff --git a/frappe/public/scss/website.scss b/frappe/public/scss/website.scss index 30781c52c1..0149ac0d0a 100644 --- a/frappe/public/scss/website.scss +++ b/frappe/public/scss/website.scss @@ -6,6 +6,7 @@ @import 'website-image'; @import 'page-builder'; @import 'markdown'; +@import 'sidebar'; .container { padding-left: 1.25rem; @@ -55,6 +56,14 @@ } } +.navbar-brand { + img { + display: inline-block; + max-width: 150px; + max-height: 25px; + } +} + .dropdown-menu { padding: 0.25rem; } @@ -135,6 +144,7 @@ a.card { .footer-logo { width: 5rem; height: 2rem; + object-fit: contain; } .footer-link, .footer-child-item a { 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_log/test_energy_point_log.py b/frappe/social/doctype/energy_point_log/test_energy_point_log.py index 9e4d0e6425..1ecaba9cd5 100644 --- a/frappe/social/doctype/energy_point_log/test_energy_point_log.py +++ b/frappe/social/doctype/energy_point_log/test_energy_point_log.py @@ -306,7 +306,7 @@ def get_points(user, point_type='energy_points'): def assign_users_to_todo(todo_name, users): for user in users: assign_to({ - 'assign_to': user, + 'assign_to': [user], 'doctype': 'ToDo', 'name': todo_name }) \ No newline at end of file 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/templates/includes/navbar/navbar.html b/frappe/templates/includes/navbar/navbar.html index d669eee9d3..3ae0aef164 100644 --- a/frappe/templates/includes/navbar/navbar.html +++ b/frappe/templates/includes/navbar/navbar.html @@ -1,7 +1,13 @@