From 2bbe464f59e2808a35f2d8d2bc938e05b6ef8af2 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 24 Jul 2020 17:53:22 +0530 Subject: [PATCH 001/273] feat: Allow skip tables during backups --- frappe/utils/backups.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index 3b905de6bd..d0867e40e6 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -148,7 +148,11 @@ class BackupGenerator: args = dict([item[0], frappe.utils.esc(str(item[1]), '$ ')] for item in self.__dict__.copy().items()) - cmd_string = """mysqldump --single-transaction --quick --lock-tables=false -u %(user)s -p%(password)s %(db_name)s -h %(db_host)s -P %(db_port)s | gzip > %(backup_path_db)s """ % args + if self.verbose: + print("Skipping Tables: {0}\n".format(", ".join(frappe.conf.ignore_tables_in_backup))) + + args["skip_tables"] = " ".join(["--ignore-table={0}.{1}".format(frappe.conf.db_name, table) for table in frappe.conf.ignore_tables_in_backup]) + cmd_string = """mysqldump --single-transaction --quick --lock-tables=false -u %(user)s -p%(password)s %(db_name)s -h %(db_host)s -P %(db_port)s %(skip_tables)s | gzip > %(backup_path_db)s """ % args if self.db_type == 'postgres': cmd_string = "pg_dump postgres://{user}:{password}@{db_host}:{db_port}/{db_name} | gzip > {backup_path_db}".format( From befea91e6d5b7081960807976107f823ab559d18 Mon Sep 17 00:00:00 2001 From: gavin Date: Mon, 27 Jul 2020 11:01:01 +0530 Subject: [PATCH 002/273] fix: empty conf value for ignore_tables_in_backup Co-authored-by: Faris Ansari --- frappe/utils/backups.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index d0867e40e6..e475cef276 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -151,7 +151,7 @@ class BackupGenerator: if self.verbose: print("Skipping Tables: {0}\n".format(", ".join(frappe.conf.ignore_tables_in_backup))) - args["skip_tables"] = " ".join(["--ignore-table={0}.{1}".format(frappe.conf.db_name, table) for table in frappe.conf.ignore_tables_in_backup]) + args["skip_tables"] = " ".join(["--ignore-table={0}.{1}".format(frappe.conf.db_name, table) for table in frappe.conf.ignore_tables_in_backup or []]) cmd_string = """mysqldump --single-transaction --quick --lock-tables=false -u %(user)s -p%(password)s %(db_name)s -h %(db_host)s -P %(db_port)s %(skip_tables)s | gzip > %(backup_path_db)s """ % args if self.db_type == 'postgres': From 218749e4011a236887426465aa58cdbf43b85139 Mon Sep 17 00:00:00 2001 From: Emil Date: Wed, 5 Aug 2020 16:20:17 +0300 Subject: [PATCH 003/273] :sparkles: Add Map View --- frappe/geo/utils.py | 39 ++++++++ frappe/public/build.json | 1 + frappe/public/css/list.css | 4 + frappe/public/js/frappe/list/base_list.js | 2 +- .../public/js/frappe/list/list_sidebar.html | 2 + frappe/public/js/frappe/list/list_sidebar.js | 8 ++ frappe/public/js/frappe/views/map/map_view.js | 97 +++++++++++++++++++ frappe/public/less/list.less | 6 ++ 8 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 frappe/geo/utils.py create mode 100644 frappe/public/js/frappe/views/map/map_view.js diff --git a/frappe/geo/utils.py b/frappe/geo/utils.py new file mode 100644 index 0000000000..4bc07249fe --- /dev/null +++ b/frappe/geo/utils.py @@ -0,0 +1,39 @@ +# -*- 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 pymysql import InternalError + + +@frappe.whitelist() +def get_coords(doctype, filters): + '''Get list of coordinates in form + returns {names: ['latitude', 'longitude']}''' + filters_sql = get_coords_conditions(doctype, filters)[4:] + if filters_sql: + try: + coords = frappe.db.sql("""SELECT * FROM `tab{}` WHERE {}""".format(doctype, filters_sql), as_dict=True) + except InternalError: + frappe.msgprint(frappe._('This Doctype did not contains latitude and longitude fields')) + return + else: + coords = frappe.get_all(doctype, fields=['name', 'latitude', 'longitude']) + out = frappe._dict() + for i in coords: + out[i.name] = out.get(i.docname, []) + out[i.name].append(i.latitude) + out[i.name].append(i.longitude) + return out + + +def get_coords_conditions(doctype, filters=None): + """Returns SQL conditions with user permissions and filters for event queries""" + from frappe.desk.reportview import get_filters_cond + if not frappe.has_permission(doctype): + frappe.throw(frappe._("Not Permitted"), frappe.PermissionError) + + return get_filters_cond(doctype, filters, [], with_match_conditions=True) \ No newline at end of file diff --git a/frappe/public/build.json b/frappe/public/build.json index 997a3092ad..096bb09c6e 100755 --- a/frappe/public/build.json +++ b/frappe/public/build.json @@ -304,6 +304,7 @@ "public/js/frappe/views/calendar/calendar.js", "public/js/frappe/views/dashboard/dashboard_view.js", "public/js/frappe/views/image/image_view.js", + "public/js/frappe/views/map/map_view.js", "public/js/frappe/views/kanban/kanban_view.js", "public/js/frappe/views/inbox/inbox_view.js", "public/js/frappe/views/file/file_view.js", diff --git a/frappe/public/css/list.css b/frappe/public/css/list.css index 5ae77c73ca..49ffbcd9e9 100644 --- a/frappe/public/css/list.css +++ b/frappe/public/css/list.css @@ -401,6 +401,10 @@ input.list-row-checkbox { .pswp__more-item img { max-height: 100%; } +.map-view-container { + display: flex; + flex-wrap: wrap; +} .list-paging-area .gantt-view-mode { margin-left: 15px; margin-right: 15px; diff --git a/frappe/public/js/frappe/list/base_list.js b/frappe/public/js/frappe/list/base_list.js index bbe2fa2f95..af220a97d3 100644 --- a/frappe/public/js/frappe/list/base_list.js +++ b/frappe/public/js/frappe/list/base_list.js @@ -695,5 +695,5 @@ class FilterArea { } // utility function to validate view modes -frappe.views.view_modes = ['List', 'Gantt', 'Kanban', 'Calendar', 'Image', 'Inbox', 'Report', 'Dashboard']; +frappe.views.view_modes = ['List', 'Gantt', 'Kanban', 'Calendar', 'Image', 'Map', 'Inbox', 'Report']; frappe.views.is_valid = view_mode => frappe.views.view_modes.includes(view_mode); diff --git a/frappe/public/js/frappe/list/list_sidebar.html b/frappe/public/js/frappe/list/list_sidebar.html index dcbbe7ac5e..c5b75782b5 100644 --- a/frappe/public/js/frappe/list/list_sidebar.html +++ b/frappe/public/js/frappe/list/list_sidebar.html @@ -30,6 +30,8 @@ {%= __("Dashboard") %} +
  • ${display_name}
  • `).appendTo($dropdown); + $(`
  • ${account.email_id}
  • `).appendTo($dropdown); if (account.email_id === "Sent Mail") divider = false; }); @@ -233,21 +225,40 @@ frappe.views.ListSidebar = class ListSidebar { }); } - setup_keyboard_shortcuts() { - this.sidebar.find('.list-link > a, .list-link > .btn-group > a').each((i, el) => { - frappe.ui.keys - .get_shortcut_group(this.page) - .add($(el)); + setup_assigned_to_me() { + this.page.sidebar.find(".assigned-to-me a").on("click", () => { + this.list_view.filter_area.add(this.list_view.doctype, "_assign", "like", `%${frappe.session.user}%`); }); } - setup_list_group_by() { - this.list_group_by = new frappe.views.ListGroupBy({ - doctype: this.doctype, - sidebar: this, - list_view: this.list_view, - page: this.page - }); + setup_upgrade_box() { + let upgrade_list = $(``).appendTo(this.sidebar); + + // Show Renew/Upgrade button, + // if account is holding one user free plan or + // if account's expiry date within range of 30 days from today's date + + let upgrade_date = frappe.datetime.add_days(frappe.datetime.get_today(), 30); + if (frappe.boot.limits.users === 1 || upgrade_date >= frappe.boot.limits.expiry) { + let upgrade_box = $(`
    + +
    Go Premium
    +

    Upgrade to a premium plan with more users, storage and priority support.

    + +
    `).appendTo(upgrade_list); + + upgrade_box.find('.btn-upgrade').on('click', () => { + frappe.set_route('usage-info'); + }); + + upgrade_box.find('.close').on('click', () => { + upgrade_list.remove(); + frappe.flags.upgrade_dismissed = 1; + }); + } } get_cat_tags() { @@ -258,7 +269,6 @@ frappe.views.ListSidebar = class ListSidebar { var me = this; frappe.call({ method: 'frappe.desk.reportview.get_sidebar_stats', - type: 'GET', args: { stats: me.stats, doctype: me.doctype, @@ -266,9 +276,29 @@ frappe.views.ListSidebar = class ListSidebar { filters: (me.list_view.filter_area ? me.list_filter.get_current_filters() : me.default_filters) || [] }, callback: function(r) { - me.render_stat("_user_tags", (r.message.stats || {})["_user_tags"]); - let stats_dropdown = me.sidebar.find('.list-stats-dropdown'); - frappe.utils.setup_search(stats_dropdown, '.stat-link', '.stat-label'); + me.defined_category = r.message; + if (r.message.defined_cat) { + me.defined_category = r.message.defined_cat; + me.cats = {}; + //structure the tag categories + for (var i in me.defined_category) { + if (me.cats[me.defined_category[i].category] === undefined) { + me.cats[me.defined_category[i].category] = [me.defined_category[i].tag]; + } else { + me.cats[me.defined_category[i].category].push(me.defined_category[i].tag); + } + me.cat_tags[i] = me.defined_category[i].tag; + } + me.tempstats = r.message.stats; + + $.each(me.cats, function(i, v) { + me.render_stat(i, (me.tempstats || {})["_user_tags"], v); + }); + me.render_stat("_user_tags", (me.tempstats || {})["_user_tags"]); + } else { + //render normal stats + me.render_stat("_user_tags", (r.message.stats || {})["_user_tags"]); + } } }); } @@ -331,7 +361,7 @@ frappe.views.ListSidebar = class ListSidebar { me.list_view.refresh(); }); }) - .appendTo(this.sidebar.find(".list-stats-dropdown")); + .insertBefore(this.sidebar.find(".close-sidebar-button")); } set_fieldtype(df) { @@ -362,8 +392,8 @@ frappe.views.ListSidebar = class ListSidebar { } reload_stats() { - this.sidebar.find(".stat-link").remove(); - this.sidebar.find(".stat-no-records").remove(); + this.sidebar.find(".sidebar-stat").remove(); + this.sidebar.find(".list-tag-preview").remove(); this.get_stats(); } From 526f470bed8aa581c16eed321aecc225971d5bd8 Mon Sep 17 00:00:00 2001 From: AminovE99 <32329685+AminovE99@users.noreply.github.com> Date: Mon, 10 Aug 2020 17:16:05 +0300 Subject: [PATCH 008/273] :art: Beauty code --- frappe/public/js/frappe/views/map/map_view.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/frappe/public/js/frappe/views/map/map_view.js b/frappe/public/js/frappe/views/map/map_view.js index 66a219162c..d511460798 100644 --- a/frappe/public/js/frappe/views/map/map_view.js +++ b/frappe/public/js/frappe/views/map/map_view.js @@ -63,13 +63,12 @@ frappe.views.MapView = class MapView extends frappe.views.ListView { lastCoords = [value[0], value[1]]; } } - if (this.type === 'location_field'){ - for (let i = 0; i < this.coords.length; i++){ + if (this.type === 'location_field') { + for (let i = 0; i < this.coords.length; i++) { let features = JSON.parse(this.coords[i].location).features; features.forEach( coords => L.geoJSON(coords).bindPopup(this.coords[i].name).addTo(this.map) ); - console.log(features[0].geometry.coordinates); lastCoords = features[0].geometry.coordinates.reverse(); } } @@ -84,11 +83,11 @@ frappe.views.MapView = class MapView extends frappe.views.ListView { get_coords_method = frappe.listview_settings[this.doctype].get_coords_method; } if (cur_list.meta.fields.find(i => i.fieldname === 'location') && - cur_list.meta.fields.find(i => i.fieldtype === 'Geolocation')){ + cur_list.meta.fields.find(i => i.fieldtype === 'Geolocation')) { this.type = 'location_field'; } if (cur_list.meta.fields.find(i => i.fieldname === "latitude") && - cur_list.meta.fields.find(i => i.fieldname === "longitude")){ + cur_list.meta.fields.find(i => i.fieldname === "longitude")) { this.type = 'coordinates'; } return frappe.call({ From d4dd7e097a0a5da1358598271b3201d1aab331b8 Mon Sep 17 00:00:00 2001 From: AminovE99 <32329685+AminovE99@users.noreply.github.com> Date: Tue, 11 Aug 2020 14:13:08 +0300 Subject: [PATCH 009/273] :art: Apply suggestions from code review Co-authored-by: Mathieu Brunot --- frappe/geo/utils.py | 6 +++--- frappe/public/build.json | 2 +- frappe/public/js/frappe/list/base_list.js | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frappe/geo/utils.py b/frappe/geo/utils.py index 249f3ffcca..b99ca56ce8 100644 --- a/frappe/geo/utils.py +++ b/frappe/geo/utils.py @@ -14,7 +14,7 @@ from pymysql import InternalError @frappe.whitelist() def get_coords(doctype, filters, type): '''Get list of coordinates in form - returns {names: ['latitude', 'longitude']} or location type''' + returns {name, location} with location being a geojson string''' filters_sql = get_coords_conditions(doctype, filters)[4:] out = None if type == 'coordinates': @@ -28,10 +28,10 @@ def return_location(doctype, filters_sql): try: coords = frappe.db.sql("""SELECT * FROM `tab{}` WHERE {}""".format(doctype, filters_sql), as_dict=True) except InternalError: - frappe.msgprint(frappe._('This Doctype did not contains latitude and longitude fields')) + frappe.msgprint(frappe._('This Doctype did not contain location field')) return else: - coords = frappe.get_all(doctype, fields = ['location', 'name']) + coords = frappe.get_all(doctype, fields = ['name', 'location']) return coords def return_coordinates(doctype, filters_sql): diff --git a/frappe/public/build.json b/frappe/public/build.json index c5678e485e..874b9d2419 100755 --- a/frappe/public/build.json +++ b/frappe/public/build.json @@ -304,7 +304,7 @@ "public/js/frappe/views/calendar/calendar.js", "public/js/frappe/views/dashboard/dashboard_view.js", "public/js/frappe/views/image/image_view.js", - "public/js/frappe/views/map/map_view.js", + "public/js/frappe/views/map/map_view.js", "public/js/frappe/views/kanban/kanban_view.js", "public/js/frappe/views/inbox/inbox_view.js", "public/js/frappe/views/file/file_view.js", diff --git a/frappe/public/js/frappe/list/base_list.js b/frappe/public/js/frappe/list/base_list.js index af220a97d3..0f8508b4c1 100644 --- a/frappe/public/js/frappe/list/base_list.js +++ b/frappe/public/js/frappe/list/base_list.js @@ -695,5 +695,5 @@ class FilterArea { } // utility function to validate view modes -frappe.views.view_modes = ['List', 'Gantt', 'Kanban', 'Calendar', 'Image', 'Map', 'Inbox', 'Report']; +frappe.views.view_modes = ['List', 'Gantt', 'Kanban', 'Calendar', 'Image', 'Map', 'Inbox', 'Report', 'Dashboard']; frappe.views.is_valid = view_mode => frappe.views.view_modes.includes(view_mode); From 632b41bab7521c97fb3eab060e31cd769133005f Mon Sep 17 00:00:00 2001 From: AminovE99 <32329685+AminovE99@users.noreply.github.com> Date: Tue, 11 Aug 2020 14:23:33 +0300 Subject: [PATCH 010/273] :bug: Fix list sidebar --- frappe/public/js/frappe/list/list_sidebar.js | 103 +++++++------------ 1 file changed, 37 insertions(+), 66 deletions(-) diff --git a/frappe/public/js/frappe/list/list_sidebar.js b/frappe/public/js/frappe/list/list_sidebar.js index e0c3c721de..b3f65253c7 100644 --- a/frappe/public/js/frappe/list/list_sidebar.js +++ b/frappe/public/js/frappe/list/list_sidebar.js @@ -13,7 +13,6 @@ frappe.views.ListSidebar = class ListSidebar { constructor(opts) { $.extend(this, opts); this.make(); - this.get_stats(); this.cat_tags = []; } @@ -26,17 +25,25 @@ frappe.views.ListSidebar = class ListSidebar { this.setup_reports(); this.setup_list_filter(); - this.setup_assigned_to_me(); this.setup_views(); this.setup_kanban_boards(); this.setup_calendar_view(); this.setup_email_inbox(); + this.setup_keyboard_shortcuts(); + this.setup_list_group_by(); - let limits = frappe.boot.limits; + // do not remove + // used to trigger custom scripts + $(document).trigger('list_sidebar_setup'); - if (limits.upgrade_url && limits.expiry && !frappe.flags.upgrade_dismissed) { - this.setup_upgrade_box(); + if (this.list_view.list_view_settings && this.list_view.list_view_settings.disable_sidebar_stats) { + this.sidebar.find('.sidebar-stat').remove(); + } else { + this.sidebar.find('.list-stats').on('click', (e) => { + this.reload_stats(); + }); } + } setup_views() { @@ -54,7 +61,7 @@ frappe.views.ListSidebar = class ListSidebar { show_list_link = true; } - if (frappe.treeview_settings[this.doctype]) { + if (frappe.treeview_settings[this.doctype] || frappe.get_meta(this.doctype).is_tree) { this.sidebar.find(".tree-link").removeClass("hide"); } @@ -83,7 +90,7 @@ frappe.views.ListSidebar = class ListSidebar { this.sidebar.find('.list-link[data-view="Image"]').removeClass('hide'); show_list_link = true; } - // show map link if map_view doctype has get_coords or latitude and longitude + if ((JSON.stringify(frappe.listview_settings) !== '{}' && frappe.listview_settings[this.list_view.doctype].get_coords_method) || (this.list_view.meta.fields.find(i => i.fieldname === "latitude") && @@ -162,7 +169,7 @@ frappe.views.ListSidebar = class ListSidebar { reference_doctype: doctype } }).then(result => { - if (!result) return; + if (!(result && Array.isArray(result) && result.length)) return; const calendar_views = result; const $link_calendar = this.sidebar.find('.list-link[data-view="Calendar"]'); @@ -211,11 +218,13 @@ frappe.views.ListSidebar = class ListSidebar { accounts.forEach((account) => { let email_account = (account.email_id == "All Accounts") ? "All Accounts" : account.email_account; let route = ["List", "Communication", "Inbox", email_account].join('/'); + let display_name = ["All Accounts", "Sent Mail", "Spam", "Trash"].includes(account.email_id) ? __(account.email_id) : account.email_id; + if (!divider) { this.get_divider().appendTo($dropdown); divider = true; } - $(`
  • ${account.email_id}
  • `).appendTo($dropdown); + $(`
  • ${display_name}
  • `).appendTo($dropdown); if (account.email_id === "Sent Mail") divider = false; }); @@ -225,40 +234,21 @@ frappe.views.ListSidebar = class ListSidebar { }); } - setup_assigned_to_me() { - this.page.sidebar.find(".assigned-to-me a").on("click", () => { - this.list_view.filter_area.add(this.list_view.doctype, "_assign", "like", `%${frappe.session.user}%`); + setup_keyboard_shortcuts() { + this.sidebar.find('.list-link > a, .list-link > .btn-group > a').each((i, el) => { + frappe.ui.keys + .get_shortcut_group(this.page) + .add($(el)); }); } - setup_upgrade_box() { - let upgrade_list = $(``).appendTo(this.sidebar); - - // Show Renew/Upgrade button, - // if account is holding one user free plan or - // if account's expiry date within range of 30 days from today's date - - let upgrade_date = frappe.datetime.add_days(frappe.datetime.get_today(), 30); - if (frappe.boot.limits.users === 1 || upgrade_date >= frappe.boot.limits.expiry) { - let upgrade_box = $(`
    - -
    Go Premium
    -

    Upgrade to a premium plan with more users, storage and priority support.

    - -
    `).appendTo(upgrade_list); - - upgrade_box.find('.btn-upgrade').on('click', () => { - frappe.set_route('usage-info'); - }); - - upgrade_box.find('.close').on('click', () => { - upgrade_list.remove(); - frappe.flags.upgrade_dismissed = 1; - }); - } + setup_list_group_by() { + this.list_group_by = new frappe.views.ListGroupBy({ + doctype: this.doctype, + sidebar: this, + list_view: this.list_view, + page: this.page + }); } get_cat_tags() { @@ -269,6 +259,7 @@ frappe.views.ListSidebar = class ListSidebar { var me = this; frappe.call({ method: 'frappe.desk.reportview.get_sidebar_stats', + type: 'GET', args: { stats: me.stats, doctype: me.doctype, @@ -276,29 +267,9 @@ frappe.views.ListSidebar = class ListSidebar { filters: (me.list_view.filter_area ? me.list_filter.get_current_filters() : me.default_filters) || [] }, callback: function(r) { - me.defined_category = r.message; - if (r.message.defined_cat) { - me.defined_category = r.message.defined_cat; - me.cats = {}; - //structure the tag categories - for (var i in me.defined_category) { - if (me.cats[me.defined_category[i].category] === undefined) { - me.cats[me.defined_category[i].category] = [me.defined_category[i].tag]; - } else { - me.cats[me.defined_category[i].category].push(me.defined_category[i].tag); - } - me.cat_tags[i] = me.defined_category[i].tag; - } - me.tempstats = r.message.stats; - - $.each(me.cats, function(i, v) { - me.render_stat(i, (me.tempstats || {})["_user_tags"], v); - }); - me.render_stat("_user_tags", (me.tempstats || {})["_user_tags"]); - } else { - //render normal stats - me.render_stat("_user_tags", (r.message.stats || {})["_user_tags"]); - } + me.render_stat("_user_tags", (r.message.stats || {})["_user_tags"]); + let stats_dropdown = me.sidebar.find('.list-stats-dropdown'); + frappe.utils.setup_search(stats_dropdown, '.stat-link', '.stat-label'); } }); } @@ -361,7 +332,7 @@ frappe.views.ListSidebar = class ListSidebar { me.list_view.refresh(); }); }) - .insertBefore(this.sidebar.find(".close-sidebar-button")); + .appendTo(this.sidebar.find(".list-stats-dropdown")); } set_fieldtype(df) { @@ -392,8 +363,8 @@ frappe.views.ListSidebar = class ListSidebar { } reload_stats() { - this.sidebar.find(".sidebar-stat").remove(); - this.sidebar.find(".list-tag-preview").remove(); + this.sidebar.find(".stat-link").remove(); + this.sidebar.find(".stat-no-records").remove(); this.get_stats(); } From 8373520296751b76a4617b1869acb6e30714f78f Mon Sep 17 00:00:00 2001 From: AminovE99 <32329685+AminovE99@users.noreply.github.com> Date: Tue, 11 Aug 2020 14:24:51 +0300 Subject: [PATCH 011/273] :bug: Remove blank line --- frappe/public/js/frappe/list/list_sidebar.js | 1 - 1 file changed, 1 deletion(-) diff --git a/frappe/public/js/frappe/list/list_sidebar.js b/frappe/public/js/frappe/list/list_sidebar.js index b3f65253c7..4dbd1076db 100644 --- a/frappe/public/js/frappe/list/list_sidebar.js +++ b/frappe/public/js/frappe/list/list_sidebar.js @@ -219,7 +219,6 @@ frappe.views.ListSidebar = class ListSidebar { let email_account = (account.email_id == "All Accounts") ? "All Accounts" : account.email_account; let route = ["List", "Communication", "Inbox", email_account].join('/'); let display_name = ["All Accounts", "Sent Mail", "Spam", "Trash"].includes(account.email_id) ? __(account.email_id) : account.email_id; - if (!divider) { this.get_divider().appendTo($dropdown); divider = true; From 325d0e32c11f4dc31cd8fe6b8541132d56a118bf Mon Sep 17 00:00:00 2001 From: AminovE99 <32329685+AminovE99@users.noreply.github.com> Date: Tue, 11 Aug 2020 17:20:03 +0300 Subject: [PATCH 012/273] :art: Changes from new requirements (python) --- frappe/geo/utils.py | 43 +++++++++++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/frappe/geo/utils.py b/frappe/geo/utils.py index b99ca56ce8..1f9dd2d335 100644 --- a/frappe/geo/utils.py +++ b/frappe/geo/utils.py @@ -4,17 +4,17 @@ from __future__ import unicode_literals +import json + import frappe from pymysql import InternalError - - @frappe.whitelist() def get_coords(doctype, filters, type): '''Get list of coordinates in form - returns {name, location} with location being a geojson string''' + returns {names: ['latitude', 'longitude']} or location type''' filters_sql = get_coords_conditions(doctype, filters)[4:] out = None if type == 'coordinates': @@ -23,18 +23,34 @@ def get_coords(doctype, filters, type): out = return_location(doctype, filters_sql) return out + +def convert_to_geo_json(coords_list): + handled_geo_json_dict = [] + for element in coords_list: + handled_geo_json = json.loads(element['location']) + for coord in handled_geo_json['features']: + coord['properties']['name'] = element['name'] + handled_geo_json_dict.append(coord.copy()) + print(handled_geo_json['features']) + handled_geo_json = {"type": "FeatureCollection", "features": handled_geo_json_dict} + return handled_geo_json + + def return_location(doctype, filters_sql): if filters_sql: try: - coords = frappe.db.sql("""SELECT * FROM `tab{}` WHERE {}""".format(doctype, filters_sql), as_dict=True) + coords = frappe.db.sql("""SELECT name,location FROM `tab{}` WHERE {}""".format(doctype, filters_sql), as_dict=True) except InternalError: - frappe.msgprint(frappe._('This Doctype did not contain location field')) + frappe.msgprint(frappe._('This Doctype did not contains latitude and longitude fields')) return else: - coords = frappe.get_all(doctype, fields = ['name', 'location']) - return coords + coords = frappe.get_all(doctype, fields=['location', 'name']) + handled_geo_json = convert_to_geo_json(coords) + return handled_geo_json + def return_coordinates(doctype, filters_sql): + handled_geo_json = {"type": "FeatureCollection", "features": None} if filters_sql: try: coords = frappe.db.sql("""SELECT * FROM `tab{}` WHERE {}""".format(doctype, filters_sql), as_dict=True) @@ -43,12 +59,15 @@ def return_coordinates(doctype, filters_sql): return else: coords = frappe.get_all(doctype, fields=['name', 'latitude', 'longitude']) - out = frappe._dict() + out_list = [] for i in coords: - out[i.name] = out.get(i.docname, []) - out[i.name].append(i.latitude) - out[i.name].append(i.longitude) - return out + node = {"type": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": None}} + node['properties']['name'] = i.name + node['geometry']['coordinates'] = [i.latitude, i.longitude] + out_list.append(node.copy()) + handled_geo_json['features'] = out_list + print(handled_geo_json) + return handled_geo_json def get_coords_conditions(doctype, filters=None): From da46ca2f1234e788a39f353fb677349041356b84 Mon Sep 17 00:00:00 2001 From: AminovE99 <32329685+AminovE99@users.noreply.github.com> Date: Tue, 11 Aug 2020 17:21:57 +0300 Subject: [PATCH 013/273] :art: Changes from new requirements --- frappe/public/js/frappe/views/map/map_view.js | 26 +++++-------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/frappe/public/js/frappe/views/map/map_view.js b/frappe/public/js/frappe/views/map/map_view.js index d511460798..7e75dd0640 100644 --- a/frappe/public/js/frappe/views/map/map_view.js +++ b/frappe/public/js/frappe/views/map/map_view.js @@ -53,25 +53,11 @@ frappe.views.MapView = class MapView extends frappe.views.ListView { }).addTo(this.map); L.control.scale().addTo(this.map); - - let lastCoords = []; - if (this.type === 'coordinates') { - for (const [key, value] of Object.entries(this.coords)) { - new L.marker([value[0], value[1]]) - .bindPopup(key) - .addTo(this.map); - lastCoords = [value[0], value[1]]; - } - } - if (this.type === 'location_field') { - for (let i = 0; i < this.coords.length; i++) { - let features = JSON.parse(this.coords[i].location).features; - features.forEach( - coords => L.geoJSON(coords).bindPopup(this.coords[i].name).addTo(this.map) - ); - lastCoords = features[0].geometry.coordinates.reverse(); - } - } + console.log(this.coords); + this.coords.features.forEach( + coords => L.geoJSON(coords).bindPopup(coords.properties.name).addTo(this.map) + ); + let lastCoords = this.coords.features[0].geometry.coordinates.reverse(); this.map.panTo(lastCoords, 8); } @@ -98,7 +84,7 @@ frappe.views.MapView = class MapView extends frappe.views.ListView { type: this.type } }).then(r => { - this.coords = Object.assign(r.message); + this.coords = r.message; }); } From f27eee1ce67ac95b5f4538a6994074f8d93d51e9 Mon Sep 17 00:00:00 2001 From: AminovE99 <32329685+AminovE99@users.noreply.github.com> Date: Tue, 11 Aug 2020 17:30:36 +0300 Subject: [PATCH 014/273] :bug: Only location or latitude and longitude --- frappe/geo/utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frappe/geo/utils.py b/frappe/geo/utils.py index 1f9dd2d335..c1fc5bbb52 100644 --- a/frappe/geo/utils.py +++ b/frappe/geo/utils.py @@ -37,11 +37,11 @@ def convert_to_geo_json(coords_list): def return_location(doctype, filters_sql): - if filters_sql: + if filters_sql: try: - coords = frappe.db.sql("""SELECT name,location FROM `tab{}` WHERE {}""".format(doctype, filters_sql), as_dict=True) + coords = frappe.db.sql("""SELECT name, location FROM `tab{}` WHERE {}""".format(doctype, filters_sql), as_dict=True) except InternalError: - frappe.msgprint(frappe._('This Doctype did not contains latitude and longitude fields')) + frappe.msgprint(frappe._('This Doctype did not contains location fields')) return else: coords = frappe.get_all(doctype, fields=['location', 'name']) @@ -53,7 +53,7 @@ def return_coordinates(doctype, filters_sql): handled_geo_json = {"type": "FeatureCollection", "features": None} if filters_sql: try: - coords = frappe.db.sql("""SELECT * FROM `tab{}` WHERE {}""".format(doctype, filters_sql), as_dict=True) + coords = frappe.db.sql("""SELECT name, latitude, longitude FROM `tab{}` WHERE {}""".format(doctype, filters_sql), as_dict=True) except InternalError: frappe.msgprint(frappe._('This Doctype did not contains latitude and longitude fields')) return From 32546694b796060c1812ba311f7a4fe54d56e826 Mon Sep 17 00:00:00 2001 From: AminovE99 <32329685+AminovE99@users.noreply.github.com> Date: Tue, 11 Aug 2020 17:50:55 +0300 Subject: [PATCH 015/273] :pencil: Update description Co-authored-by: Mathieu Brunot --- frappe/geo/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/geo/utils.py b/frappe/geo/utils.py index c1fc5bbb52..ab7b856c3c 100644 --- a/frappe/geo/utils.py +++ b/frappe/geo/utils.py @@ -14,7 +14,7 @@ from pymysql import InternalError @frappe.whitelist() def get_coords(doctype, filters, type): '''Get list of coordinates in form - returns {names: ['latitude', 'longitude']} or location type''' + returns {name, location} with location being a geojson string''' filters_sql = get_coords_conditions(doctype, filters)[4:] out = None if type == 'coordinates': From 8061b37748b1058bc04b643fa9c7ba1a7c7cff5d Mon Sep 17 00:00:00 2001 From: Mathieu Brunot Date: Tue, 11 Aug 2020 17:10:02 +0200 Subject: [PATCH 016/273] :art: Restore empty line --- frappe/public/js/frappe/list/list_sidebar.js | 1 + 1 file changed, 1 insertion(+) diff --git a/frappe/public/js/frappe/list/list_sidebar.js b/frappe/public/js/frappe/list/list_sidebar.js index 4dbd1076db..b3f65253c7 100644 --- a/frappe/public/js/frappe/list/list_sidebar.js +++ b/frappe/public/js/frappe/list/list_sidebar.js @@ -219,6 +219,7 @@ frappe.views.ListSidebar = class ListSidebar { let email_account = (account.email_id == "All Accounts") ? "All Accounts" : account.email_account; let route = ["List", "Communication", "Inbox", email_account].join('/'); let display_name = ["All Accounts", "Sent Mail", "Spam", "Trash"].includes(account.email_id) ? __(account.email_id) : account.email_id; + if (!divider) { this.get_divider().appendTo($dropdown); divider = true; From be76942394b01778767c11880b34630b58d47956 Mon Sep 17 00:00:00 2001 From: AminovE99 <32329685+AminovE99@users.noreply.github.com> Date: Wed, 12 Aug 2020 12:57:08 +0300 Subject: [PATCH 017/273] :fire: Remove debug print --- frappe/geo/utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frappe/geo/utils.py b/frappe/geo/utils.py index ab7b856c3c..836b52f4b2 100644 --- a/frappe/geo/utils.py +++ b/frappe/geo/utils.py @@ -31,7 +31,6 @@ def convert_to_geo_json(coords_list): for coord in handled_geo_json['features']: coord['properties']['name'] = element['name'] handled_geo_json_dict.append(coord.copy()) - print(handled_geo_json['features']) handled_geo_json = {"type": "FeatureCollection", "features": handled_geo_json_dict} return handled_geo_json @@ -44,7 +43,7 @@ def return_location(doctype, filters_sql): frappe.msgprint(frappe._('This Doctype did not contains location fields')) return else: - coords = frappe.get_all(doctype, fields=['location', 'name']) + coords = frappe.get_all(doctype, fields=['name', 'location']) handled_geo_json = convert_to_geo_json(coords) return handled_geo_json From 60153cb857bd16139f72a8cf2f364bbffb251864 Mon Sep 17 00:00:00 2001 From: AminovE99 <32329685+AminovE99@users.noreply.github.com> Date: Wed, 12 Aug 2020 16:29:07 +0300 Subject: [PATCH 018/273] :truck: Rename function --- frappe/geo/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/geo/utils.py b/frappe/geo/utils.py index 836b52f4b2..949efb6d5e 100644 --- a/frappe/geo/utils.py +++ b/frappe/geo/utils.py @@ -24,7 +24,7 @@ def get_coords(doctype, filters, type): return out -def convert_to_geo_json(coords_list): +def merge_all_feature_collection_in_one(coords_list): handled_geo_json_dict = [] for element in coords_list: handled_geo_json = json.loads(element['location']) @@ -44,7 +44,7 @@ def return_location(doctype, filters_sql): return else: coords = frappe.get_all(doctype, fields=['name', 'location']) - handled_geo_json = convert_to_geo_json(coords) + handled_geo_json = merge_all_feature_collection_in_one(coords) return handled_geo_json From d0728df83b99be00a20094dadb392fd81b06ebae Mon Sep 17 00:00:00 2001 From: AminovE99 <32329685+AminovE99@users.noreply.github.com> Date: Wed, 12 Aug 2020 16:30:01 +0300 Subject: [PATCH 019/273] :fire: Remove console log --- frappe/public/js/frappe/views/map/map_view.js | 1 - 1 file changed, 1 deletion(-) diff --git a/frappe/public/js/frappe/views/map/map_view.js b/frappe/public/js/frappe/views/map/map_view.js index 7e75dd0640..8b46ef1a95 100644 --- a/frappe/public/js/frappe/views/map/map_view.js +++ b/frappe/public/js/frappe/views/map/map_view.js @@ -53,7 +53,6 @@ frappe.views.MapView = class MapView extends frappe.views.ListView { }).addTo(this.map); L.control.scale().addTo(this.map); - console.log(this.coords); this.coords.features.forEach( coords => L.geoJSON(coords).bindPopup(coords.properties.name).addTo(this.map) ); From e9b3085c6a1ab1376000bac74c8582a22841f4fe Mon Sep 17 00:00:00 2001 From: "mathieu.brunot" Date: Wed, 12 Aug 2020 17:19:35 +0200 Subject: [PATCH 020/273] :art: Split Geo Utils into specific functions Signed-off-by: mathieu.brunot --- frappe/geo/utils.py | 78 ++++++++++++++++++++++++++++----------------- 1 file changed, 48 insertions(+), 30 deletions(-) diff --git a/frappe/geo/utils.py b/frappe/geo/utils.py index 949efb6d5e..919dcfd961 100644 --- a/frappe/geo/utils.py +++ b/frappe/geo/utils.py @@ -13,64 +13,82 @@ from pymysql import InternalError @frappe.whitelist() def get_coords(doctype, filters, type): - '''Get list of coordinates in form - returns {name, location} with location being a geojson string''' + '''Get a geojson dict representing a doctype.''' filters_sql = get_coords_conditions(doctype, filters)[4:] - out = None - if type == 'coordinates': - out = return_coordinates(doctype, filters_sql) + + coords = None if type == 'location_field': - out = return_location(doctype, filters_sql) + coords = return_location(doctype, filters_sql) + elif type == 'coordinates': + coords = return_coordinates(doctype, filters_sql) + + out = convert_to_geojson(type, coords) return out +def convert_to_geojson(type, coords): + '''Converts GPS coordinates to geoJSON string.''' + geojson = {"type": "FeatureCollection", "features": None} -def merge_all_feature_collection_in_one(coords_list): - handled_geo_json_dict = [] - for element in coords_list: - handled_geo_json = json.loads(element['location']) - for coord in handled_geo_json['features']: + if type == 'location_field': + geojson['features'] = merge_location_features_in_one(coords) + elif type == 'coordinates': + geojson['features'] = create_gps_markers(coords) + + return geojson + + +def merge_location_features_in_one(coords): + '''Merging all features from location field.''' + geojson_dict = [] + for element in coords: + geojson_loc = json.loads(element['location']) + for coord in geojson_loc['features']: coord['properties']['name'] = element['name'] - handled_geo_json_dict.append(coord.copy()) - handled_geo_json = {"type": "FeatureCollection", "features": handled_geo_json_dict} - return handled_geo_json + geojson_dict.append(coord.copy()) + + return geojson_dict + + +def create_gps_markers(coords): + '''Build Marker based on latitude and longitude.''' + geojson_dict = [] + for i in coords: + node = {"type": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": None}} + node['properties']['name'] = i.name + node['geometry']['coordinates'] = [i.latitude, i.longitude] + geojson_dict.append(node.copy()) + + return geojson_dict def return_location(doctype, filters_sql): - if filters_sql: + '''Get name and location fields for Doctype.''' + if filters_sql: try: - coords = frappe.db.sql("""SELECT name, location FROM `tab{}` WHERE {}""".format(doctype, filters_sql), as_dict=True) + coords = frappe.db.sql('''SELECT name, location FROM `tab{}` WHERE {}'''.format(doctype, filters_sql), as_dict=True) except InternalError: frappe.msgprint(frappe._('This Doctype did not contains location fields')) return else: coords = frappe.get_all(doctype, fields=['name', 'location']) - handled_geo_json = merge_all_feature_collection_in_one(coords) - return handled_geo_json + return coords def return_coordinates(doctype, filters_sql): - handled_geo_json = {"type": "FeatureCollection", "features": None} + '''Get name, latitude and longitude fields for Doctype.''' if filters_sql: try: - coords = frappe.db.sql("""SELECT name, latitude, longitude FROM `tab{}` WHERE {}""".format(doctype, filters_sql), as_dict=True) + coords = frappe.db.sql('''SELECT name, latitude, longitude FROM `tab{}` WHERE {}'''.format(doctype, filters_sql), as_dict=True) except InternalError: frappe.msgprint(frappe._('This Doctype did not contains latitude and longitude fields')) return else: coords = frappe.get_all(doctype, fields=['name', 'latitude', 'longitude']) - out_list = [] - for i in coords: - node = {"type": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": None}} - node['properties']['name'] = i.name - node['geometry']['coordinates'] = [i.latitude, i.longitude] - out_list.append(node.copy()) - handled_geo_json['features'] = out_list - print(handled_geo_json) - return handled_geo_json + return coords def get_coords_conditions(doctype, filters=None): - """Returns SQL conditions with user permissions and filters for event queries""" + '''Returns SQL conditions with user permissions and filters for event queries.''' from frappe.desk.reportview import get_filters_cond if not frappe.has_permission(doctype): frappe.throw(frappe._("Not Permitted"), frappe.PermissionError) From 80857f6c90c94094c391f61fe9438b6cd69d9ced Mon Sep 17 00:00:00 2001 From: Emil Date: Fri, 14 Aug 2020 14:52:57 +0300 Subject: [PATCH 021/273] :white_check_mark: Add tests Signed-off-by: Emil --- frappe/geo/utils.py | 4 ++-- frappe/tests/tests_geo_utils.py | 42 +++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 frappe/tests/tests_geo_utils.py diff --git a/frappe/geo/utils.py b/frappe/geo/utils.py index 919dcfd961..d7011a7eb0 100644 --- a/frappe/geo/utils.py +++ b/frappe/geo/utils.py @@ -67,7 +67,7 @@ def return_location(doctype, filters_sql): try: coords = frappe.db.sql('''SELECT name, location FROM `tab{}` WHERE {}'''.format(doctype, filters_sql), as_dict=True) except InternalError: - frappe.msgprint(frappe._('This Doctype did not contains location fields')) + frappe.msgprint(frappe._('This Doctype did not contains location fields'), raise_exception=True) return else: coords = frappe.get_all(doctype, fields=['name', 'location']) @@ -80,7 +80,7 @@ def return_coordinates(doctype, filters_sql): try: coords = frappe.db.sql('''SELECT name, latitude, longitude FROM `tab{}` WHERE {}'''.format(doctype, filters_sql), as_dict=True) except InternalError: - frappe.msgprint(frappe._('This Doctype did not contains latitude and longitude fields')) + frappe.msgprint(frappe._('This Doctype did not contains latitude and longitude fields'), raise_exception=True) return else: coords = frappe.get_all(doctype, fields=['name', 'latitude', 'longitude']) diff --git a/frappe/tests/tests_geo_utils.py b/frappe/tests/tests_geo_utils.py new file mode 100644 index 0000000000..3c5757423e --- /dev/null +++ b/frappe/tests/tests_geo_utils.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals + +import unittest + +import frappe +from frappe.geo.utils import get_coords + + +class TestGeoUtils(unittest.TestCase): + def setUp(self): + self.todo = frappe.get_doc( + dict(doctype='ToDo', description='Test description', assigned_by='Administrator')).insert() + + self.test_location_dict = {'type': 'FeatureCollection', 'features': [ + {'type': 'Feature', 'properties': {}, "geometry": {'type': 'Point', 'coordinates': [49.20433, 55.753395]}}]} + self.test_location = frappe.get_doc({'name': 'Test Location', 'doctype': 'Location', + 'location': str(self.test_location_dict)}) + + self.test_filter_exists = [['Location', 'name', 'like', '%Test Location%']] + self.test_filter_not_exists = [['Location', 'name', 'like', '%Test Location Not exists%']] + self.test_filter_todo = [['ToDo', 'description', 'like', '%Test description%']] + + def test_get_coords_location_with_filter_exists(self): + coords = get_coords('Location', self.test_filter_exists, 'location_field') + self.assertEqual(self.test_location_dict['features'][0]['geometry'], coords['features'][0]['geometry']) + + def test_get_coords_location_with_filter_not_exists(self): + coords = get_coords('Location', self.test_filter_not_exists, 'location_field') + self.assertEqual(coords, {'type': 'FeatureCollection', 'features': []}) + + def test_get_coords_from_not_existable_location(self): + self.assertRaises(frappe.ValidationError, get_coords, 'ToDo', self.test_filter_todo, 'location_field') + + def test_get_coords_from_not_existable_coords(self): + self.assertRaises(frappe.ValidationError, get_coords, 'ToDo', self.test_filter_todo, 'coordinates') + + def tearDown(self): + self.todo.delete() From 6cb6a18b48a050efc7ee1e307f180d3e4ba55ec3 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 23 Sep 2020 13:18:04 +0530 Subject: [PATCH 022/273] feat: Ability to exclude/include certain doctypes from conf or via CLI --- frappe/commands/site.py | 18 +++++++- frappe/utils/backups.py | 92 +++++++++++++++++++++++++++++++++-------- 2 files changed, 90 insertions(+), 20 deletions(-) diff --git a/frappe/commands/site.py b/frappe/commands/site.py index c5008b32a5..a727341b73 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -387,10 +387,13 @@ def use(site, sites_path='.'): @click.command('backup') @click.option('--with-files', default=False, is_flag=True, help="Take backup with files") +@click.option('--ignore-backup-conf', default=False, is_flag=True, help="Ignore excludes/includes set in config") +@click.option('--include', default="", type=str, help="Specify the DocTypes to backup seperated by commas") +@click.option('--exclude', default="", type=str, help="Specify the DocTypes to not backup seperated by commas") @click.option('--verbose', default=False, is_flag=True) @pass_context def backup(context, with_files=False, backup_path_db=None, backup_path_files=None, - backup_path_private_files=None, quiet=False, verbose=False): + backup_path_private_files=None, quiet=False, verbose=False, ignore_backup_conf=False, include="", exclude=""): "Backup" from frappe.utils.backups import scheduled_backup verbose = verbose or context.verbose @@ -399,10 +402,21 @@ def backup(context, with_files=False, backup_path_db=None, backup_path_files=Non try: frappe.init(site=site) frappe.connect() - odb = scheduled_backup(ignore_files=not with_files, backup_path_db=backup_path_db, backup_path_files=backup_path_files, backup_path_private_files=backup_path_private_files, force=True, verbose=verbose) + odb = scheduled_backup( + ignore_files=not with_files, + backup_path_db=backup_path_db, + backup_path_files=backup_path_files, + backup_path_private_files=backup_path_private_files, + ignore_conf=ignore_backup_conf, + include_doctypes=include, + exclude_doctypes=exclude, + verbose=verbose, + force=True, + ) except Exception as e: if verbose: print("Backup failed for {0}. Database or site_config.json may be corrupted".format(site)) + print(e) exit_code = 1 continue diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index 4d29134abd..d3a063cd89 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -18,6 +18,7 @@ from frappe.utils import cstr, get_url, now_datetime # backup variable for backwards compatibility verbose = False _verbose = verbose +base_tables = ["__Auth", "__global_search", "__UserSettings"] class BackupGenerator: @@ -29,7 +30,7 @@ class BackupGenerator: """ def __init__(self, db_name, user, password, backup_path_db=None, backup_path_files=None, backup_path_private_files=None, db_host="localhost", db_port=None, verbose=False, - db_type='mariadb', backup_path_conf=None): + db_type='mariadb', backup_path_conf=None, ignore_conf=False, include_doctypes="", exclude_doctypes=""): global _verbose self.db_host = db_host self.db_port = db_port @@ -41,6 +42,9 @@ class BackupGenerator: self.backup_path_db = backup_path_db self.backup_path_files = backup_path_files self.backup_path_private_files = backup_path_private_files + self.ignore_conf = ignore_conf + self.include_doctypes = include_doctypes + self.exclude_doctypes = exclude_doctypes if not self.db_type: self.db_type = 'mariadb' @@ -54,6 +58,7 @@ class BackupGenerator: self.site_slug = site.replace('.', '_') self.verbose = verbose self.setup_backup_directory() + self.setup_backup_tables() _verbose = verbose def setup_backup_directory(self): @@ -68,6 +73,35 @@ class BackupGenerator: dir = os.path.dirname(file_path) os.makedirs(dir, exist_ok=True) + def setup_backup_tables(self): + """Sets self.backup_includes, self.backup_excludes based on passed args + """ + def get_tables(doctypes): + tables = [] + for doctype in doctypes: + if doctype: + if doctype.startswith("tab"): + tables.append(doctype) + else: + tables.append("tab" + doctype) + return tables + + passed_tables = { + "include": get_tables(self.include_doctypes.strip().split(",")), + "exclude": get_tables(self.exclude_doctypes.strip().split(",")) + } + conf_tables = { + "include": get_tables(frappe.conf.backup.get("includes", [])) + base_tables, + "exclude": get_tables(frappe.conf.backup.get("excludes", [])) + } + + self.backup_includes = passed_tables["include"] + self.backup_excludes = passed_tables["exclude"] + + if not self.ignore_conf: + self.backup_includes = self.backup_includes or conf_tables["include"] + self.backup_excludes = self.backup_excludes or conf_tables["exclude"] + @property def site_config_backup_path(self): # For backwards compatibility @@ -189,23 +223,42 @@ class BackupGenerator: args = dict([item[0], frappe.utils.esc(str(item[1]), '$ ')] for item in self.__dict__.copy().items()) - if self.verbose: - print("Skipping Tables: {0}\n".format(", ".join(frappe.conf.ignore_tables_in_backup))) - - args["skip_tables"] = " ".join(["--ignore-table={0}.{1}".format(frappe.conf.db_name, table) for table in frappe.conf.ignore_tables_in_backup or []]) - cmd_string = """mysqldump --single-transaction --quick --lock-tables=false -u %(user)s -p%(password)s %(db_name)s -h %(db_host)s -P %(db_port)s %(skip_tables)s | gzip > %(backup_path_db)s """ % args + if self.backup_includes: + print("Backing Up Tables: {0}\n".format(", ".join(self.backup_includes))) + elif self.backup_excludes: + print("Skipping Tables: {0}\n".format(", ".join(self.backup_excludes))) if self.db_type == 'postgres': - cmd_string = "pg_dump postgres://{user}:{password}@{db_host}:{db_port}/{db_name} | gzip > {backup_path_db}".format( - user=args.get('user'), - password=args.get('password'), - db_host=args.get('db_host'), - db_port=args.get('db_port'), - db_name=args.get('db_name'), - backup_path_db=args.get('backup_path_db') - ) + if self.backup_includes: + args["include"] = " ".join(["--table='{0}'".format(table) for table in self.backup_includes]) + elif self.backup_excludes: + args["exclude"] = " ".join(["--exclude-table='{0}'".format(table) for table in self.backup_excludes]) - err, out = frappe.utils.execute_in_shell(cmd_string) + cmd_string = "pg_dump postgres://{user}:{password}@{db_host}:{db_port}/{db_name} {include} {exclude} | gzip > {backup_path_db}" + + else: + if self.backup_includes: + args["include"] = " ".join(["'{0}'".format(x) for x in self.backup_includes]) + + elif self.backup_excludes: + args["exclude"] = " ".join(["--ignore-table='{0}.{1}'".format(frappe.conf.db_name, table) for table in self.backup_excludes]) + + cmd_string = "mysqldump --single-transaction --quick --lock-tables=false -u {user} -p{password} {db_name} -h {db_host} -P {db_port} {include} {exclude} | gzip > {backup_path_db}" + + command = cmd_string.format( + user=args.get('user'), + password=args.get('password'), + db_host=args.get('db_host'), + db_port=args.get('db_port'), + db_name=args.get('db_name'), + backup_path_db=args.get('backup_path_db'), + exclude=args.get('exclude', ''), + include=args.get('include', '') + ) + + print(command) + + err, out = frappe.utils.execute_in_shell(command) def send_email(self): """ @@ -279,14 +332,14 @@ def fetch_latest_backups(): } -def scheduled_backup(older_than=6, ignore_files=False, backup_path_db=None, backup_path_files=None, backup_path_private_files=None, force=False, verbose=False): +def scheduled_backup(older_than=6, ignore_files=False, backup_path_db=None, backup_path_files=None, backup_path_private_files=None, force=False, verbose=False, ignore_conf=False, include_doctypes="", exclude_doctypes=""): """this function is called from scheduler deletes backups older than 7 days takes backup""" - odb = new_backup(older_than, ignore_files, backup_path_db=backup_path_db, backup_path_files=backup_path_files, force=force, verbose=verbose) + odb = new_backup(older_than, ignore_files, backup_path_db=backup_path_db, backup_path_files=backup_path_files, force=force, verbose=verbose, ignore_conf=ignore_conf, include_doctypes=include_doctypes, exclude_doctypes=exclude_doctypes) return odb -def new_backup(older_than=6, ignore_files=False, backup_path_db=None, backup_path_files=None, backup_path_private_files=None, force=False, verbose=False): +def new_backup(older_than=6, ignore_files=False, backup_path_db=None, backup_path_files=None, backup_path_private_files=None, force=False, verbose=False, ignore_conf=False, include_doctypes="", exclude_doctypes=""): delete_temp_backups(older_than = frappe.conf.keep_backups_for_hours or 24) odb = BackupGenerator(frappe.conf.db_name, frappe.conf.db_name,\ frappe.conf.db_password, @@ -295,6 +348,9 @@ def new_backup(older_than=6, ignore_files=False, backup_path_db=None, backup_pat db_host = frappe.db.host, db_port = frappe.db.port, db_type = frappe.conf.db_type, + ignore_conf=ignore_conf, + include_doctypes=include_doctypes, + exclude_doctypes=exclude_doctypes, verbose=verbose) odb.get_backup(older_than, ignore_files, force=force) return odb From bb2a1910e86c959286f461ededdf7678dffa6dfc Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 24 Sep 2020 11:34:43 +0530 Subject: [PATCH 023/273] fix: Handle excludes or includes explicitly * Take backup only if doctype exists * Show mysql command only if verbose is passed --- frappe/utils/backups.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index d3a063cd89..b8d1da299a 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -76,10 +76,11 @@ class BackupGenerator: def setup_backup_tables(self): """Sets self.backup_includes, self.backup_excludes based on passed args """ + existing_doctypes = set([x.name for x in frappe.get_all("DocType")]) def get_tables(doctypes): tables = [] for doctype in doctypes: - if doctype: + if doctype and doctype in existing_doctypes: if doctype.startswith("tab"): tables.append(doctype) else: @@ -98,7 +99,7 @@ class BackupGenerator: self.backup_includes = passed_tables["include"] self.backup_excludes = passed_tables["exclude"] - if not self.ignore_conf: + if not (self.backup_includes or self.backup_excludes) and not self.ignore_conf: self.backup_includes = self.backup_includes or conf_tables["include"] self.backup_excludes = self.backup_excludes or conf_tables["exclude"] @@ -239,7 +240,6 @@ class BackupGenerator: else: if self.backup_includes: args["include"] = " ".join(["'{0}'".format(x) for x in self.backup_includes]) - elif self.backup_excludes: args["exclude"] = " ".join(["--ignore-table='{0}.{1}'".format(frappe.conf.db_name, table) for table in self.backup_excludes]) @@ -256,7 +256,8 @@ class BackupGenerator: include=args.get('include', '') ) - print(command) + if self.verbose: + print(command) err, out = frappe.utils.execute_in_shell(command) From bde7adeb2f597ef59d7c173ad5265b4b1fab6c27 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 7 Oct 2020 14:06:28 +0530 Subject: [PATCH 024/273] style: Black + flake8 --- frappe/utils/backups.py | 445 ++++++++++++++++++++++++++++------------ 1 file changed, 314 insertions(+), 131 deletions(-) diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index 25147f2085..b59f5526e9 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -2,7 +2,6 @@ # MIT License. See license.txt # imports - standard imports -import json import os from calendar import timegm from datetime import datetime @@ -25,12 +24,31 @@ base_tables = ["__Auth", "__global_search", "__UserSettings"] class BackupGenerator: """ - This class contains methods to perform On Demand Backup + This class contains methods to perform On Demand Backup - To initialize, specify (db_name, user, password, db_file_name=None, db_host="localhost") - If specifying db_file_name, also append ".sql.gz" + To initialize, specify (db_name, user, password, db_file_name=None, db_host="localhost") + If specifying db_file_name, also append ".sql.gz" """ - def __init__(self, db_name, user, password, backup_path=None, backup_path_db=None, backup_path_files=None, backup_path_private_files=None, db_host="localhost", db_port=None, db_type='mariadb', backup_path_conf=None, ignore_conf=False, compress_files=False, include_doctypes="", exclude_doctypes="", verbose=False): + + def __init__( + self, + db_name, + user, + password, + backup_path=None, + backup_path_db=None, + backup_path_files=None, + backup_path_private_files=None, + db_host="localhost", + db_port=None, + db_type="mariadb", + backup_path_conf=None, + ignore_conf=False, + compress_files=False, + include_doctypes="", + exclude_doctypes="", + verbose=False, + ): global _verbose self.compress_files = compress_files or compress self.db_host = db_host @@ -49,22 +67,29 @@ class BackupGenerator: self.exclude_doctypes = exclude_doctypes if not self.db_type: - self.db_type = 'mariadb' + self.db_type = "mariadb" - if not self.db_port and self.db_type == 'mariadb': - self.db_port = 3306 - elif not self.db_port and self.db_type == 'postgres': - self.db_port = 5432 + if not self.db_port: + if self.db_type == "mariadb": + self.db_port = 3306 + if self.db_type == "postgres": + self.db_port = 5432 site = frappe.local.site or frappe.generate_hash(length=8) - self.site_slug = site.replace('.', '_') + self.site_slug = site.replace(".", "_") self.verbose = verbose self.setup_backup_directory() self.setup_backup_tables() _verbose = verbose def setup_backup_directory(self): - specified = self.backup_path or self.backup_path_db or self.backup_path_files or self.backup_path_private_files or self.backup_path_conf + specified = ( + self.backup_path + or self.backup_path_db + or self.backup_path_files + or self.backup_path_private_files + or self.backup_path_conf + ) if not specified: backups_folder = get_backup_path() @@ -74,15 +99,22 @@ class BackupGenerator: if self.backup_path: os.makedirs(self.backup_path, exist_ok=True) - for file_path in set([self.backup_path_files, self.backup_path_db, self.backup_path_private_files, self.backup_path_conf]): + for file_path in set( + [ + self.backup_path_files, + self.backup_path_db, + self.backup_path_private_files, + self.backup_path_conf, + ] + ): if file_path: dir = os.path.dirname(file_path) os.makedirs(dir, exist_ok=True) def setup_backup_tables(self): - """Sets self.backup_includes, self.backup_excludes based on passed args - """ + """Sets self.backup_includes, self.backup_excludes based on passed args""" existing_doctypes = set([x.name for x in frappe.get_all("DocType")]) + def get_tables(doctypes): tables = [] for doctype in doctypes: @@ -95,11 +127,11 @@ class BackupGenerator: passed_tables = { "include": get_tables(self.include_doctypes.strip().split(",")), - "exclude": get_tables(self.exclude_doctypes.strip().split(",")) + "exclude": get_tables(self.exclude_doctypes.strip().split(",")), } conf_tables = { "include": get_tables(frappe.conf.backup.get("includes", [])) + base_tables, - "exclude": get_tables(frappe.conf.backup.get("excludes", [])) + "exclude": get_tables(frappe.conf.backup.get("excludes", [])), } self.backup_includes = passed_tables["include"] @@ -112,24 +144,43 @@ class BackupGenerator: @property def site_config_backup_path(self): # For backwards compatibility - click.secho("BackupGenerator.site_config_backup_path has been deprecated in favour of BackupGenerator.backup_path_conf", fg="yellow") + click.secho( + "BackupGenerator.site_config_backup_path has been deprecated in favour of" + " BackupGenerator.backup_path_conf", + fg="yellow", + ) return getattr(self, "backup_path_conf", None) def get_backup(self, older_than=24, ignore_files=False, force=False): """ - Takes a new dump if existing file is old - and sends the link to the file as email + Takes a new dump if existing file is old + and sends the link to the file as email """ - #Check if file exists and is less than a day old - #If not Take Dump + # Check if file exists and is less than a day old + # If not Take Dump if not force: - last_db, last_file, last_private_file, site_config_backup_path = self.get_recent_backup(older_than) + ( + last_db, + last_file, + last_private_file, + site_config_backup_path, + ) = self.get_recent_backup(older_than) else: - last_db, last_file, last_private_file, site_config_backup_path = False, False, False, False + last_db, last_file, last_private_file, site_config_backup_path = ( + False, + False, + False, + False, + ) - self.todays_date = now_datetime().strftime('%Y%m%d_%H%M%S') + self.todays_date = now_datetime().strftime("%Y%m%d_%H%M%S") - if not (self.backup_path_conf and self.backup_path_db and self.backup_path_files and self.backup_path_private_files): + if not ( + self.backup_path_conf + and self.backup_path_db + and self.backup_path_files + and self.backup_path_private_files + ): self.set_backup_file_name() if not (last_db and last_file and last_private_file and site_config_backup_path): @@ -145,7 +196,7 @@ class BackupGenerator: self.backup_path_conf = site_config_backup_path def set_backup_file_name(self): - #Generate a random name using today's date and a 8 digit random number + # Generate a random name using today's date and a 8 digit random number for_conf = self.todays_date + "-" + self.site_slug + "-site_config_backup.json" for_db = self.todays_date + "-" + self.site_slug + "-database.sql.gz" ext = "tgz" if self.compress_files else "tar" @@ -191,8 +242,7 @@ class BackupGenerator: return file_path latest_backups = { - file_type: get_latest(pattern) - for file_type, pattern in file_type_slugs.items() + file_type: get_latest(pattern) for file_type, pattern in file_type_slugs.items() } recent_backups = { @@ -208,32 +258,40 @@ class BackupGenerator: def zip_files(self): # For backwards compatibility - pre v13 - click.secho("BackupGenerator.zip_files has been deprecated in favour of BackupGenerator.backup_files", fg="yellow") + click.secho( + "BackupGenerator.zip_files has been deprecated in favour of" + " BackupGenerator.backup_files", + fg="yellow", + ) return self.backup_files() def get_summary(self): summary = { "config": { "path": self.backup_path_conf, - "size": get_file_size(self.backup_path_conf, format=True) + "size": get_file_size(self.backup_path_conf, format=True), }, "database": { "path": self.backup_path_db, - "size": get_file_size(self.backup_path_db, format=True) - } + "size": get_file_size(self.backup_path_db, format=True), + }, } - if os.path.exists(self.backup_path_files) and os.path.exists(self.backup_path_private_files): - summary.update({ - "public": { - "path": self.backup_path_files, - "size": get_file_size(self.backup_path_files, format=True) - }, - "private": { - "path": self.backup_path_private_files, - "size": get_file_size(self.backup_path_private_files, format=True) + if os.path.exists(self.backup_path_files) and os.path.exists( + self.backup_path_private_files + ): + summary.update( + { + "public": { + "path": self.backup_path_files, + "size": get_file_size(self.backup_path_files, format=True), + }, + "private": { + "path": self.backup_path_private_files, + "size": get_file_size(self.backup_path_private_files, format=True), + }, } - }) + ) return summary @@ -249,13 +307,17 @@ class BackupGenerator: for folder in ("public", "private"): files_path = frappe.get_site_path(folder, "files") - backup_path = self.backup_path_files if folder=="public" else self.backup_path_private_files + backup_path = ( + self.backup_path_files if folder == "public" else self.backup_path_private_files + ) if self.compress_files: cmd_string = "tar cf - {1} | gzip > {0}" else: cmd_string = "tar -cf {0} {1}" - output = subprocess.check_output(cmd_string.format(backup_path, files_path), shell=True) + output = subprocess.check_output( + cmd_string.format(backup_path, files_path), shell=True + ) if self.verbose and output: print(output.decode("utf8")) @@ -271,39 +333,57 @@ class BackupGenerator: import frappe.utils # escape reserved characters - args = dict([item[0], frappe.utils.esc(str(item[1]), '$ ')] - for item in self.__dict__.copy().items()) + args = dict( + [item[0], frappe.utils.esc(str(item[1]), "$ ")] + for item in self.__dict__.copy().items() + ) if self.backup_includes: print("Backing Up Tables: {0}\n".format(", ".join(self.backup_includes))) elif self.backup_excludes: print("Skipping Tables: {0}\n".format(", ".join(self.backup_excludes))) - if self.db_type == 'postgres': + if self.db_type == "postgres": if self.backup_includes: - args["include"] = " ".join(["--table='{0}'".format(table) for table in self.backup_includes]) + args["include"] = " ".join( + ["--table='{0}'".format(table) for table in self.backup_includes] + ) elif self.backup_excludes: - args["exclude"] = " ".join(["--exclude-table='{0}'".format(table) for table in self.backup_excludes]) + args["exclude"] = " ".join( + ["--exclude-table='{0}'".format(table) for table in self.backup_excludes] + ) - cmd_string = "pg_dump postgres://{user}:{password}@{db_host}:{db_port}/{db_name} {include} {exclude} | gzip > {backup_path_db}" + cmd_string = ( + "pg_dump postgres://{user}:{password}@{db_host}:{db_port}/{db_name}" + " {include} {exclude} | gzip > {backup_path_db}" + ) else: if self.backup_includes: args["include"] = " ".join(["'{0}'".format(x) for x in self.backup_includes]) elif self.backup_excludes: - args["exclude"] = " ".join(["--ignore-table='{0}.{1}'".format(frappe.conf.db_name, table) for table in self.backup_excludes]) + args["exclude"] = " ".join( + [ + "--ignore-table='{0}.{1}'".format(frappe.conf.db_name, table) + for table in self.backup_excludes + ] + ) - cmd_string = "mysqldump --single-transaction --quick --lock-tables=false -u {user} -p{password} {db_name} -h {db_host} -P {db_port} {include} {exclude} | gzip > {backup_path_db}" + cmd_string = ( + "mysqldump --single-transaction --quick --lock-tables=false -u {user}" + " -p{password} {db_name} -h {db_host} -P {db_port} {include} {exclude}" + " | gzip > {backup_path_db}" + ) command = cmd_string.format( - user=args.get('user'), - password=args.get('password'), - db_host=args.get('db_host'), - db_port=args.get('db_port'), - db_name=args.get('db_name'), - backup_path_db=args.get('backup_path_db'), - exclude=args.get('exclude', ''), - include=args.get('include', '') + user=args.get("user"), + password=args.get("password"), + db_host=args.get("db_host"), + db_port=args.get("db_port"), + db_name=args.get("db_name"), + backup_path_db=args.get("backup_path_db"), + exclude=args.get("exclude", ""), + include=args.get("include", ""), ) if self.verbose: @@ -313,13 +393,17 @@ class BackupGenerator: def send_email(self): """ - Sends the link to backup file located at erpnext/backups + Sends the link to backup file located at erpnext/backups """ from frappe.email import get_system_managers recipient_list = get_system_managers() - db_backup_url = get_url(os.path.join('backups', os.path.basename(self.backup_path_db))) - files_backup_url = get_url(os.path.join('backups', os.path.basename(self.backup_path_files))) + db_backup_url = get_url( + os.path.join("backups", os.path.basename(self.backup_path_db)) + ) + files_backup_url = get_url( + os.path.join("backups", os.path.basename(self.backup_path_files)) + ) msg = """Hello, @@ -331,11 +415,13 @@ Your backups are ready to be downloaded. This link will be valid for 24 hours. A new backup will be available for download only after 24 hours.""" % { "db_backup_url": db_backup_url, - "files_backup_url": files_backup_url + "files_backup_url": files_backup_url, } datetime_str = datetime.fromtimestamp(os.stat(self.backup_path_db).st_ctime) - subject = datetime_str.strftime("%d/%m/%Y %H:%M:%S") + """ - Backup ready to be downloaded""" + subject = ( + datetime_str.strftime("%d/%m/%Y %H:%M:%S") + """ - Backup ready to be downloaded""" + ) frappe.sendmail(recipients=recipient_list, msg=msg, subject=subject) return recipient_list @@ -344,16 +430,25 @@ download only after 24 hours.""" % { @frappe.whitelist() def get_backup(): """ - This function is executed when the user clicks on - Toos > Download Backup + This function is executed when the user clicks on + Toos > Download Backup """ delete_temp_backups() - odb = BackupGenerator(frappe.conf.db_name, frappe.conf.db_name,\ - frappe.conf.db_password, db_host = frappe.db.host,\ - db_type=frappe.conf.db_type, db_port=frappe.conf.db_port) + odb = BackupGenerator( + frappe.conf.db_name, + frappe.conf.db_name, + frappe.conf.db_password, + db_host=frappe.db.host, + db_type=frappe.conf.db_type, + db_port=frappe.conf.db_port, + ) odb.get_backup() recipient_list = odb.send_email() - frappe.msgprint(_("Download link for your backup will be emailed on the following email address: {0}").format(', '.join(recipient_list))) + frappe.msgprint( + _( + "Download link for your backup will be emailed on the following email address: {0}" + ).format(", ".join(recipient_list)) + ) @frappe.whitelist() @@ -375,44 +470,86 @@ def fetch_latest_backups(): ) database, public, private, config = odb.get_recent_backup(older_than=24 * 30) - return { - "database": database, - "public": public, - "private": private, - "config": config - } + return {"database": database, "public": public, "private": private, "config": config} -def scheduled_backup(older_than=6, ignore_files=False, backup_path=None, backup_path_db=None, backup_path_files=None, backup_path_private_files=None, backup_path_conf=None, ignore_conf=False, include_doctypes="", exclude_doctypes="", compress=False, force=False, verbose=False): +def scheduled_backup( + older_than=6, + ignore_files=False, + backup_path=None, + backup_path_db=None, + backup_path_files=None, + backup_path_private_files=None, + backup_path_conf=None, + ignore_conf=False, + include_doctypes="", + exclude_doctypes="", + compress=False, + force=False, + verbose=False, +): """this function is called from scheduler - deletes backups older than 7 days - takes backup""" - odb = new_backup(older_than=older_than, ignore_files=ignore_files, backup_path=None, backup_path_db=backup_path_db, backup_path_files=backup_path_files, backup_path_private_files=backup_path_private_files, backup_path_conf=backup_path_conf, ignore_conf=ignore_conf, include_doctypes=include_doctypes, exclude_doctypes=exclude_doctypes, compress=compress, force=force, verbose=verbose) + deletes backups older than 7 days + takes backup""" + odb = new_backup( + older_than=older_than, + ignore_files=ignore_files, + backup_path=None, + backup_path_db=backup_path_db, + backup_path_files=backup_path_files, + backup_path_private_files=backup_path_private_files, + backup_path_conf=backup_path_conf, + ignore_conf=ignore_conf, + include_doctypes=include_doctypes, + exclude_doctypes=exclude_doctypes, + compress=compress, + force=force, + verbose=verbose, + ) return odb -def new_backup(older_than=6, ignore_files=False, backup_path=None, backup_path_db=None, backup_path_files=None, backup_path_private_files=None, backup_path_conf=None, ignore_conf=False, include_doctypes="", exclude_doctypes="", compress=False, force=False, verbose=False): - delete_temp_backups(older_than = frappe.conf.keep_backups_for_hours or 24) - odb = BackupGenerator(frappe.conf.db_name, frappe.conf.db_name,\ - frappe.conf.db_password, - db_host = frappe.db.host, - db_port = frappe.db.port, - db_type = frappe.conf.db_type, - backup_path=backup_path, - backup_path_db=backup_path_db, - backup_path_files=backup_path_files, - backup_path_private_files=backup_path_private_files, - backup_path_conf=backup_path_conf, - ignore_conf=ignore_conf, - include_doctypes=include_doctypes, - exclude_doctypes=exclude_doctypes, - verbose=verbose, - compress_files=compress) + +def new_backup( + older_than=6, + ignore_files=False, + backup_path=None, + backup_path_db=None, + backup_path_files=None, + backup_path_private_files=None, + backup_path_conf=None, + ignore_conf=False, + include_doctypes="", + exclude_doctypes="", + compress=False, + force=False, + verbose=False, +): + delete_temp_backups(older_than=frappe.conf.keep_backups_for_hours or 24) + odb = BackupGenerator( + frappe.conf.db_name, + frappe.conf.db_name, + frappe.conf.db_password, + db_host=frappe.db.host, + db_port=frappe.db.port, + db_type=frappe.conf.db_type, + backup_path=backup_path, + backup_path_db=backup_path_db, + backup_path_files=backup_path_files, + backup_path_private_files=backup_path_private_files, + backup_path_conf=backup_path_conf, + ignore_conf=ignore_conf, + include_doctypes=include_doctypes, + exclude_doctypes=exclude_doctypes, + verbose=verbose, + compress_files=compress, + ) odb.get_backup(older_than, ignore_files, force=force) return odb + def delete_temp_backups(older_than=24): """ - Cleans up the backup_link_path directory by deleting files older than 24 hours + Cleans up the backup_link_path directory by deleting files older than 24 hours """ backup_path = get_backup_path() if os.path.exists(backup_path): @@ -422,54 +559,72 @@ def delete_temp_backups(older_than=24): if is_file_old(this_file_path, older_than): os.remove(this_file_path) + def is_file_old(db_file_name, older_than=24): - """ - Checks if file exists and is older than specified hours - Returns -> - True: file does not exist or file is old - False: file is new - """ - if os.path.isfile(db_file_name): - from datetime import timedelta - #Get timestamp of the file - file_datetime = datetime.fromtimestamp\ - (os.stat(db_file_name).st_ctime) - if datetime.today() - file_datetime >= timedelta(hours = older_than): - if _verbose: - print("File is old") - return True - else: - if _verbose: - print("File is recent") - return False + """ + Checks if file exists and is older than specified hours + Returns -> + True: file does not exist or file is old + False: file is new + """ + if os.path.isfile(db_file_name): + from datetime import timedelta + + # Get timestamp of the file + file_datetime = datetime.fromtimestamp(os.stat(db_file_name).st_ctime) + if datetime.today() - file_datetime >= timedelta(hours=older_than): + if _verbose: + print("File is old") + return True else: if _verbose: - print("File does not exist") - return True + print("File is recent") + return False + else: + if _verbose: + print("File does not exist") + return True + def get_backup_path(): backup_path = frappe.utils.get_site_path(conf.get("backup_path", "private/backups")) return backup_path -def backup(with_files=False, backup_path_db=None, backup_path_files=None, backup_path_private_files=None, backup_path_conf=None, quiet=False): + +def backup( + with_files=False, + backup_path_db=None, + backup_path_files=None, + backup_path_private_files=None, + backup_path_conf=None, + quiet=False, +): "Backup" - odb = scheduled_backup(ignore_files=not with_files, backup_path_db=backup_path_db, backup_path_files=backup_path_files, backup_path_private_files=backup_path_private_files, backup_path_conf=backup_path_conf, force=True) + odb = scheduled_backup( + ignore_files=not with_files, + backup_path_db=backup_path_db, + backup_path_files=backup_path_files, + backup_path_private_files=backup_path_private_files, + backup_path_conf=backup_path_conf, + force=True, + ) return { "backup_path_db": odb.backup_path_db, "backup_path_files": odb.backup_path_files, - "backup_path_private_files": odb.backup_path_private_files + "backup_path_private_files": odb.backup_path_private_files, } if __name__ == "__main__": """ - is_file_old db_name user password db_host db_type db_port - get_backup db_name user password db_host db_type db_port + is_file_old db_name user password db_host db_type db_port + get_backup db_name user password db_host db_type db_port """ import sys + cmd = sys.argv[1] - db_type = 'mariadb' + db_type = "mariadb" try: db_type = sys.argv[6] except IndexError: @@ -482,19 +637,47 @@ if __name__ == "__main__": pass if cmd == "is_file_old": - odb = BackupGenerator(sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5] or "localhost", db_type=db_type, db_port=db_port) + odb = BackupGenerator( + sys.argv[2], + sys.argv[3], + sys.argv[4], + sys.argv[5] or "localhost", + db_type=db_type, + db_port=db_port, + ) is_file_old(odb.db_file_name) if cmd == "get_backup": - odb = BackupGenerator(sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5] or "localhost", db_type=db_type, db_port=db_port) + odb = BackupGenerator( + sys.argv[2], + sys.argv[3], + sys.argv[4], + sys.argv[5] or "localhost", + db_type=db_type, + db_port=db_port, + ) odb.get_backup() if cmd == "take_dump": - odb = BackupGenerator(sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5] or "localhost", db_type=db_type, db_port=db_port) + odb = BackupGenerator( + sys.argv[2], + sys.argv[3], + sys.argv[4], + sys.argv[5] or "localhost", + db_type=db_type, + db_port=db_port, + ) odb.take_dump() if cmd == "send_email": - odb = BackupGenerator(sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5] or "localhost", db_type=db_type, db_port=db_port) + odb = BackupGenerator( + sys.argv[2], + sys.argv[3], + sys.argv[4], + sys.argv[5] or "localhost", + db_type=db_type, + db_port=db_port, + ) odb.send_email("abc.sql.gz") if cmd == "delete_temp_backups": From bd6f52305eb1e48f370217de89608a16976e207c Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 7 Oct 2020 14:32:18 +0530 Subject: [PATCH 025/273] fix: Show traceback if verbose --- frappe/commands/site.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frappe/commands/site.py b/frappe/commands/site.py index dc5d8b838e..446166d44c 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -422,6 +422,8 @@ def backup(context, with_files=False, backup_path=None, backup_path_db=None, bac ) except Exception: click.secho("Backup failed for Site {0}. Database or site_config.json may be corrupted".format(site), fg="red") + if verbose: + print(frappe.get_traceback()) exit_code = 1 continue From 7519ba5e1c81a721bc248762b28f03657b12210a Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 7 Oct 2020 14:32:40 +0530 Subject: [PATCH 026/273] fix: Backup tables correctly ? --- frappe/utils/backups.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index b59f5526e9..5a91ad656e 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -129,9 +129,12 @@ class BackupGenerator: "include": get_tables(self.include_doctypes.strip().split(",")), "exclude": get_tables(self.exclude_doctypes.strip().split(",")), } + specified_tables = get_tables(frappe.conf.get("backup", {}).get("includes", [])) + include_tables = (specified_tables + base_tables) if specified_tables else [] + conf_tables = { - "include": get_tables(frappe.conf.backup.get("includes", [])) + base_tables, - "exclude": get_tables(frappe.conf.backup.get("excludes", [])), + "include": include_tables, + "exclude": get_tables(frappe.conf.get("backup", {}).get("excludes", [])), } self.backup_includes = passed_tables["include"] @@ -387,7 +390,7 @@ class BackupGenerator: ) if self.verbose: - print(command) + print(command + "\n") err, out = frappe.utils.execute_in_shell(command) From 7d638d298c098036fcf509d3a6086418644e4b57 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 7 Oct 2020 14:33:02 +0530 Subject: [PATCH 027/273] chore: remove old comment that lied --- frappe/utils/backups.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index 5a91ad656e..5b1f6b0616 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -619,10 +619,6 @@ def backup( if __name__ == "__main__": - """ - is_file_old db_name user password db_host db_type db_port - get_backup db_name user password db_host db_type db_port - """ import sys cmd = sys.argv[1] From 26387401b39517f61fd50f965a5bf84c99e9817a Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 7 Oct 2020 21:06:23 +0530 Subject: [PATCH 028/273] test: Added tests for bench backup skip tables * Added utils for coloured outputs * Fixed bug during last branch update --- frappe/tests/test_commands.py | 112 +++++++++++++++++++++++++++++++++- frappe/utils/backups.py | 2 +- 2 files changed, 110 insertions(+), 4 deletions(-) diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index e7e639c775..39d76aaba5 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -1,10 +1,13 @@ # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors # imports - standard imports +import json import os import shlex import subprocess +import sys import unittest +import gzip from glob import glob # imports - module imports @@ -12,12 +15,66 @@ import frappe from frappe.utils.backups import fetch_latest_backups +# TODO: check frappe.cli.coloured_output to set coloured output! + +def supports_color(): + """ + Returns True if the running system's terminal supports color, and False + otherwise. + """ + plat = sys.platform + supported_platform = plat != 'Pocket PC' and (plat != 'win32' or 'ANSICON' in os.environ) + # isatty is not always implemented, #6223. + is_a_tty = hasattr(sys.stdout, 'isatty') and sys.stdout.isatty() + return supported_platform and is_a_tty + + +class color(dict): + nc = '\033[0m' + blue = '\033[94m' + green = '\033[92m' + yellow = '\033[93m' + red = '\033[91m' + silver = '\033[90m' + + def __getattr__(self, key): + if supports_color(): + ret = self.get(key) + else: + ret = "" + return ret + + def clean(value): - if isinstance(value, (bytes, str)): - value = value.decode().strip() + """Strips and converts bytes to str + + Args: + value ([type]): [description] + + Returns: + [type]: [description] + """ + if isinstance(value, bytes): + value = value.decode() + if isinstance(value, str): + value = value.strip() return value +def exists_in_backup(doctypes, file): + """Checks if the list of doctypes exist in the database.sql.gz file supplied + + Args: + doctypes (list): List of DocTypes to be checked + file (str): Path of the database file + + Returns: + bool: True if all tables exist + """ + with gzip.open(file, 'rb') as f: + content = f.read().decode("utf8") + return all(["CREATE TABLE `tab{}`".format(doctype).lower() in content.lower() for doctype in doctypes]) + class BaseTestCommands(unittest.TestCase): def execute(self, command, kwargs=None): site = {"site": frappe.local.site} @@ -25,7 +82,8 @@ class BaseTestCommands(unittest.TestCase): kwargs.update(site) else: kwargs = site - command = command.replace("\n", " ").format(**kwargs) + command = " ".join(command.split()).format(**kwargs) + print("{0}$ {1}{2}".format(color.silver, command, color.nc)) command = shlex.split(command) self._proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) self.stdout = clean(self._proc.stdout) @@ -54,6 +112,21 @@ class TestCommands(BaseTestCommands): self.assertEquals(self.stdout[1:-1], frappe.bold(text='DocType')) def test_backup(self): + backup = { + "includes": { + "includes": [ + "ToDo", + "Note", + ] + }, + "excludes": { + "excludes": [ + "Activity Log", + "Access Log", + "Error Log" + ] + } + } home = os.path.expanduser("~") site_backup_path = frappe.utils.get_site_path("private", "backups") @@ -119,3 +192,36 @@ class TestCommands(BaseTestCommands): # test 6: take a backup with --verbose self.execute("bench --site {site} backup --verbose") self.assertEquals(self.returncode, 0) + + # test 7: take a backup with frappe.conf.backup.includes + self.execute("bench --site {site} set-config backup '{includes}' --as-dict", {"includes": json.dumps(backup["includes"])}) + self.execute("bench --site {site} backup --verbose") + self.assertEquals(self.returncode, 0) + database = fetch_latest_backups()["database"] + self.assertTrue(exists_in_backup(backup["includes"]["includes"], database)) + + # test 8: take a backup with frappe.conf.backup.excludes + self.execute("bench --site {site} set-config backup '{excludes}' --as-dict", {"excludes": json.dumps(backup["excludes"])}) + self.execute("bench --site {site} backup --verbose") + self.assertEquals(self.returncode, 0) + database = fetch_latest_backups()["database"] + self.assertFalse(exists_in_backup(backup["excludes"]["excludes"], database)) + self.assertTrue(exists_in_backup(backup["includes"]["includes"], database)) + + # test 9: take a backup with --include (with frappe.conf.excludes still set) + self.execute("bench --site {site} backup --include '{include}'", {"include": ",".join(backup["includes"]["includes"])}) + self.assertEquals(self.returncode, 0) + database = fetch_latest_backups()["database"] + self.assertTrue(exists_in_backup(backup["includes"]["includes"], database)) + + # test 10: take a backup with --exclude + self.execute("bench --site {site} backup --exclude '{exclude}'", {"exclude": ",".join(backup["excludes"]["excludes"])}) + self.assertEquals(self.returncode, 0) + database = fetch_latest_backups()["database"] + self.assertFalse(exists_in_backup(backup["excludes"]["excludes"], database)) + + # test 11: take a backup with --ignore-backup-conf + self.execute("bench --site {site} backup --ignore-backup-conf") + self.assertEquals(self.returncode, 0) + database = fetch_latest_backups()["database"] + self.assertTrue(exists_in_backup(backup["excludes"]["excludes"], database)) diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index 5b1f6b0616..43dd7c17f1 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -497,7 +497,7 @@ def scheduled_backup( odb = new_backup( older_than=older_than, ignore_files=ignore_files, - backup_path=None, + backup_path=backup_path, backup_path_db=backup_path_db, backup_path_files=backup_path_files, backup_path_private_files=backup_path_private_files, From a22347d806f98da3c54b2cab8a4c9f2d59a26022 Mon Sep 17 00:00:00 2001 From: Mathieu Brunot Date: Wed, 7 Oct 2020 18:10:18 +0200 Subject: [PATCH 029/273] chore: Apply suggestions from code review Co-authored-by: Prssanna Desai --- frappe/geo/utils.py | 4 ++-- frappe/public/js/frappe/views/map/map_view.js | 13 ++++--------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/frappe/geo/utils.py b/frappe/geo/utils.py index d7011a7eb0..f1102f2289 100644 --- a/frappe/geo/utils.py +++ b/frappe/geo/utils.py @@ -67,7 +67,7 @@ def return_location(doctype, filters_sql): try: coords = frappe.db.sql('''SELECT name, location FROM `tab{}` WHERE {}'''.format(doctype, filters_sql), as_dict=True) except InternalError: - frappe.msgprint(frappe._('This Doctype did not contains location fields'), raise_exception=True) + frappe.msgprint(frappe._('This Doctype does not contain location fields'), raise_exception=True) return else: coords = frappe.get_all(doctype, fields=['name', 'location']) @@ -80,7 +80,7 @@ def return_coordinates(doctype, filters_sql): try: coords = frappe.db.sql('''SELECT name, latitude, longitude FROM `tab{}` WHERE {}'''.format(doctype, filters_sql), as_dict=True) except InternalError: - frappe.msgprint(frappe._('This Doctype did not contains latitude and longitude fields'), raise_exception=True) + frappe.msgprint(frappe._('This Doctype does not contain latitude and longitude fields'), raise_exception=True) return else: coords = frappe.get_all(doctype, fields=['name', 'latitude', 'longitude']) diff --git a/frappe/public/js/frappe/views/map/map_view.js b/frappe/public/js/frappe/views/map/map_view.js index 8b46ef1a95..c70199f041 100644 --- a/frappe/public/js/frappe/views/map/map_view.js +++ b/frappe/public/js/frappe/views/map/map_view.js @@ -61,17 +61,12 @@ frappe.views.MapView = class MapView extends frappe.views.ListView { } get_coords() { - let get_coords_method; - if (JSON.stringify(frappe.listview_settings) === '{}') { - get_coords_method = 'frappe.geo.utils.get_coords'; - } else { - get_coords_method = frappe.listview_settings[this.doctype].get_coords_method; - } - if (cur_list.meta.fields.find(i => i.fieldname === 'location') && - cur_list.meta.fields.find(i => i.fieldtype === 'Geolocation')) { + let get_coords_method = this.settings && this.settings.get_coords_method || 'frappe.geo.utils.get_coords'; + + if (cur_list.meta.fields.find(i => i.fieldname === 'location' && i.fieldtype === 'Geolocation')) { this.type = 'location_field'; } - if (cur_list.meta.fields.find(i => i.fieldname === "latitude") && + else if (cur_list.meta.fields.find(i => i.fieldname === "latitude") && cur_list.meta.fields.find(i => i.fieldname === "longitude")) { this.type = 'coordinates'; } From ad313cf549e3be84fc9c3deaa42b8a59f7fbc61d Mon Sep 17 00:00:00 2001 From: Mathieu Brunot Date: Wed, 7 Oct 2020 18:12:03 +0200 Subject: [PATCH 030/273] chore: Apply suggestions from code review Co-authored-by: Prssanna Desai --- frappe/geo/utils.py | 2 +- frappe/public/js/frappe/list/list_sidebar.js | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/frappe/geo/utils.py b/frappe/geo/utils.py index f1102f2289..f4b0284226 100644 --- a/frappe/geo/utils.py +++ b/frappe/geo/utils.py @@ -41,7 +41,7 @@ def merge_location_features_in_one(coords): '''Merging all features from location field.''' geojson_dict = [] for element in coords: - geojson_loc = json.loads(element['location']) + geojson_loc = frappe.parse_json(element['location']) for coord in geojson_loc['features']: coord['properties']['name'] = element['name'] geojson_dict.append(coord.copy()) diff --git a/frappe/public/js/frappe/list/list_sidebar.js b/frappe/public/js/frappe/list/list_sidebar.js index b3f65253c7..4d637602a3 100644 --- a/frappe/public/js/frappe/list/list_sidebar.js +++ b/frappe/public/js/frappe/list/list_sidebar.js @@ -91,11 +91,10 @@ frappe.views.ListSidebar = class ListSidebar { show_list_link = true; } - if ((JSON.stringify(frappe.listview_settings) !== '{}' && - frappe.listview_settings[this.list_view.doctype].get_coords_method) || - (this.list_view.meta.fields.find(i => i.fieldname === "latitude") && - this.list_view.meta.fields.find(i => i.fieldname === "longitude")) || - (this.list_view.meta.fields.find(i => i.fieldname === 'location') && this.list_view.meta.fields.find(i => i.fieldtype === 'Geolocation'))) { + if (this.list_view.settings.get_coords_method || + (this.list_view.meta.fields.find(i => i.fieldname === "latitude") && + this.list_view.meta.fields.find(i => i.fieldname === "longitude")) || + (this.list_view.meta.fields.find(i => i.fieldname === 'location' && i.fieldtype == 'Geolocation'))) this.sidebar.find('.list-link[data-view="Map"]').removeClass('hide'); show_list_link = true; } From 3c181bf2a203a8c82060e70b0e502e90090fca7e Mon Sep 17 00:00:00 2001 From: "mathieu.brunot" Date: Wed, 7 Oct 2020 18:57:01 +0200 Subject: [PATCH 031/273] Replace spaces by tabs Signed-off-by: mathieu.brunot --- frappe/geo/utils.py | 114 ++++++++++++++++---------------- frappe/tests/tests_geo_utils.py | 44 ++++++------ 2 files changed, 79 insertions(+), 79 deletions(-) diff --git a/frappe/geo/utils.py b/frappe/geo/utils.py index f4b0284226..ffb27e62dc 100644 --- a/frappe/geo/utils.py +++ b/frappe/geo/utils.py @@ -13,84 +13,84 @@ from pymysql import InternalError @frappe.whitelist() def get_coords(doctype, filters, type): - '''Get a geojson dict representing a doctype.''' - filters_sql = get_coords_conditions(doctype, filters)[4:] + '''Get a geojson dict representing a doctype.''' + filters_sql = get_coords_conditions(doctype, filters)[4:] - coords = None - if type == 'location_field': - coords = return_location(doctype, filters_sql) - elif type == 'coordinates': - coords = return_coordinates(doctype, filters_sql) + coords = None + if type == 'location_field': + coords = return_location(doctype, filters_sql) + elif type == 'coordinates': + coords = return_coordinates(doctype, filters_sql) - out = convert_to_geojson(type, coords) - return out + out = convert_to_geojson(type, coords) + return out def convert_to_geojson(type, coords): - '''Converts GPS coordinates to geoJSON string.''' - geojson = {"type": "FeatureCollection", "features": None} + '''Converts GPS coordinates to geoJSON string.''' + geojson = {"type": "FeatureCollection", "features": None} - if type == 'location_field': - geojson['features'] = merge_location_features_in_one(coords) - elif type == 'coordinates': - geojson['features'] = create_gps_markers(coords) + if type == 'location_field': + geojson['features'] = merge_location_features_in_one(coords) + elif type == 'coordinates': + geojson['features'] = create_gps_markers(coords) - return geojson + return geojson def merge_location_features_in_one(coords): - '''Merging all features from location field.''' - geojson_dict = [] - for element in coords: - geojson_loc = frappe.parse_json(element['location']) - for coord in geojson_loc['features']: - coord['properties']['name'] = element['name'] - geojson_dict.append(coord.copy()) + '''Merging all features from location field.''' + geojson_dict = [] + for element in coords: + geojson_loc = frappe.parse_json(element['location']) + for coord in geojson_loc['features']: + coord['properties']['name'] = element['name'] + geojson_dict.append(coord.copy()) - return geojson_dict + return geojson_dict def create_gps_markers(coords): - '''Build Marker based on latitude and longitude.''' - geojson_dict = [] - for i in coords: - node = {"type": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": None}} - node['properties']['name'] = i.name - node['geometry']['coordinates'] = [i.latitude, i.longitude] - geojson_dict.append(node.copy()) + '''Build Marker based on latitude and longitude.''' + geojson_dict = [] + for i in coords: + node = {"type": "Feature", "properties": {}, "geometry": {"type": "Point", "coordinates": None}} + node['properties']['name'] = i.name + node['geometry']['coordinates'] = [i.latitude, i.longitude] + geojson_dict.append(node.copy()) - return geojson_dict + return geojson_dict def return_location(doctype, filters_sql): - '''Get name and location fields for Doctype.''' - if filters_sql: - try: - coords = frappe.db.sql('''SELECT name, location FROM `tab{}` WHERE {}'''.format(doctype, filters_sql), as_dict=True) - except InternalError: - frappe.msgprint(frappe._('This Doctype does not contain location fields'), raise_exception=True) - return - else: - coords = frappe.get_all(doctype, fields=['name', 'location']) - return coords + '''Get name and location fields for Doctype.''' + if filters_sql: + try: + coords = frappe.db.sql('''SELECT name, location FROM `tab{}` WHERE {}'''.format(doctype, filters_sql), as_dict=True) + except InternalError: + frappe.msgprint(frappe._('This Doctype does not contain location fields'), raise_exception=True) + return + else: + coords = frappe.get_all(doctype, fields=['name', 'location']) + return coords def return_coordinates(doctype, filters_sql): - '''Get name, latitude and longitude fields for Doctype.''' - if filters_sql: - try: - coords = frappe.db.sql('''SELECT name, latitude, longitude FROM `tab{}` WHERE {}'''.format(doctype, filters_sql), as_dict=True) - except InternalError: - frappe.msgprint(frappe._('This Doctype does not contain latitude and longitude fields'), raise_exception=True) - return - else: - coords = frappe.get_all(doctype, fields=['name', 'latitude', 'longitude']) - return coords + '''Get name, latitude and longitude fields for Doctype.''' + if filters_sql: + try: + coords = frappe.db.sql('''SELECT name, latitude, longitude FROM `tab{}` WHERE {}'''.format(doctype, filters_sql), as_dict=True) + except InternalError: + frappe.msgprint(frappe._('This Doctype does not contain latitude and longitude fields'), raise_exception=True) + return + else: + coords = frappe.get_all(doctype, fields=['name', 'latitude', 'longitude']) + return coords def get_coords_conditions(doctype, filters=None): - '''Returns SQL conditions with user permissions and filters for event queries.''' - from frappe.desk.reportview import get_filters_cond - if not frappe.has_permission(doctype): - frappe.throw(frappe._("Not Permitted"), frappe.PermissionError) + '''Returns SQL conditions with user permissions and filters for event queries.''' + from frappe.desk.reportview import get_filters_cond + if not frappe.has_permission(doctype): + frappe.throw(frappe._("Not Permitted"), frappe.PermissionError) - return get_filters_cond(doctype, filters, [], with_match_conditions=True) + return get_filters_cond(doctype, filters, [], with_match_conditions=True) diff --git a/frappe/tests/tests_geo_utils.py b/frappe/tests/tests_geo_utils.py index 3c5757423e..2067a6aa97 100644 --- a/frappe/tests/tests_geo_utils.py +++ b/frappe/tests/tests_geo_utils.py @@ -11,32 +11,32 @@ from frappe.geo.utils import get_coords class TestGeoUtils(unittest.TestCase): - def setUp(self): - self.todo = frappe.get_doc( - dict(doctype='ToDo', description='Test description', assigned_by='Administrator')).insert() + def setUp(self): + self.todo = frappe.get_doc( + dict(doctype='ToDo', description='Test description', assigned_by='Administrator')).insert() - self.test_location_dict = {'type': 'FeatureCollection', 'features': [ - {'type': 'Feature', 'properties': {}, "geometry": {'type': 'Point', 'coordinates': [49.20433, 55.753395]}}]} - self.test_location = frappe.get_doc({'name': 'Test Location', 'doctype': 'Location', - 'location': str(self.test_location_dict)}) + self.test_location_dict = {'type': 'FeatureCollection', 'features': [ + {'type': 'Feature', 'properties': {}, "geometry": {'type': 'Point', 'coordinates': [49.20433, 55.753395]}}]} + self.test_location = frappe.get_doc({'name': 'Test Location', 'doctype': 'Location', + 'location': str(self.test_location_dict)}) - self.test_filter_exists = [['Location', 'name', 'like', '%Test Location%']] - self.test_filter_not_exists = [['Location', 'name', 'like', '%Test Location Not exists%']] - self.test_filter_todo = [['ToDo', 'description', 'like', '%Test description%']] + self.test_filter_exists = [['Location', 'name', 'like', '%Test Location%']] + self.test_filter_not_exists = [['Location', 'name', 'like', '%Test Location Not exists%']] + self.test_filter_todo = [['ToDo', 'description', 'like', '%Test description%']] - def test_get_coords_location_with_filter_exists(self): - coords = get_coords('Location', self.test_filter_exists, 'location_field') - self.assertEqual(self.test_location_dict['features'][0]['geometry'], coords['features'][0]['geometry']) + def test_get_coords_location_with_filter_exists(self): + coords = get_coords('Location', self.test_filter_exists, 'location_field') + self.assertEqual(self.test_location_dict['features'][0]['geometry'], coords['features'][0]['geometry']) - def test_get_coords_location_with_filter_not_exists(self): - coords = get_coords('Location', self.test_filter_not_exists, 'location_field') - self.assertEqual(coords, {'type': 'FeatureCollection', 'features': []}) + def test_get_coords_location_with_filter_not_exists(self): + coords = get_coords('Location', self.test_filter_not_exists, 'location_field') + self.assertEqual(coords, {'type': 'FeatureCollection', 'features': []}) - def test_get_coords_from_not_existable_location(self): - self.assertRaises(frappe.ValidationError, get_coords, 'ToDo', self.test_filter_todo, 'location_field') + def test_get_coords_from_not_existable_location(self): + self.assertRaises(frappe.ValidationError, get_coords, 'ToDo', self.test_filter_todo, 'location_field') - def test_get_coords_from_not_existable_coords(self): - self.assertRaises(frappe.ValidationError, get_coords, 'ToDo', self.test_filter_todo, 'coordinates') + def test_get_coords_from_not_existable_coords(self): + self.assertRaises(frappe.ValidationError, get_coords, 'ToDo', self.test_filter_todo, 'coordinates') - def tearDown(self): - self.todo.delete() + def tearDown(self): + self.todo.delete() From ff1f1e755da30f028ff52e7b878a3ee48905e4ca Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 8 Oct 2020 15:45:15 +0530 Subject: [PATCH 032/273] fix: Dynamic summary spacing --- frappe/utils/backups.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index 43dd7c17f1..1ae976807a 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -302,8 +302,12 @@ class BackupGenerator: backup_summary = self.get_summary() print("Backup Summary for {0} at {1}".format(frappe.local.site, now())) + title = max([len(x) for x in backup_summary]) + path = max([len(x["path"]) for x in backup_summary.values()]) + for _type, info in backup_summary.items(): - print("{0:8}: {1:85} {2}".format(_type.title(), info["path"], info["size"])) + template = "{{0:{0}}}: {{1:{1}}} {{2}}".format(title, path) + print(template.format(_type.title(), info["path"], info["size"])) def backup_files(self): import subprocess From 104186906f3ecf8abe130c35e34c4c4ba9b21e36 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 8 Oct 2020 16:23:14 +0530 Subject: [PATCH 033/273] feat: Show command execution summary in message format --- frappe/tests/test_commands.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index 39d76aaba5..b63a886a2f 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -82,14 +82,25 @@ class BaseTestCommands(unittest.TestCase): kwargs.update(site) else: kwargs = site - command = " ".join(command.split()).format(**kwargs) - print("{0}$ {1}{2}".format(color.silver, command, color.nc)) - command = shlex.split(command) + self.command = " ".join(command.split()).format(**kwargs) + print("{0}$ {1}{2}".format(color.silver, self.command, color.nc)) + command = shlex.split(self.command) self._proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) self.stdout = clean(self._proc.stdout) self.stderr = clean(self._proc.stderr) self.returncode = clean(self._proc.returncode) + def _formatMessage(self, msg, standardMsg): + output = super(BaseTestCommands, self)._formatMessage(msg, standardMsg) + cmd_execution_summary = "\n".join([ + "-" * 70, + "Last Command Execution Summary:", + "Command: {}".format(self.command) if self.command else "", + "Standard Output: {}".format(self.stdout) if self.stdout else "", + "Standard Error: {}".format(self.stderr) if self.stderr else "", + "Return Code: {}".format(self.returncode) if self.returncode else "", + ]).strip() + return "{}\n\n{}".format(output, cmd_execution_summary) class TestCommands(BaseTestCommands): def test_execute(self): From 44a09191b38f13682ef6f8a0ce21058ce3c25756 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 8 Oct 2020 18:56:29 +0530 Subject: [PATCH 034/273] fix: Use public schema for postgres site tx @surajshetty3416 --- frappe/tests/test_commands.py | 9 +++++++-- frappe/utils/backups.py | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index b63a886a2f..e7cb9f5d40 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -71,9 +71,14 @@ def exists_in_backup(doctypes, file): Returns: bool: True if all tables exist """ - with gzip.open(file, 'rb') as f: + predicate = ( + 'CREATE TABLE public."tab{}"' + if frappe.conf.db_type == "postgres" + else "CREATE TABLE `tab{}`" + ) + with gzip.open(file, "rb") as f: content = f.read().decode("utf8") - return all(["CREATE TABLE `tab{}`".format(doctype).lower() in content.lower() for doctype in doctypes]) + return all([predicate.format(doctype).lower() in content.lower() for doctype in doctypes]) class BaseTestCommands(unittest.TestCase): def execute(self, command, kwargs=None): diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index 1ae976807a..ab5783006c 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -353,7 +353,7 @@ class BackupGenerator: if self.db_type == "postgres": if self.backup_includes: args["include"] = " ".join( - ["--table='{0}'".format(table) for table in self.backup_includes] + ["--table='public.\"{0}\"'".format(table) for table in self.backup_includes] ) elif self.backup_excludes: args["exclude"] = " ".join( From 368a031da3b389bb65b859a3165cc9640bf56e0b Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 9 Oct 2020 11:19:16 +0530 Subject: [PATCH 035/273] fix: Update postgres exlcude table data --- frappe/tests/test_commands.py | 2 +- frappe/utils/backups.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index e7cb9f5d40..19fd90e99c 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -72,7 +72,7 @@ def exists_in_backup(doctypes, file): bool: True if all tables exist """ predicate = ( - 'CREATE TABLE public."tab{}"' + 'COPY public."tab{}"' if frappe.conf.db_type == "postgres" else "CREATE TABLE `tab{}`" ) diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index ab5783006c..552debbaa1 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -357,7 +357,7 @@ class BackupGenerator: ) elif self.backup_excludes: args["exclude"] = " ".join( - ["--exclude-table='{0}'".format(table) for table in self.backup_excludes] + ["--exclude-table-data='public.\"{0}\"'".format(table) for table in self.backup_excludes] ) cmd_string = ( From 9219db4c2a4dbb0709cc0f893622262cc4defc2e Mon Sep 17 00:00:00 2001 From: Saurabh Date: Fri, 16 Oct 2020 14:09:35 +0530 Subject: [PATCH 036/273] fix: validate email id before passing to formataddr --- frappe/utils/__init__.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py index d3bf1dd10c..9640bcd394 100644 --- a/frappe/utils/__init__.py +++ b/frappe/utils/__init__.py @@ -66,9 +66,14 @@ def get_email_address(user=None): def get_formatted_email(user, mail=None): """get Email Address of user formatted as: `John Doe `""" fullname = get_fullname(user) + if not mail: - mail = get_email_address(user) - return cstr(make_header(decode_header(formataddr((fullname, mail))))) + mail = get_email_address(user) or validate_email_address(user) + + if not mail: + return '' + else: + return cstr(make_header(decode_header(formataddr((fullname, mail))))) def extract_email_id(email): """fetch only the email part of the Email Address""" From 59d35cb5aeadd161701c1b5651cf0aad21facc7e Mon Sep 17 00:00:00 2001 From: marination Date: Tue, 20 Oct 2020 20:21:07 +0530 Subject: [PATCH 037/273] fix: Conditionally set parent field only on DocType rename --- frappe/model/rename_doc.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frappe/model/rename_doc.py b/frappe/model/rename_doc.py index 7a2129e76e..e04d59ab6a 100644 --- a/frappe/model/rename_doc.py +++ b/frappe/model/rename_doc.py @@ -249,8 +249,11 @@ def update_link_field_values(link_fields, old, new, doctype): # or no longer exists pass else: + parent = field['parent'] + # because the table hasn't been renamed yet! - parent = field['parent'] if field['parent']!=new else old + if field['parent'] == new and doctype == "DocType": + parent = old frappe.db.sql(""" update `tab{table_name}` set `{fieldname}`=%s From 007e59184da031beeee99f6f5333dda6676bde1d Mon Sep 17 00:00:00 2001 From: "mathieu.brunot" Date: Sat, 24 Oct 2020 03:03:16 +0200 Subject: [PATCH 038/273] chore: Fix sider issues Signed-off-by: mathieu.brunot --- frappe/geo/utils.py | 2 -- frappe/public/js/frappe/views/map/map_view.js | 3 +-- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/frappe/geo/utils.py b/frappe/geo/utils.py index ffb27e62dc..77e48acb76 100644 --- a/frappe/geo/utils.py +++ b/frappe/geo/utils.py @@ -4,8 +4,6 @@ from __future__ import unicode_literals -import json - import frappe from pymysql import InternalError diff --git a/frappe/public/js/frappe/views/map/map_view.js b/frappe/public/js/frappe/views/map/map_view.js index c70199f041..48e4ac8b3e 100644 --- a/frappe/public/js/frappe/views/map/map_view.js +++ b/frappe/public/js/frappe/views/map/map_view.js @@ -65,8 +65,7 @@ frappe.views.MapView = class MapView extends frappe.views.ListView { if (cur_list.meta.fields.find(i => i.fieldname === 'location' && i.fieldtype === 'Geolocation')) { this.type = 'location_field'; - } - else if (cur_list.meta.fields.find(i => i.fieldname === "latitude") && + } else if (cur_list.meta.fields.find(i => i.fieldname === "latitude") && cur_list.meta.fields.find(i => i.fieldname === "longitude")) { this.type = 'coordinates'; } From 969aa86e68c0726d202a73bc1d488f9f89e72bc2 Mon Sep 17 00:00:00 2001 From: prssanna Date: Tue, 27 Oct 2020 17:46:21 +0530 Subject: [PATCH 039/273] fix: calculate chart data from beginning of period - show period as label --- .../dashboard_chart/dashboard_chart.py | 24 +++++++++++++++---- frappe/utils/data.py | 11 +++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py index 7e2d952928..c9b54561ea 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py @@ -8,8 +8,7 @@ from frappe import _ import datetime import json from frappe.utils.dashboard import cache_source, get_from_date_from_timespan -from frappe.utils import nowdate, add_to_date, getdate, get_last_day, formatdate,\ - get_datetime, cint, now_datetime +from frappe.utils import * from frappe.model.naming import append_number_if_name_exists from frappe.boot import get_allowed_reports from frappe.model.document import Document @@ -156,6 +155,7 @@ def add_chart_to_dashboard(args): def get_chart_config(chart, filters, timespan, timegrain, from_date, to_date): if not from_date: from_date = get_from_date_from_timespan(to_date, timespan) + from_date = get_period_beginning(from_date, timegrain) if not to_date: to_date = now_datetime() @@ -163,7 +163,6 @@ def get_chart_config(chart, filters, timespan, timegrain, from_date, to_date): datefield = chart.based_on aggregate_function = get_aggregate_function(chart.chart_type) value_field = chart.value_based_on or '1' - from_date = from_date.strftime('%Y-%m-%d') to_date = to_date filters.append([doctype, datefield, '>=', from_date, False]) @@ -185,7 +184,7 @@ def get_chart_config(chart, filters, timespan, timegrain, from_date, to_date): result = get_result(data, timegrain, from_date, to_date) chart_config = { - "labels": [formatdate(r[0].strftime('%Y-%m-%d')) for r in result], + "labels": [get_period(r[0], timegrain) for r in result], "datasets": [{ "name": chart.name, "values": [r[1] for r in result] @@ -282,7 +281,7 @@ def get_result(data, timegrain, from_date, to_date): start_date = getdate(from_date) end_date = getdate(to_date) - result = [[start_date, 0.0]] + result = [] while start_date < end_date: next_date = get_next_expected_date(start_date, timegrain) @@ -304,6 +303,21 @@ def get_next_expected_date(date, timegrain): next_date = get_period_ending(add_to_date(date, days=1), timegrain) return getdate(next_date) +def get_period_beginning(date, timegrain): + as_str = True + if timegrain == 'Daily': + pass + elif timegrain == 'Weekly': + date = get_first_day_of_week(date, as_str=as_str) + elif timegrain == 'Monthly': + date = get_first_day(date, as_str=as_str) + elif timegrain == 'Quarterly': + date = get_quarter_start(date, as_str=as_str) + elif timegrain == 'Yearly': + date = get_year_start(date, as_str=as_str) + + return date + def get_period_ending(date, timegrain): date = getdate(date) if timegrain == 'Daily': diff --git a/frappe/utils/data.py b/frappe/utils/data.py index 41f247da45..f189b6aa53 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -441,6 +441,17 @@ def get_timespan_date_range(timespan): return date_range_map.get(timespan) +def get_period(date, interval='Monthly'): + date = getdate(date) + months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + return { + 'Daily': date.strftime('%d-%m-%y'), + 'Weekly': date.strftime('%d-%m-%y'), + 'Monthly': str(months[date.month - 1]) + ' ' + str(date.year), + 'Quarterly': 'Quarter ' + str(((date.month-1)//3)+1) + ' ' + str(date.year), + 'Yearly': str(date.year) + }[interval] + def global_date_format(date, format="long"): """returns localized date in the form of January 1, 2012""" date = getdate(date) From 6334f2b6d1a4fa2427aced54e6a683c5405a38a7 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 27 Oct 2020 21:23:26 +0530 Subject: [PATCH 040/273] fix: Commit after rename tables to avoid floating tables --- frappe/core/doctype/doctype/doctype.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 8a9c130fbe..d45542a9d0 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -387,12 +387,14 @@ class DocType(Document): def after_rename(self, old, new, merge=False): """Change table name using `RENAME TABLE` if table exists. Or update `doctype` property for Single type.""" + if self.issingle: frappe.db.sql("""update tabSingles set doctype=%s where doctype=%s""", (new, old)) frappe.db.sql("""update tabSingles set value=%s where doctype=%s and field='name' and value = %s""", (new, new, old)) else: frappe.db.sql("rename table `tab%s` to `tab%s`" % (old, new)) + frappe.db.commit() def rename_files_and_folders(self, old, new): # move files From 650ff243b85d7b6751e4c950e50cbefd1cbc5f9e Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 27 Oct 2020 21:24:16 +0530 Subject: [PATCH 041/273] fix: Rename files after successful database update --- frappe/core/doctype/doctype/doctype.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index d45542a9d0..d7cc9ba919 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -380,10 +380,6 @@ class DocType(Document): if merge: frappe.throw(_("DocType can not be merged")) - # Do not rename and move files and folders for custom doctype - if not self.custom and not frappe.flags.in_test and not frappe.flags.in_patch: - self.rename_files_and_folders(old, new) - def after_rename(self, old, new, merge=False): """Change table name using `RENAME TABLE` if table exists. Or update `doctype` property for Single type.""" @@ -396,6 +392,10 @@ class DocType(Document): frappe.db.sql("rename table `tab%s` to `tab%s`" % (old, new)) frappe.db.commit() + # Do not rename and move files and folders for custom doctype + if not self.custom and not frappe.flags.in_test and not frappe.flags.in_patch: + self.rename_files_and_folders(old, new) + def rename_files_and_folders(self, old, new): # move files new_path = get_doc_path(self.module, 'doctype', new) From c73e779373a5d7a0954df9045e9663cbb6bc232d Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 27 Oct 2020 21:26:47 +0530 Subject: [PATCH 042/273] fix: Validate existing doctype names too --- frappe/model/rename_doc.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frappe/model/rename_doc.py b/frappe/model/rename_doc.py index 7a2129e76e..291a709119 100644 --- a/frappe/model/rename_doc.py +++ b/frappe/model/rename_doc.py @@ -49,9 +49,7 @@ def rename_doc(doctype, old, new, force=False, merge=False, ignore_permissions=F old_doc = frappe.get_doc(doctype, old) out = old_doc.run_method("before_rename", old, new, merge) or {} new = (out.get("new") or new) if isinstance(out, dict) else (out or new) - - if doctype != "DocType": - new = validate_rename(doctype, new, meta, merge, force, ignore_permissions) + new = validate_rename(doctype, new, meta, merge, force, ignore_permissions) if not merge: rename_parent_and_child(doctype, old, new, meta) From 7f43169c4a7f195c9b462cb076d3c7f6ef947884 Mon Sep 17 00:00:00 2001 From: prssanna Date: Wed, 28 Oct 2020 00:11:43 +0530 Subject: [PATCH 043/273] refactor: reorganise date functions indashboard_chart.py --- .../dashboard_chart/dashboard_chart.py | 75 ++----------------- frappe/utils/dashboard.py | 15 ---- frappe/utils/data.py | 32 +++++--- frappe/utils/dateutils.py | 62 ++++++++++++++- 4 files changed, 85 insertions(+), 99 deletions(-) diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py index c9b54561ea..587f3c02b0 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py @@ -7,8 +7,11 @@ import frappe from frappe import _ import datetime import json -from frappe.utils.dashboard import cache_source, get_from_date_from_timespan -from frappe.utils import * +from frappe.utils.dashboard import cache_source +from frappe.utils import nowdate, add_to_date, getdate, formatdate,\ + get_datetime, cint, now_datetime +from frappe.utils.dateutils import\ + get_period, get_period_beginning, get_period_ending, get_from_date_from_timespan from frappe.model.naming import append_number_if_name_exists from frappe.boot import get_allowed_reports from frappe.model.document import Document @@ -303,74 +306,6 @@ def get_next_expected_date(date, timegrain): next_date = get_period_ending(add_to_date(date, days=1), timegrain) return getdate(next_date) -def get_period_beginning(date, timegrain): - as_str = True - if timegrain == 'Daily': - pass - elif timegrain == 'Weekly': - date = get_first_day_of_week(date, as_str=as_str) - elif timegrain == 'Monthly': - date = get_first_day(date, as_str=as_str) - elif timegrain == 'Quarterly': - date = get_quarter_start(date, as_str=as_str) - elif timegrain == 'Yearly': - date = get_year_start(date, as_str=as_str) - - return date - -def get_period_ending(date, timegrain): - date = getdate(date) - if timegrain == 'Daily': - pass - elif timegrain == 'Weekly': - date = get_week_ending(date) - elif timegrain == 'Monthly': - date = get_month_ending(date) - elif timegrain == 'Quarterly': - date = get_quarter_ending(date) - elif timegrain == 'Yearly': - date = get_year_ending(date) - - return getdate(date) - -def get_week_ending(date): - # week starts on monday - from datetime import timedelta - start = date - timedelta(days = date.weekday()) - end = start + timedelta(days=6) - - return end - -def get_month_ending(date): - month_of_the_year = int(date.strftime('%m')) - # first day of next month (note month starts from 1) - - date = add_to_date('{}-01-01'.format(date.year), months = month_of_the_year) - # last day of this month - return add_to_date(date, days=-1) - -def get_quarter_ending(date): - date = getdate(date) - - # find the earliest quarter ending date that is after - # the given date - for month in (3, 6, 9, 12): - quarter_end_month = getdate('{}-{}-01'.format(date.year, month)) - quarter_end_date = getdate(get_last_day(quarter_end_month)) - if date <= quarter_end_date: - date = quarter_end_date - break - - return date - -def get_year_ending(date): - ''' returns year ending of the given date ''' - - # first day of next year (note year starts from 1) - date = add_to_date('{}-01-01'.format(date.year), months = 12) - # last day of this month - return add_to_date(date, days=-1) - @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def get_charts_for_user(doctype, txt, searchfield, start, page_len, filters): diff --git a/frappe/utils/dashboard.py b/frappe/utils/dashboard.py index 7eaf470767..e386dcd881 100644 --- a/frappe/utils/dashboard.py +++ b/frappe/utils/dashboard.py @@ -61,21 +61,6 @@ def generate_and_cache_results(args, function, cache_key, chart): frappe.db.set_value("Dashboard Chart", args.chart_name, "last_synced_on", frappe.utils.now(), update_modified = False) return results -def get_from_date_from_timespan(to_date, timespan): - days = months = years = 0 - if timespan == "Last Week": - days = -7 - if timespan == "Last Month": - months = -1 - elif timespan == "Last Quarter": - months = -3 - elif timespan == "Last Year": - years = -1 - elif timespan == "All Time": - years = -50 - return add_to_date(to_date, years=years, months=months, days=days, - as_datetime=True) - def get_dashboards_with_link(docname, doctype): dashboards = [] links = [] diff --git a/frappe/utils/data.py b/frappe/utils/data.py index f189b6aa53..34659e1cac 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -221,6 +221,27 @@ def get_last_day(dt): """ return get_first_day(dt, 0, 1) + datetime.timedelta(-1) +def get_quarter_ending(date): + date = getdate(date) + + # find the earliest quarter ending date that is after + # the given date + for month in (3, 6, 9, 12): + quarter_end_month = getdate('{}-{}-01'.format(date.year, month)) + quarter_end_date = getdate(get_last_day(quarter_end_month)) + if date <= quarter_end_date: + date = quarter_end_date + break + + return date + +def get_year_ending(date): + ''' returns year ending of the given date ''' + + # first day of next year (note year starts from 1) + date = add_to_date('{}-01-01'.format(date.year), months = 12) + # last day of this month + return add_to_date(date, days=-1) def get_time(time_str): if isinstance(time_str, datetime.datetime): @@ -441,17 +462,6 @@ def get_timespan_date_range(timespan): return date_range_map.get(timespan) -def get_period(date, interval='Monthly'): - date = getdate(date) - months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] - return { - 'Daily': date.strftime('%d-%m-%y'), - 'Weekly': date.strftime('%d-%m-%y'), - 'Monthly': str(months[date.month - 1]) + ' ' + str(date.year), - 'Quarterly': 'Quarter ' + str(((date.month-1)//3)+1) + ' ' + str(date.year), - 'Yearly': str(date.year) - }[interval] - def global_date_format(date, format="long"): """returns localized date in the form of January 1, 2012""" date = getdate(date) diff --git a/frappe/utils/dateutils.py b/frappe/utils/dateutils.py index 90abdeb6cd..2895eb0568 100644 --- a/frappe/utils/dateutils.py +++ b/frappe/utils/dateutils.py @@ -7,8 +7,8 @@ import frappe.defaults import datetime from frappe.utils import get_datetime from frappe.utils import add_to_date, getdate -from frappe.utils.data import get_last_day_of_week -from frappe.desk.doctype.dashboard_chart.dashboard_chart import get_period_ending +from frappe.utils.data import get_first_day, get_first_day_of_week, get_quarter_start, get_year_start,\ + get_last_day, get_last_day_of_week, get_quarter_ending, get_year_ending from six import string_types # global values -- used for caching @@ -102,4 +102,60 @@ def get_dates_from_timegrain(from_date, to_date, timegrain="Daily"): else: date = get_period_ending(add_to_date(dates[-1], years=years, months=months, days=days), timegrain) dates.append(date) - return dates \ No newline at end of file + return dates + +def get_from_date_from_timespan(to_date, timespan): + days = months = years = 0 + if timespan == "Last Week": + days = -7 + if timespan == "Last Month": + months = -1 + elif timespan == "Last Quarter": + months = -3 + elif timespan == "Last Year": + years = -1 + elif timespan == "All Time": + years = -50 + return add_to_date(to_date, years=years, months=months, days=days, + as_datetime=True) + +def get_period(date, interval='Monthly'): + date = getdate(date) + months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + return { + 'Daily': date.strftime('%d-%m-%y'), + 'Weekly': date.strftime('%d-%m-%y'), + 'Monthly': str(months[date.month - 1]) + ' ' + str(date.year), + 'Quarterly': 'Quarter ' + str(((date.month-1)//3)+1) + ' ' + str(date.year), + 'Yearly': str(date.year) + }[interval] + +def get_period_beginning(date, timegrain): + as_str = True + if timegrain == 'Daily': + pass + elif timegrain == 'Weekly': + date = get_first_day_of_week(date, as_str=as_str) + elif timegrain == 'Monthly': + date = get_first_day(date, as_str=as_str) + elif timegrain == 'Quarterly': + date = get_quarter_start(date, as_str=as_str) + elif timegrain == 'Yearly': + date = get_year_start(date, as_str=as_str) + + return date + +def get_period_ending(date, timegrain): + date = getdate(date) + if timegrain == 'Daily': + pass + elif timegrain == 'Weekly': + date = get_last_day_of_week(date) + elif timegrain == 'Monthly': + date = get_last_day(date) + elif timegrain == 'Quarterly': + date = get_quarter_ending(date) + elif timegrain == 'Yearly': + date = get_year_ending(date) + + return getdate(date) From 7302c85f55fe5cca5f222468235913057d03e03e Mon Sep 17 00:00:00 2001 From: prssanna Date: Wed, 28 Oct 2020 00:37:34 +0530 Subject: [PATCH 044/273] fix: dashboard chart tests --- .../dashboard_chart/test_dashboard_chart.py | 30 ++++--------- frappe/utils/dateutils.py | 45 ++++++++----------- 2 files changed, 27 insertions(+), 48 deletions(-) diff --git a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py index 5e39998e62..13fea8282d 100644 --- a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py @@ -4,9 +4,9 @@ from __future__ import unicode_literals import unittest, frappe -from frappe.utils import getdate, formatdate, get_last_day -from frappe.desk.doctype.dashboard_chart.dashboard_chart import (get, - get_period_ending) +from frappe.utils import getdate, formatdate +from frappe.utils.dateutils import get_period_ending, get_period_beginning, get_period +from frappe.desk.doctype.dashboard_chart.dashboard_chart import get from datetime import datetime from dateutil.relativedelta import relativedelta @@ -53,15 +53,11 @@ class TestDashboardChart(unittest.TestCase): cur_date = datetime.now() - relativedelta(years=1) result = get(chart_name='Test Dashboard Chart', refresh=1) - self.assertEqual(result.get('labels')[0], formatdate(cur_date.strftime('%Y-%m-%d'))) - - if formatdate(cur_date.strftime('%Y-%m-%d')) == formatdate(get_last_day(cur_date).strftime('%Y-%m-%d')): - cur_date += relativedelta(months=1) + self.assertEqual(result.get('labels')[0], get_period(cur_date)) for idx in range(1, 13): - month = get_last_day(cur_date) month = formatdate(month.strftime('%Y-%m-%d')) - self.assertEqual(result.get('labels')[idx], month) + self.assertEqual(result.get('labels')[idx], get_period(month)) cur_date += relativedelta(months=1) frappe.db.rollback() @@ -87,15 +83,11 @@ class TestDashboardChart(unittest.TestCase): cur_date = datetime.now() - relativedelta(years=1) result = get(chart_name ='Test Empty Dashboard Chart', refresh=1) - self.assertEqual(result.get('labels')[0], formatdate(cur_date.strftime('%Y-%m-%d'))) - - if formatdate(cur_date.strftime('%Y-%m-%d')) == formatdate(get_last_day(cur_date).strftime('%Y-%m-%d')): - cur_date += relativedelta(months=1) + self.assertEqual(result.get('labels')[0], get_period(cur_date)) for idx in range(1, 13): - month = get_last_day(cur_date) month = formatdate(month.strftime('%Y-%m-%d')) - self.assertEqual(result.get('labels')[idx], month) + self.assertEqual(result.get('labels')[idx], get_period(month)) cur_date += relativedelta(months=1) frappe.db.rollback() @@ -124,15 +116,11 @@ class TestDashboardChart(unittest.TestCase): cur_date = datetime.now() - relativedelta(years=1) result = get(chart_name ='Test Empty Dashboard Chart 2', refresh = 1) - self.assertEqual(result.get('labels')[0], formatdate(cur_date.strftime('%Y-%m-%d'))) - - if formatdate(cur_date.strftime('%Y-%m-%d')) == formatdate(get_last_day(cur_date).strftime('%Y-%m-%d')): - cur_date += relativedelta(months=1) + self.assertEqual(result.get('labels')[0], get_period(cur_date)) for idx in range(1, 13): - month = get_last_day(cur_date) month = formatdate(month.strftime('%Y-%m-%d')) - self.assertEqual(result.get('labels')[idx], month) + self.assertEqual(result.get('labels')[idx], get_period(month)) cur_date += relativedelta(months=1) # only 1 data point with value diff --git a/frappe/utils/dateutils.py b/frappe/utils/dateutils.py index 2895eb0568..06b434a512 100644 --- a/frappe/utils/dateutils.py +++ b/frappe/utils/dateutils.py @@ -5,8 +5,7 @@ from __future__ import unicode_literals import frappe import frappe.defaults import datetime -from frappe.utils import get_datetime -from frappe.utils import add_to_date, getdate +from frappe.utils import get_datetime, add_to_date, getdate from frappe.utils.data import get_first_day, get_first_day_of_week, get_quarter_start, get_year_start,\ get_last_day, get_last_day_of_week, get_quarter_ending, get_year_ending from six import string_types @@ -130,32 +129,24 @@ def get_period(date, interval='Monthly'): 'Yearly': str(date.year) }[interval] -def get_period_beginning(date, timegrain): - as_str = True - if timegrain == 'Daily': - pass - elif timegrain == 'Weekly': - date = get_first_day_of_week(date, as_str=as_str) - elif timegrain == 'Monthly': - date = get_first_day(date, as_str=as_str) - elif timegrain == 'Quarterly': - date = get_quarter_start(date, as_str=as_str) - elif timegrain == 'Yearly': - date = get_year_start(date, as_str=as_str) - - return date +def get_period_beginning(date, timegrain, as_str=True): + return getdate({ + 'Daily': date, + 'Weekly': get_first_day_of_week(date), + 'Monthly': get_first_day(date), + 'Quarterly': get_quarter_start(date), + 'Yearly': get_year_start(date) + }[timegrain]) def get_period_ending(date, timegrain): date = getdate(date) if timegrain == 'Daily': - pass - elif timegrain == 'Weekly': - date = get_last_day_of_week(date) - elif timegrain == 'Monthly': - date = get_last_day(date) - elif timegrain == 'Quarterly': - date = get_quarter_ending(date) - elif timegrain == 'Yearly': - date = get_year_ending(date) - - return getdate(date) + return date + else: + return getdate({ + 'Daily': date, + 'Weekly': get_last_day_of_week(date), + 'Monthly': get_last_day(date), + 'Quarterly': get_quarter_ending(date), + 'Yearly': get_year_ending(date) + }[timegrain]) From 2dd9ae212755d24afd464d9167310adc90ac7f49 Mon Sep 17 00:00:00 2001 From: prssanna Date: Wed, 28 Oct 2020 11:10:58 +0530 Subject: [PATCH 045/273] fix: remove unused imports --- frappe/desk/doctype/dashboard_chart/dashboard_chart.py | 2 +- frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py index 587f3c02b0..c2e0f78624 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py @@ -8,7 +8,7 @@ from frappe import _ import datetime import json from frappe.utils.dashboard import cache_source -from frappe.utils import nowdate, add_to_date, getdate, formatdate,\ +from frappe.utils import nowdate, add_to_date, getdate,\ get_datetime, cint, now_datetime from frappe.utils.dateutils import\ get_period, get_period_beginning, get_period_ending, get_from_date_from_timespan diff --git a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py index 13fea8282d..d723171337 100644 --- a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals import unittest, frappe from frappe.utils import getdate, formatdate -from frappe.utils.dateutils import get_period_ending, get_period_beginning, get_period +from frappe.utils.dateutils import get_period_ending, get_period from frappe.desk.doctype.dashboard_chart.dashboard_chart import get from datetime import datetime From 99a260e32acda6e680b37c71c6d552f8e1e72278 Mon Sep 17 00:00:00 2001 From: prssanna Date: Wed, 28 Oct 2020 11:56:15 +0530 Subject: [PATCH 046/273] fix: chart tests --- .../dashboard_chart/dashboard_chart.py | 4 ++-- .../dashboard_chart/test_dashboard_chart.py | 19 ++++++++----------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py index c2e0f78624..184fef7634 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py @@ -166,6 +166,7 @@ def get_chart_config(chart, filters, timespan, timegrain, from_date, to_date): datefield = chart.based_on aggregate_function = get_aggregate_function(chart.chart_type) value_field = chart.value_based_on or '1' + from_date = from_date.strftime('%Y-%m-%d') to_date = to_date filters.append([doctype, datefield, '>=', from_date, False]) @@ -283,8 +284,7 @@ def get_aggregate_function(chart_type): def get_result(data, timegrain, from_date, to_date): start_date = getdate(from_date) end_date = getdate(to_date) - - result = [] + result = [[start_date, 0.0]] if timegrain == 'Daily' else [] while start_date < end_date: next_date = get_next_expected_date(start_date, timegrain) diff --git a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py index d723171337..dcdebe6cd2 100644 --- a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py @@ -4,7 +4,7 @@ from __future__ import unicode_literals import unittest, frappe -from frappe.utils import getdate, formatdate +from frappe.utils import getdate, formatdate, get_last_day from frappe.utils.dateutils import get_period_ending, get_period from frappe.desk.doctype.dashboard_chart.dashboard_chart import get @@ -53,10 +53,9 @@ class TestDashboardChart(unittest.TestCase): cur_date = datetime.now() - relativedelta(years=1) result = get(chart_name='Test Dashboard Chart', refresh=1) - self.assertEqual(result.get('labels')[0], get_period(cur_date)) - for idx in range(1, 13): - month = formatdate(month.strftime('%Y-%m-%d')) + for idx in range(13): + month = get_last_day(cur_date) self.assertEqual(result.get('labels')[idx], get_period(month)) cur_date += relativedelta(months=1) @@ -83,10 +82,9 @@ class TestDashboardChart(unittest.TestCase): cur_date = datetime.now() - relativedelta(years=1) result = get(chart_name ='Test Empty Dashboard Chart', refresh=1) - self.assertEqual(result.get('labels')[0], get_period(cur_date)) - for idx in range(1, 13): - month = formatdate(month.strftime('%Y-%m-%d')) + for idx in range(13): + month = get_last_day(cur_date) self.assertEqual(result.get('labels')[idx], get_period(month)) cur_date += relativedelta(months=1) @@ -116,10 +114,9 @@ class TestDashboardChart(unittest.TestCase): cur_date = datetime.now() - relativedelta(years=1) result = get(chart_name ='Test Empty Dashboard Chart 2', refresh = 1) - self.assertEqual(result.get('labels')[0], get_period(cur_date)) - for idx in range(1, 13): - month = formatdate(month.strftime('%Y-%m-%d')) + for idx in range(13): + month = get_last_day(cur_date) self.assertEqual(result.get('labels')[idx], get_period(month)) cur_date += relativedelta(months=1) @@ -171,7 +168,7 @@ class TestDashboardChart(unittest.TestCase): timeseries = 1 )).insert() - result = get(chart_name ='Test Daily Dashboard Chart', refresh = 1) + result = get(chart_name = 'Test Daily Dashboard Chart', refresh = 1) self.assertEqual(result.get('datasets')[0].get('values'), [200.0, 400.0, 300.0, 0.0, 100.0, 0.0]) self.assertEqual( From 8fde766b8f3a0f4a8e87d7ea2a17d2d2a10a33f7 Mon Sep 17 00:00:00 2001 From: prssanna Date: Mon, 2 Nov 2020 18:47:14 +0530 Subject: [PATCH 047/273] fix: use get_dates_from_timegrain function --- .../dashboard_chart/dashboard_chart.py | 19 +++---------------- .../dashboard_chart/test_dashboard_chart.py | 3 +++ 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py index 184fef7634..c814f324f5 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py @@ -11,7 +11,7 @@ from frappe.utils.dashboard import cache_source from frappe.utils import nowdate, add_to_date, getdate,\ get_datetime, cint, now_datetime from frappe.utils.dateutils import\ - get_period, get_period_beginning, get_period_ending, get_from_date_from_timespan + get_period, get_period_beginning, get_period_ending, get_from_date_from_timespan, get_dates_from_timegrain from frappe.model.naming import append_number_if_name_exists from frappe.boot import get_allowed_reports from frappe.model.document import Document @@ -282,15 +282,8 @@ def get_aggregate_function(chart_type): def get_result(data, timegrain, from_date, to_date): - start_date = getdate(from_date) - end_date = getdate(to_date) - result = [[start_date, 0.0]] if timegrain == 'Daily' else [] - - while start_date < end_date: - next_date = get_next_expected_date(start_date, timegrain) - result.append([next_date, 0.0]) - start_date = next_date - + dates = get_dates_from_timegrain(from_date, to_date, timegrain) + result = [[date, 0] for date in dates] data_index = 0 if data: for i, d in enumerate(result): @@ -300,12 +293,6 @@ def get_result(data, timegrain, from_date, to_date): return result -def get_next_expected_date(date, timegrain): - next_date = None - # given date is always assumed to be the period ending date - next_date = get_period_ending(add_to_date(date, days=1), timegrain) - return getdate(next_date) - @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def get_charts_for_user(doctype, txt, searchfield, start, page_len, filters): diff --git a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py index dcdebe6cd2..b9503ee167 100644 --- a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py @@ -56,6 +56,7 @@ class TestDashboardChart(unittest.TestCase): for idx in range(13): month = get_last_day(cur_date) + month = formatdate(month.strftime('%Y-%m-%d')) self.assertEqual(result.get('labels')[idx], get_period(month)) cur_date += relativedelta(months=1) @@ -85,6 +86,7 @@ class TestDashboardChart(unittest.TestCase): for idx in range(13): month = get_last_day(cur_date) + month = formatdate(month.strftime('%Y-%m-%d')) self.assertEqual(result.get('labels')[idx], get_period(month)) cur_date += relativedelta(months=1) @@ -117,6 +119,7 @@ class TestDashboardChart(unittest.TestCase): for idx in range(13): month = get_last_day(cur_date) + month = formatdate(month.strftime('%Y-%m-%d')) self.assertEqual(result.get('labels')[idx], get_period(month)) cur_date += relativedelta(months=1) From ba33aa1b78424cd45cc9b7bf26da599d5c895361 Mon Sep 17 00:00:00 2001 From: prssanna Date: Mon, 2 Nov 2020 19:06:42 +0530 Subject: [PATCH 048/273] fix: remove unused imports --- frappe/desk/doctype/dashboard_chart/dashboard_chart.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py index c814f324f5..9cfc0a04c8 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py @@ -8,10 +8,10 @@ from frappe import _ import datetime import json from frappe.utils.dashboard import cache_source -from frappe.utils import nowdate, add_to_date, getdate,\ +from frappe.utils import nowdate, getdate, get_datetime,\ get_datetime, cint, now_datetime from frappe.utils.dateutils import\ - get_period, get_period_beginning, get_period_ending, get_from_date_from_timespan, get_dates_from_timegrain + get_period, get_period_beginning, get_from_date_from_timespan, get_dates_from_timegrain from frappe.model.naming import append_number_if_name_exists from frappe.boot import get_allowed_reports from frappe.model.document import Document From 875ba903b987bbdaffa4e838ad16f89174e3b1a4 Mon Sep 17 00:00:00 2001 From: prssanna Date: Mon, 2 Nov 2020 19:18:56 +0530 Subject: [PATCH 049/273] fix: chart test date format --- frappe/desk/doctype/dashboard_chart/dashboard_chart.py | 3 +-- .../desk/doctype/dashboard_chart/test_dashboard_chart.py | 8 +++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py index 9cfc0a04c8..3f8d7c3c79 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py @@ -8,8 +8,7 @@ from frappe import _ import datetime import json from frappe.utils.dashboard import cache_source -from frappe.utils import nowdate, getdate, get_datetime,\ - get_datetime, cint, now_datetime +from frappe.utils import nowdate, getdate, get_datetime, cint, now_datetime from frappe.utils.dateutils import\ get_period, get_period_beginning, get_from_date_from_timespan, get_dates_from_timegrain from frappe.model.naming import append_number_if_name_exists diff --git a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py index b9503ee167..3c37ad4a09 100644 --- a/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/test_dashboard_chart.py @@ -176,8 +176,7 @@ class TestDashboardChart(unittest.TestCase): self.assertEqual(result.get('datasets')[0].get('values'), [200.0, 400.0, 300.0, 0.0, 100.0, 0.0]) self.assertEqual( result.get('labels'), - [formatdate('2019-01-06'), formatdate('2019-01-07'), formatdate('2019-01-08'),\ - formatdate('2019-01-09'), formatdate('2019-01-10'), formatdate('2019-01-11')] + ['06-01-19', '07-01-19', '08-01-19', '09-01-19', '10-01-19', '11-01-19'] ) frappe.db.rollback() @@ -206,7 +205,10 @@ class TestDashboardChart(unittest.TestCase): result = get(chart_name ='Test Weekly Dashboard Chart', refresh = 1) self.assertEqual(result.get('datasets')[0].get('values'), [50.0, 300.0, 800.0, 0.0]) - self.assertEqual(result.get('labels'), [formatdate('2018-12-30'), formatdate('2019-01-06'), formatdate('2019-01-13'), formatdate('2019-01-20')]) + self.assertEqual( + result.get('labels'), + ['30-12-18', '06-01-19', '13-01-19', '20-01-19'] + ) frappe.db.rollback() From 50f6f83912e08bf1ea7331bc8f23f81509235dc2 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 3 Nov 2020 15:11:10 +0530 Subject: [PATCH 050/273] test: Added tests for dt controller + db sync --- frappe/tests/test_document.py | 39 ++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/frappe/tests/test_document.py b/frappe/tests/test_document.py index c96076cfba..38f081343a 100644 --- a/frappe/tests/test_document.py +++ b/frappe/tests/test_document.py @@ -251,6 +251,7 @@ class TestDocument(unittest.TestCase): def test_rename_doc(self): from random import choice, sample + from frappe.model.base_document import get_controller available_documents = [] doctype = "ToDo" @@ -288,4 +289,40 @@ class TestDocument(unittest.TestCase): self.assertEqual(merged_todo_doc.priority, second_todo_doc.priority) for docname in available_documents: - frappe.delete_doc(doctype, docname) \ No newline at end of file + frappe.delete_doc(doctype, docname) + + # test 3: rename doctypes with controller code + doctype = frappe._dict({ + "old": "Test Rename Document Old", + "new": "Test Rename Document New", + }) + doc = frappe.get_doc({ + "doctype": "DocType", + "module": "Custom", + "name": doctype.old, + "custom": 0, + "fields": [ + { + "label": "Some Field", + "fieldname": "some_fieldname", + "fieldtype": "Data" + } + ], + "permissions": [ + {"role": "System Manager", "read": 1} + ], + }) + doc.save() + # check if module exists exists; + # if custom, get_controller will return Document class + # if not custom, a different class will be returned + self.assertNotEqual(get_controller(doctype.old), frappe.model.document.Document) + + # rename doc via wrapper API accessible via /desk + frappe.rename_doc("DocType", doctype.old, doctype.new) + + # check if database and controllers are updated + self.assertTrue(frappe.db.exists("DocType", doctype.new)) + self.assertFalse(frappe.db.exists("DocType", doctype.old)) + with self.assertRaises(ImportError): + get_controller(doctype.old) From 5ba92a3ae18a3af2b36e87698e91a4e41ef7bc5e Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 3 Nov 2020 15:42:52 +0530 Subject: [PATCH 051/273] test: Use insert instead of save --- frappe/tests/test_document.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/tests/test_document.py b/frappe/tests/test_document.py index 38f081343a..6409195f92 100644 --- a/frappe/tests/test_document.py +++ b/frappe/tests/test_document.py @@ -312,7 +312,7 @@ class TestDocument(unittest.TestCase): {"role": "System Manager", "read": 1} ], }) - doc.save() + doc.insert() # check if module exists exists; # if custom, get_controller will return Document class # if not custom, a different class will be returned From 841f2f4a36d984d9745146f656f2465abe183e1e Mon Sep 17 00:00:00 2001 From: marination Date: Tue, 3 Nov 2020 21:51:37 +0530 Subject: [PATCH 052/273] chore: Rename Doctype Test and more explicit comment - Better decription of why the fix is done, what case it handles - Test for Renaming Doctype and Record having same name as DocType --- frappe/model/rename_doc.py | 10 ++++++++-- frappe/tests/test_document.py | 37 ++++++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/frappe/model/rename_doc.py b/frappe/model/rename_doc.py index e04d59ab6a..789a7f51cf 100644 --- a/frappe/model/rename_doc.py +++ b/frappe/model/rename_doc.py @@ -251,8 +251,14 @@ def update_link_field_values(link_fields, old, new, doctype): else: parent = field['parent'] - # because the table hasn't been renamed yet! - if field['parent'] == new and doctype == "DocType": + # Handles the case where one of the link fields belongs to + # the DocType being renamed. + # Here this field could have the current DocType as its value too. + + # In this case while updating link field value, the field's parent + # or the current DocType table name hasn't been renamed yet, + # so consider it's old name. + if parent == new and doctype == "DocType": parent = old frappe.db.sql(""" diff --git a/frappe/tests/test_document.py b/frappe/tests/test_document.py index c96076cfba..4e9984e89a 100644 --- a/frappe/tests/test_document.py +++ b/frappe/tests/test_document.py @@ -288,4 +288,39 @@ class TestDocument(unittest.TestCase): self.assertEqual(merged_todo_doc.priority, second_todo_doc.priority) for docname in available_documents: - frappe.delete_doc(doctype, docname) \ No newline at end of file + frappe.delete_doc(doctype, docname) + + def test_rename_doctype(self): + from frappe.core.doctype.doctype.test_doctype import new_doctype + + fields =[{ + "label": "Linked To", + "fieldname": "linked_to_doctype", + "fieldtype": "Link", + "options": "DocType", + "unique": 0 + }] + if not frappe.db.exists("DocType", "Rename This"): + new_doctype("Rename This", unique=0, fields=fields).insert() + + to_rename_record = frappe.get_doc({ + "doctype": "Rename This", + "linked_to_doctype": "Rename This" + }) + to_rename_record.insert() + + # Rename doctype + self.assertEqual("Renamed Doc", frappe.rename_doc("DocType", "Rename This", "Renamed Doc", force=True)) + + # Test if Doctype value has changed in Link field + renamed_doctype_record = frappe.get_doc("Renamed Doc", to_rename_record.name) + self.assertEqual(renamed_doctype_record.linked_to_doctype, "Renamed Doc") + + # Test if there are conflicts between a record and a DocType + # having the same name + old_name = to_rename_record.name + new_name = "ToDo" + self.assertEqual(new_name, frappe.rename_doc("Renamed Doc", old_name, new_name, force=True)) + + frappe.delete_doc_if_exists("Renamed Doc", "ToDo") + frappe.delete_doc_if_exists("DocType", "Renamed Doc") \ No newline at end of file From 0245b0ff4fd03162f68ab040b4a80bd528e03a93 Mon Sep 17 00:00:00 2001 From: prssanna Date: Wed, 4 Nov 2020 16:22:16 +0530 Subject: [PATCH 053/273] fix: delete prepared reports in batches --- .../prepared_report/prepared_report.py | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/frappe/core/doctype/prepared_report/prepared_report.py b/frappe/core/doctype/prepared_report/prepared_report.py index 2c02d99dad..2c4d933440 100644 --- a/frappe/core/doctype/prepared_report/prepared_report.py +++ b/frappe/core/doctype/prepared_report/prepared_report.py @@ -89,20 +89,18 @@ def delete_expired_prepared_reports(): 'creation': ['<', frappe.utils.add_days(frappe.utils.now(), -expiry_period)] }) - args = { - 'reports': prepared_reports_to_delete, - 'limit': 50 - } - - enqueue(method=delete_prepared_reports, job_name="delete_prepared_reports", **args) + batches = frappe.utils.create_batch(prepared_reports_to_delete, 50) + for batch in batches: + args = { + 'reports': batch, + } + enqueue(method=delete_prepared_reports, job_name="delete_prepared_reports", **args) @frappe.whitelist() -def delete_prepared_reports(reports, limit=None): +def delete_prepared_reports(reports): reports = frappe.parse_json(reports) - for index, doc in enumerate(reports): - if limit and index == limit: - return - frappe.delete_doc('Prepared Report', doc['name'], ignore_permissions=True) + for report in reports: + frappe.delete_doc('Prepared Report', report['name'], ignore_permissions=True) def create_json_gz_file(data, dt, dn): # Storing data in CSV file causes information loss From 22d3824fee3ac7e4253edc677ba4c98834e88d37 Mon Sep 17 00:00:00 2001 From: Steffen Brennscheidt Date: Thu, 5 Nov 2020 11:32:06 +0000 Subject: [PATCH 054/273] fix: No redis dependency during tests and install Adding a user during after_install hook caused error during install of an app --- frappe/core/doctype/user/user.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 2c5865fb69..0cec7a511c 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -98,15 +98,16 @@ class User(Document): self.share_with_self() clear_notifications(user=self.name) frappe.clear_cache(user=self.name) + now=frappe.flags.in_test or frappe.flags.in_install self.send_password_notification(self.__new_password) frappe.enqueue( 'frappe.core.doctype.user.user.create_contact', user=self, ignore_mandatory=True, - now=frappe.flags.in_test or frappe.flags.in_install + now=now ) if self.name not in ('Administrator', 'Guest') and not self.user_image: - frappe.enqueue('frappe.core.doctype.user.user.update_gravatar', name=self.name) + frappe.enqueue('frappe.core.doctype.user.user.update_gravatar', name=self.name, now=now) def has_website_permission(self, ptype, user, verbose=False): """Returns true if current user is the session user""" From 476f21eb4dfdcb93a17582b76fd052ee6c3c3d3a Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 5 Nov 2020 18:26:36 +0530 Subject: [PATCH 055/273] fix: load doc from db in get_transitions --- frappe/model/workflow.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frappe/model/workflow.py b/frappe/model/workflow.py index 7239b202bd..72ce8c9ce4 100644 --- a/frappe/model/workflow.py +++ b/frappe/model/workflow.py @@ -29,6 +29,8 @@ def get_transitions(doc, workflow = None, raise_exception=False): if doc.is_new(): return [] + doc.load_from_db() + frappe.has_permission(doc, 'read', throw=True) roles = frappe.get_roles() From a9bf8023c6862310c294cc810e3c518a49b79ba0 Mon Sep 17 00:00:00 2001 From: Himanshu Date: Sat, 7 Nov 2020 19:22:00 +0530 Subject: [PATCH 056/273] Update standard_macros.html --- frappe/templates/print_formats/standard_macros.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/templates/print_formats/standard_macros.html b/frappe/templates/print_formats/standard_macros.html index 3681a87f53..24967b9525 100644 --- a/frappe/templates/print_formats/standard_macros.html +++ b/frappe/templates/print_formats/standard_macros.html @@ -137,7 +137,7 @@ data-fieldname="{{ df.fieldname }}" data-fieldtype="{{ df.fieldtype }}" {% elif df.fieldtype=="HTML" %} {{ frappe.render_template(df.options, {"doc":doc}) }} {% elif df.fieldtype=="Currency" %} - {{ doc.get_formatted(df.fieldname, doc, translated=df.translatable) }} + {{ doc.get_formatted(df.fieldname, parent_doc or doc, translated=df.translatable) }} {% else %} {{ doc.get_formatted(df.fieldname, parent_doc or doc, translated=df.translatable) }} {% endif %} From 19a14d09e0dc872594fc5ae0f0be9505ee6e76d3 Mon Sep 17 00:00:00 2001 From: Glen Whitney Date: Sun, 8 Nov 2020 17:58:10 +0000 Subject: [PATCH 057/273] fix(quick entry): make sure init_callback is always called Prior to this PR, as noted in issue #7638, it is not possible with frappe.new_doc to initialize certain fields of the new document, such as the description of a Task or the posting_date of a Journal Entry (in ERPNext). The reason this occurs is that currently the route_options which can be set in the second argument to frappe.new_doc() are only allowed to set certain field types of a document (namely, Link, Select, Data, and Dynamic Link). Although it turns out that it would not work to allow any field type to be set in the route options (in particular, attempting to allow one to set Table field types in this way is non-functional), it would be reasonable simply to try setting other fields that cannot be set in the route_options via the callback allowed as the third argument of frappe.new_doc. And indeed, this approach works for those DocTypes that have a Quick Entry Form. For those DocTypes that do not, however, the callback is never called. This PR modifies frappe.ui.form.make_quick_entry() -- which frappe.new_doc calls to do most of its work -- so that the callback is called regardless of whether the DocType has a Quick Entry Form or not. The only slight awkwardness in this is that if there is a Quick Entry, the callback is passed the dialog object of that Quick Entry, whereas if there is no Quick Entry, the callback is only passed the doc object that is about to be edited in the standard Form interface for a new document. Nevertheless, in any case, it is now possible to write a callback which will initialize any field in the new document being created. Resolves #7638. --- frappe/public/js/frappe/form/quick_entry.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/form/quick_entry.js b/frappe/public/js/frappe/form/quick_entry.js index 2da7b8f236..0a489e26d6 100644 --- a/frappe/public/js/frappe/form/quick_entry.js +++ b/frappe/public/js/frappe/form/quick_entry.js @@ -35,7 +35,11 @@ frappe.ui.form.QuickEntryForm = Class.extend({ if (this.is_quick_entry() || this.force) { this.render_dialog(); resolve(this); - } else { + } else { // No quick entry, use full Form + // but still give callback a shot at the doc + if (this.init_callback) { + this.init_callback(this.doc); + } frappe.quick_entry = null; frappe.set_route('Form', this.doctype, this.doc.name) .then(() => resolve(this)); From 1e6674b4726ed22c6fedf64303532189d4f4fe42 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 9 Nov 2020 15:58:21 +0530 Subject: [PATCH 058/273] fix: Allow doctype export in test mode --- frappe/core/doctype/doctype/doctype.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index d7cc9ba919..da76da8db9 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -288,9 +288,12 @@ class DocType(Document): self.update_fields_to_fetch() - from frappe import conf - allow_doctype_export = frappe.flags.allow_doctype_export or (not frappe.flags.in_test and conf.get('developer_mode')) - if not self.custom and not frappe.flags.in_import and allow_doctype_export: + allow_doctype_export = ( + not self.custom + and not frappe.flags.in_import + and (frappe.flags.allow_doctype_export or frappe.conf.developer_mode) + ) + if allow_doctype_export: self.export_doc() self.make_controller_template() From af1ed2f0bcd964fb60381fc802092b9466ce5a20 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 9 Nov 2020 18:50:03 +0530 Subject: [PATCH 059/273] refactor: Move _new_site and extract_sql_from_archive from frappe.commands.site module to frappe.installer --- frappe/commands/site.py | 73 ++----------------------- frappe/installer.py | 115 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 117 insertions(+), 71 deletions(-) diff --git a/frappe/commands/site.py b/frappe/commands/site.py index 8af0b422ba..5305502b17 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -9,7 +9,7 @@ import click import frappe from frappe.commands import get_site, pass_context from frappe.exceptions import SiteNotSpecifiedError -from frappe.utils import get_site_path, touch_file +from frappe.installer import _new_site @click.command('new-site') @@ -42,57 +42,6 @@ def new_site(site, mariadb_root_username=None, mariadb_root_password=None, admin if len(frappe.utils.get_sites()) == 1: use(site) -def _new_site(db_name, site, mariadb_root_username=None, mariadb_root_password=None, - admin_password=None, verbose=False, install_apps=None, source_sql=None, force=False, - no_mariadb_socket=False, reinstall=False, db_password=None, db_type=None, db_host=None, - db_port=None, new_site=False): - """Install a new Frappe site""" - - if not force and os.path.exists(site): - print('Site {0} already exists'.format(site)) - sys.exit(1) - - if no_mariadb_socket and not db_type == "mariadb": - print('--no-mariadb-socket requires db_type to be set to mariadb.') - sys.exit(1) - - if not db_name: - import hashlib - db_name = '_' + hashlib.sha1(site.encode()).hexdigest()[:16] - - from frappe.commands.scheduler import _is_scheduler_enabled - from frappe.installer import install_db, make_site_dirs - from frappe.installer import install_app as _install_app - import frappe.utils.scheduler - - frappe.init(site=site) - - try: - - # enable scheduler post install? - enable_scheduler = _is_scheduler_enabled() - except Exception: - enable_scheduler = False - - make_site_dirs() - - installing = touch_file(get_site_path('locks', 'installing.lock')) - - install_db(root_login=mariadb_root_username, root_password=mariadb_root_password, db_name=db_name, - admin_password=admin_password, verbose=verbose, source_sql=source_sql, force=force, reinstall=reinstall, - db_password=db_password, db_type=db_type, db_host=db_host, db_port=db_port, no_mariadb_socket=no_mariadb_socket) - apps_to_install = ['frappe'] + (frappe.conf.get("install_apps") or []) + (list(install_apps) or []) - for app in apps_to_install: - _install_app(app, verbose=verbose, set_as_patched=not source_sql) - - os.remove(installing) - - frappe.utils.scheduler.toggle_scheduler(enable_scheduler) - frappe.db.commit() - - scheduler_status = "disabled" if frappe.utils.scheduler.is_scheduler_disabled() else "enabled" - print("*** Scheduler is", scheduler_status, "***") - @click.command('restore') @click.argument('sql-file-path') @@ -107,25 +56,9 @@ def _new_site(db_name, site, mariadb_root_username=None, mariadb_root_password=N @pass_context def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_password=None, db_name=None, verbose=None, install_app=None, admin_password=None, force=None, with_public_files=None, with_private_files=None): "Restore site database from an sql file" - from frappe.installer import extract_sql_gzip, extract_files, is_downgrade + from frappe.installer import extract_sql_from_archive, extract_files, is_downgrade force = context.force or force - - # Extract the gzip file if user has passed *.sql.gz file instead of *.sql file - if not os.path.exists(sql_file_path): - base_path = '..' - sql_file_path = os.path.join(base_path, sql_file_path) - if not os.path.exists(sql_file_path): - print('Invalid path {0}'.format(sql_file_path[3:])) - sys.exit(1) - elif sql_file_path.startswith(os.sep): - base_path = os.sep - else: - base_path = '.' - - if sql_file_path.endswith('sql.gz'): - decompressed_file_name = extract_sql_gzip(os.path.abspath(sql_file_path)) - else: - decompressed_file_name = sql_file_path + decompressed_file_name = extract_sql_from_archive(sql_file_path) site = get_site(context) frappe.init(site=site) diff --git a/frappe/installer.py b/frappe/installer.py index df767a3294..b1420e5d9f 100755 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -3,8 +3,90 @@ import json import os -from frappe.defaults import _clear_cache +import sys + import frappe +from frappe.defaults import _clear_cache + + +def _new_site( + db_name, + site, + mariadb_root_username=None, + mariadb_root_password=None, + admin_password=None, + verbose=False, + install_apps=None, + source_sql=None, + force=False, + no_mariadb_socket=False, + reinstall=False, + db_password=None, + db_type=None, + db_host=None, + db_port=None, + new_site=False, +): + """Install a new Frappe site""" + + if not force and os.path.exists(site): + print("Site {0} already exists".format(site)) + sys.exit(1) + + if no_mariadb_socket and not db_type == "mariadb": + print("--no-mariadb-socket requires db_type to be set to mariadb.") + sys.exit(1) + + if not db_name: + import hashlib + db_name = "_" + hashlib.sha1(site.encode()).hexdigest()[:16] + + frappe.init(site=site) + + from frappe.commands.scheduler import _is_scheduler_enabled + from frappe.utils import get_site_path, scheduler, touch_file + + try: + # enable scheduler post install? + enable_scheduler = _is_scheduler_enabled() + except Exception: + enable_scheduler = False + + make_site_dirs() + + installing = touch_file(get_site_path("locks", "installing.lock")) + + install_db( + root_login=mariadb_root_username, + root_password=mariadb_root_password, + db_name=db_name, + admin_password=admin_password, + verbose=verbose, + source_sql=source_sql, + force=force, + reinstall=reinstall, + db_password=db_password, + db_type=db_type, + db_host=db_host, + db_port=db_port, + no_mariadb_socket=no_mariadb_socket, + ) + apps_to_install = ( + ["frappe"] + (frappe.conf.get("install_apps") or []) + (list(install_apps) or []) + ) + + for app in apps_to_install: + install_app(app, verbose=verbose, set_as_patched=not source_sql) + + os.remove(installing) + + scheduler.toggle_scheduler(enable_scheduler) + frappe.db.commit() + + scheduler_status = ( + "disabled" if frappe.utils.scheduler.is_scheduler_disabled() else "enabled" + ) + print("*** Scheduler is", scheduler_status, "***") def install_db(root_login="root", root_password=None, db_name=None, source_sql=None, @@ -331,6 +413,37 @@ def remove_missing_apps(): frappe.db.set_global("installed_apps", json.dumps(installed_apps)) +def extract_sql_from_archive(sql_file_path): + """Return the path of an SQL file if the passed argument is the path of a gzipped + SQL file or an SQL file path. The path may be absolute or relative from the bench + root directory or the sites sub-directory. + + Args: + sql_file_path (str): Path of the SQL file + + Returns: + str: Path of the decompressed SQL file + """ + # Extract the gzip file if user has passed *.sql.gz file instead of *.sql file + if not os.path.exists(sql_file_path): + base_path = '..' + sql_file_path = os.path.join(base_path, sql_file_path) + if not os.path.exists(sql_file_path): + print('Invalid path {0}'.format(sql_file_path[3:])) + sys.exit(1) + elif sql_file_path.startswith(os.sep): + base_path = os.sep + else: + base_path = '.' + + if sql_file_path.endswith('sql.gz'): + decompressed_file_name = extract_sql_gzip(os.path.abspath(sql_file_path)) + else: + decompressed_file_name = sql_file_path + + return decompressed_file_name + + def extract_sql_gzip(sql_gz_path): import subprocess From 7b1fa59a29ec59535976a53bd10528fe80f6e18c Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 9 Nov 2020 18:51:33 +0530 Subject: [PATCH 060/273] feat: Restore partial backups via bench partial-restore --- frappe/commands/site.py | 17 ++++++++++++++++- frappe/database/db_manager.py | 1 - frappe/installer.py | 23 +++++++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/frappe/commands/site.py b/frappe/commands/site.py index 5305502b17..fd247b4182 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -91,6 +91,20 @@ def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_pas success_message = "Site {0} has been restored{1}".format(site, " with files" if (with_public_files or with_private_files) else "") click.secho(success_message, fg="green") +@click.command('partial-restore') +@click.argument('sql-file-path') +@click.option("--verbose", "-v", is_flag=True) +@pass_context +def partial_restore(context, sql_file_path, verbose): + from frappe.installer import partial_restore + verbose = context.verbose or verbose + + site = get_site(context) + frappe.init(site=site) + frappe.connect(site=site) + partial_restore(sql_file_path, verbose) + frappe.destroy() + @click.command('reinstall') @click.option('--admin-password', help='Administrator Password for reinstalled site') @click.option('--mariadb-root-username', help='Root username for MariaDB') @@ -650,5 +664,6 @@ commands = [ stop_recording, add_to_hosts, start_ngrok, - build_search_index + build_search_index, + partial_restore ] diff --git a/frappe/database/db_manager.py b/frappe/database/db_manager.py index 3345fce735..da1f584f57 100644 --- a/frappe/database/db_manager.py +++ b/frappe/database/db_manager.py @@ -3,7 +3,6 @@ import frappe class DbManager: - def __init__(self, db): """ Pass root_conn here for access to all databases. diff --git a/frappe/installer.py b/frappe/installer.py index b1420e5d9f..2bbf0421e3 100755 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -519,3 +519,26 @@ def is_downgrade(sql_file_path, verbose=False): print("Your site will be downgraded from Frappe {0} to {1}".format(current_version, backup_version)) return downgrade + + +def partial_restore(sql_file_path, verbose=False): + sql_file = extract_sql_from_archive(sql_file_path) + + if frappe.conf.db_type in (None, "mariadb"): + from frappe.database.mariadb.setup_db import import_db_from_sql + elif frappe.conf.db_type == "postgres": + from frappe.database.postgres.setup_db import import_db_from_sql + import warnings + from click import style + warn = style( + "Delete the tables you want to restore manually before attempting" + " partial restore operation for PostreSQL databases", + fg="yellow" + ) + warnings.warn(warn) + + import_db_from_sql(source_sql=sql_file, verbose=verbose) + + # Removing temporarily created file + if sql_file != sql_file_path: + os.remove(sql_file) From ce45343355a4f429e772c83636fe7f2c438345e1 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 9 Nov 2020 18:53:14 +0530 Subject: [PATCH 061/273] feat: Add aliases for bench backup * --include is the same as --only, -i * --exclude is the same as -e --- frappe/commands/site.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/commands/site.py b/frappe/commands/site.py index fd247b4182..646984dc8b 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -325,8 +325,8 @@ def use(site, sites_path='.'): @click.command('backup') @click.option('--with-files', default=False, is_flag=True, help="Take backup with files") -@click.option('--include', default="", type=str, help="Specify the DocTypes to backup seperated by commas") -@click.option('--exclude', default="", type=str, help="Specify the DocTypes to not backup seperated by commas") +@click.option('--include', '--only', '-i', default="", type=str, help="Specify the DocTypes to backup seperated by commas") +@click.option('--exclude', '-e', default="", type=str, help="Specify the DocTypes to not backup seperated by commas") @click.option('--backup-path', default=None, help="Set path for saving all the files in this operation") @click.option('--backup-path-db', default=None, help="Set path for saving database file") @click.option('--backup-path-files', default=None, help="Set path for saving public file") From b371c7005359e202c68adc4a2abd09030f5366e2 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 10 Nov 2020 11:11:19 +0530 Subject: [PATCH 062/273] refactor: Meaningful variable names --- frappe/database/db_manager.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frappe/database/db_manager.py b/frappe/database/db_manager.py index da1f584f57..b8ffae519b 100644 --- a/frappe/database/db_manager.py +++ b/frappe/database/db_manager.py @@ -65,10 +65,10 @@ class DbManager: esc = make_esc('$ ') from distutils.spawn import find_executable - pipe = find_executable('pv') - if pipe: - pipe = '{pipe} {source} |'.format( - pipe=pipe, + pv = find_executable('pv') + if pv: + pipe = '{pv} {source} |'.format( + pv=pv, source=source ) source = '' @@ -77,7 +77,7 @@ class DbManager: source = '< {source}'.format(source=source) if pipe: - print('Creating Database...') + print('Restoring Database file...') command = '{pipe} mysql -u {user} -p{password} -h{host} ' + ('-P{port}' if frappe.db.port else '') + ' {target} {source}' command = command.format( From e8fdaa195bf3fe276be58c455d4fc3bad56970a8 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 10 Nov 2020 09:11:19 +0530 Subject: [PATCH 063/273] refactor: PostgreSQL module methods correspond to MariaDB * Added bootstrap_database, import_db_from_sql function APIs similar to MariaDB implementations --- frappe/database/postgres/setup_db.py | 29 +++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/frappe/database/postgres/setup_db.py b/frappe/database/postgres/setup_db.py index f53872db82..6f2ba7a1b7 100644 --- a/frappe/database/postgres/setup_db.py +++ b/frappe/database/postgres/setup_db.py @@ -10,6 +10,23 @@ def setup_database(force, source_sql=None, verbose=False): root_conn.sql("CREATE user {0} password '{1}'".format(frappe.conf.db_name, frappe.conf.db_password)) root_conn.sql("GRANT ALL PRIVILEGES ON DATABASE `{0}` TO {0}".format(frappe.conf.db_name)) + root_conn.close() + + bootstrap_database(frappe.conf.db_name, verbose, source_sql=source_sql) + frappe.connect() + +def bootstrap_database(db_name, verbose, source_sql=None): + frappe.connect(db_name=db_name) + import_db_from_sql(source_sql, verbose) + frappe.connect(db_name=db_name) + if not 'tabDefaultValue' in frappe.db.get_tables(): + print('''Database not installed, this can due to lack of permission, or that the database name exists. + Check your mysql root password, or use --force to reinstall''') + sys.exit(1) + +def import_db_from_sql(source_sql=None, verbose=False): + if verbose: + print("Starting Database Import...") # we can't pass psql password in arguments in postgresql as mysql. So # set password connection parameter in environment variable @@ -19,15 +36,21 @@ def setup_database(force, source_sql=None, verbose=False): if not source_sql: source_sql = os.path.join(os.path.dirname(__file__), 'framework_postgres.sql') - subprocess.check_output([ + command = [ 'psql', frappe.conf.db_name, '-h', frappe.conf.db_host or 'localhost', '-p', str(frappe.conf.db_port or '5432'), '-U', frappe.conf.db_name, '-f', source_sql - ], env=subprocess_env) + ] - frappe.connect() + if verbose: + print(" ".join(command)) + + subprocess.check_output(command, env=subprocess_env) + + if verbose: + print(f"Imported from Database File: {source_sql}") def setup_help_database(help_db_name): root_conn = get_root_connection() From 19f87c36e51d57c2d7b96d20b43e919c7d4289e1 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 9 Nov 2020 18:57:21 +0530 Subject: [PATCH 064/273] fix: Marked is_downgrade function as only MariaDB compatible --- frappe/installer.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frappe/installer.py b/frappe/installer.py index 2bbf0421e3..b73f3f1d6e 100755 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -118,9 +118,9 @@ def install_db(root_login="root", root_password=None, db_name=None, source_sql=N def install_app(name, verbose=False, set_as_patched=True): from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs - from frappe.utils.fixtures import sync_fixtures from frappe.model.sync import sync_for from frappe.modules.utils import sync_customizations + from frappe.utils.fixtures import sync_fixtures frappe.flags.in_install = name frappe.flags.ignore_in_install = False @@ -458,9 +458,10 @@ def extract_sql_gzip(sql_gz_path): return decompressed_file + def extract_files(site_name, file_path, folder_name): - import subprocess import shutil + import subprocess # Need to do frappe.init to maintain the site locals frappe.init(site=site_name) @@ -488,6 +489,12 @@ def extract_files(site_name, file_path, folder_name): def is_downgrade(sql_file_path, verbose=False): """checks if input db backup will get downgraded on current bench""" + + # This function is only tested with mariadb + # TODO: Add postgres support + if frappe.conf.db_type not in (None, "mariadb"): + return False + from semantic_version import Version head = "INSERT INTO `tabInstalled Application` VALUES" From 142b6009fe0d2b609a9f0c40a4404a7dfb16ac72 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 10 Nov 2020 10:42:03 +0530 Subject: [PATCH 065/273] fix: Better error message on missing table --- frappe/database/mariadb/setup_db.py | 18 ++++++++++++++---- frappe/database/postgres/setup_db.py | 13 ++++++++++--- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/frappe/database/mariadb/setup_db.py b/frappe/database/mariadb/setup_db.py index a4e4d624ae..9b73d77171 100644 --- a/frappe/database/mariadb/setup_db.py +++ b/frappe/database/mariadb/setup_db.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals import frappe -import os, sys +import os from frappe.database.db_manager import DbManager expected_settings_10_2_earlier = { @@ -86,6 +86,8 @@ def drop_user_and_database(db_name, root_login, root_password): dbman.drop_database(db_name) def bootstrap_database(db_name, verbose, source_sql=None): + import sys + frappe.connect(db_name=db_name) if not check_database_settings(): print('Database settings do not match expected values; stopping database setup.') @@ -94,9 +96,17 @@ def bootstrap_database(db_name, verbose, source_sql=None): import_db_from_sql(source_sql, verbose) frappe.connect(db_name=db_name) - if not 'tabDefaultValue' in frappe.db.get_tables(): - print('''Database not installed, this can due to lack of permission, or that the database name exists. - Check your mysql root password, or use --force to reinstall''') + if 'tabDefaultValue' not in frappe.db.get_tables(): + from click import secho + + secho( + "Table 'tabDefaultValue' missing in the restored site. " + "Database not installed correctly, this can due to lack of " + "permission, or that the database name exists. Check your mysql" + " root password, validity of the backup file or use --force to" + " reinstall", + fg="red" + ) sys.exit(1) def import_db_from_sql(source_sql=None, verbose=False): diff --git a/frappe/database/postgres/setup_db.py b/frappe/database/postgres/setup_db.py index 6f2ba7a1b7..109ed0469e 100644 --- a/frappe/database/postgres/setup_db.py +++ b/frappe/database/postgres/setup_db.py @@ -19,9 +19,16 @@ def bootstrap_database(db_name, verbose, source_sql=None): frappe.connect(db_name=db_name) import_db_from_sql(source_sql, verbose) frappe.connect(db_name=db_name) - if not 'tabDefaultValue' in frappe.db.get_tables(): - print('''Database not installed, this can due to lack of permission, or that the database name exists. - Check your mysql root password, or use --force to reinstall''') + if 'tabDefaultValue' not in frappe.db.get_tables(): + import sys + from click import secho + + secho( + "Table 'tabDefaultValue' missing in the restored site. " + "This may be due to incorrect permissions or the result of a restore from a bad backup file. " + "Database not installed correctly.", + fg="red" + ) sys.exit(1) def import_db_from_sql(source_sql=None, verbose=False): From 853acf6dd05176729ed033ec36658557ea31ce76 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 10 Nov 2020 10:51:23 +0530 Subject: [PATCH 066/273] fix: Add "partial" tag in the backup file following site name to indicate its nature --- frappe/utils/backups.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index 552debbaa1..7856c8d953 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -65,6 +65,7 @@ class BackupGenerator: self.ignore_conf = ignore_conf self.include_doctypes = include_doctypes self.exclude_doctypes = exclude_doctypes + self.partial = False if not self.db_type: self.db_type = "mariadb" @@ -144,6 +145,8 @@ class BackupGenerator: self.backup_includes = self.backup_includes or conf_tables["include"] self.backup_excludes = self.backup_excludes or conf_tables["exclude"] + self.partial = (self.backup_includes or self.backup_excludes) and not self.ignore_conf + @property def site_config_backup_path(self): # For backwards compatibility @@ -199,7 +202,10 @@ class BackupGenerator: self.backup_path_conf = site_config_backup_path def set_backup_file_name(self): - # Generate a random name using today's date and a 8 digit random number + if self.partial: + # slugs postfixed with partial won't get returned by get_recent_backup + self.site_slug = self.site_slug + "-partial" + for_conf = self.todays_date + "-" + self.site_slug + "-site_config_backup.json" for_db = self.todays_date + "-" + self.site_slug + "-database.sql.gz" ext = "tgz" if self.compress_files else "tar" From b4e17b9f95f65900b9c287d8a14985fba114065b Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 10 Nov 2020 10:52:10 +0530 Subject: [PATCH 067/273] fix: Give more information about file to match verbosity --- frappe/utils/backups.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index 7856c8d953..f44bcd40e8 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -573,29 +573,29 @@ def delete_temp_backups(older_than=24): os.remove(this_file_path) -def is_file_old(db_file_name, older_than=24): +def is_file_old(file_path, older_than=24): """ Checks if file exists and is older than specified hours Returns -> True: file does not exist or file is old False: file is new """ - if os.path.isfile(db_file_name): + if os.path.isfile(file_path): from datetime import timedelta # Get timestamp of the file - file_datetime = datetime.fromtimestamp(os.stat(db_file_name).st_ctime) + file_datetime = datetime.fromtimestamp(os.stat(file_path).st_ctime) if datetime.today() - file_datetime >= timedelta(hours=older_than): if _verbose: - print("File is old") + print(f"File {file_path} is older than {older_than} hours") return True else: if _verbose: - print("File is recent") + print(f"File {file_path} is recent") return False else: if _verbose: - print("File does not exist") + print(f"File {file_path} does not exist") return True From a073a595448a2459e3d220e6406a46291f462daa Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 11 Nov 2020 09:11:19 +0530 Subject: [PATCH 068/273] fix: Add partial slug only for database file --- frappe/utils/backups.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index f44bcd40e8..e2fe4c559a 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -202,16 +202,14 @@ class BackupGenerator: self.backup_path_conf = site_config_backup_path def set_backup_file_name(self): - if self.partial: - # slugs postfixed with partial won't get returned by get_recent_backup - self.site_slug = self.site_slug + "-partial" - - for_conf = self.todays_date + "-" + self.site_slug + "-site_config_backup.json" - for_db = self.todays_date + "-" + self.site_slug + "-database.sql.gz" + # backups with the partial tag won't get returned by get_recent_backup + partial = "-partial" if self.partial else "" ext = "tgz" if self.compress_files else "tar" - for_public_files = self.todays_date + "-" + self.site_slug + "-files." + ext - for_private_files = self.todays_date + "-" + self.site_slug + "-private-files." + ext + for_conf = f"{self.todays_date}-{self.site_slug}-site_config_backup.json" + for_db = f"{self.todays_date}-{self.site_slug}{partial}-database.sql.gz" + for_public_files = f"{self.todays_date}-{self.site_slug}-files.{ext}" + for_private_files = f"{self.todays_date}-{self.site_slug}-private-files.{ext}" backup_path = self.backup_path or get_backup_path() if not self.backup_path_conf: From 3fed5c72553e2404123beb63ba10405cbf934536 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 10 Nov 2020 13:11:34 +0530 Subject: [PATCH 069/273] test: Add tests for bench partial-restore --- frappe/tests/test_commands.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index 283f9c01a7..f5cdd6b775 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -12,6 +12,7 @@ from glob import glob # imports - module imports import frappe +from frappe.utils import add_to_date, now from frappe.utils.backups import fetch_latest_backups import frappe.recorder @@ -243,6 +244,30 @@ class TestCommands(BaseTestCommands): database = fetch_latest_backups()["database"] self.assertTrue(exists_in_backup(backup["excludes"]["excludes"], database)) + def test_partial_restore(self): + _now = now() + for num in range(10): + frappe.get_doc({ + "doctype": "ToDo", + "date": add_to_date(_now, days=num), + "description": frappe.mock("paragraph") + }).insert() + todo_count = frappe.db.count("ToDo") + + # check if todos exist, create a partial backup and see if the state is the same after restore + self.assertIsNot(todo_count, 0) + self.execute("bench --site {site} backup --only 'ToDo'") + db_path = fetch_latest_backups(partial=True)["database"] + self.assertTrue("partial" in db_path) + + frappe.db.sql_ddl("DROP TABLE IF EXISTS `tabToDo`") + frappe.db.commit() + + self.execute("bench --site {site} partial-restore {path}", {"path": db_path}) + self.assertEquals(self.returncode, 0) + frappe.db.commit() + self.assertEquals(frappe.db.count("ToDo"), todo_count) + def test_recorder(self): frappe.recorder.stop() From 34af8cb32601993c069a862f1902aac53d848e69 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 12 Nov 2020 09:11:19 +0530 Subject: [PATCH 070/273] fix: Show partial backups when flag set * in fetch_latest_backups whitelisted API * BackupGenerator.get_recent_backup --- frappe/tests/test_commands.py | 8 ++++---- frappe/utils/backups.py | 9 ++++----- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index f5cdd6b775..1063307b76 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -215,27 +215,27 @@ class TestCommands(BaseTestCommands): self.execute("bench --site {site} set-config backup '{includes}' --as-dict", {"includes": json.dumps(backup["includes"])}) self.execute("bench --site {site} backup --verbose") self.assertEquals(self.returncode, 0) - database = fetch_latest_backups()["database"] + database = fetch_latest_backups(partial=True)["database"] self.assertTrue(exists_in_backup(backup["includes"]["includes"], database)) # test 8: take a backup with frappe.conf.backup.excludes self.execute("bench --site {site} set-config backup '{excludes}' --as-dict", {"excludes": json.dumps(backup["excludes"])}) self.execute("bench --site {site} backup --verbose") self.assertEquals(self.returncode, 0) - database = fetch_latest_backups()["database"] + database = fetch_latest_backups(partial=True)["database"] self.assertFalse(exists_in_backup(backup["excludes"]["excludes"], database)) self.assertTrue(exists_in_backup(backup["includes"]["includes"], database)) # test 9: take a backup with --include (with frappe.conf.excludes still set) self.execute("bench --site {site} backup --include '{include}'", {"include": ",".join(backup["includes"]["includes"])}) self.assertEquals(self.returncode, 0) - database = fetch_latest_backups()["database"] + database = fetch_latest_backups(partial=True)["database"] self.assertTrue(exists_in_backup(backup["includes"]["includes"], database)) # test 10: take a backup with --exclude self.execute("bench --site {site} backup --exclude '{exclude}'", {"exclude": ",".join(backup["excludes"]["excludes"])}) self.assertEquals(self.returncode, 0) - database = fetch_latest_backups()["database"] + database = fetch_latest_backups(partial=True)["database"] self.assertFalse(exists_in_backup(backup["excludes"]["excludes"], database)) # test 11: take a backup with --ignore-backup-conf diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index e2fe4c559a..178d213e59 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -202,7 +202,6 @@ class BackupGenerator: self.backup_path_conf = site_config_backup_path def set_backup_file_name(self): - # backups with the partial tag won't get returned by get_recent_backup partial = "-partial" if self.partial else "" ext = "tgz" if self.compress_files else "tar" @@ -221,11 +220,11 @@ class BackupGenerator: if not self.backup_path_private_files: self.backup_path_private_files = os.path.join(backup_path, for_private_files) - def get_recent_backup(self, older_than): + def get_recent_backup(self, older_than, partial=False): backup_path = get_backup_path() file_type_slugs = { - "database": "*-{}-database.sql.gz", + "database": "*-{{}}-{}database.sql.gz".format('*' if partial else ''), "public": "*-{}-files.tar", "private": "*-{}-private-files.tar", "config": "*-{}-site_config_backup.json", @@ -463,7 +462,7 @@ def get_backup(): @frappe.whitelist() -def fetch_latest_backups(): +def fetch_latest_backups(partial=False): """Fetches paths of the latest backup taken in the last 30 days Only for: System Managers @@ -479,7 +478,7 @@ def fetch_latest_backups(): db_type=frappe.conf.db_type, db_port=frappe.conf.db_port, ) - database, public, private, config = odb.get_recent_backup(older_than=24 * 30) + database, public, private, config = odb.get_recent_backup(older_than=24 * 30, partial=partial) return {"database": database, "public": public, "private": private, "config": config} From a22cd461ac85764477a39dc1e5ed613125b4e0bf Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 10 Nov 2020 13:34:48 +0530 Subject: [PATCH 071/273] test: Commit after insert, not before count check --- frappe/tests/test_commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index 1063307b76..d19a62d788 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -252,6 +252,7 @@ class TestCommands(BaseTestCommands): "date": add_to_date(_now, days=num), "description": frappe.mock("paragraph") }).insert() + frappe.db.commit() todo_count = frappe.db.count("ToDo") # check if todos exist, create a partial backup and see if the state is the same after restore @@ -265,7 +266,6 @@ class TestCommands(BaseTestCommands): self.execute("bench --site {site} partial-restore {path}", {"path": db_path}) self.assertEquals(self.returncode, 0) - frappe.db.commit() self.assertEquals(frappe.db.count("ToDo"), todo_count) def test_recorder(self): From 0e1807091087018338134b4aa1f10a3fd58e0589 Mon Sep 17 00:00:00 2001 From: Rohan Bansal Date: Wed, 11 Nov 2020 14:56:55 +0530 Subject: [PATCH 072/273] fix: error on trying to check semantic version --- frappe/utils/change_log.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/frappe/utils/change_log.py b/frappe/utils/change_log.py index 29fee2bac0..f7fac4cdf4 100644 --- a/frappe/utils/change_log.py +++ b/frappe/utils/change_log.py @@ -165,9 +165,10 @@ def check_for_update(): add_message_to_redis(updates) + def parse_latest_non_beta_release(response): """ - Pasrses the response JSON for all the releases and returns the latest non prerelease + Parses the response JSON for all the releases and returns the latest non prerelease Parameters response (list): response object returned by github @@ -182,32 +183,34 @@ def parse_latest_non_beta_release(response): return None + def check_release_on_github(app): - # Check if repo remote is on github from subprocess import CalledProcessError + try: + # Check if repo remote is on github remote_url = subprocess.check_output("cd ../apps/{} && git ls-remote --get-url".format(app), shell=True).decode() except CalledProcessError: # Passing this since some apps may not have git initializaed in them - return None + return if isinstance(remote_url, bytes): remote_url = remote_url.decode() if "github.com" not in remote_url: - return None + return # Get latest version from github if 'https' not in remote_url: - return None + return org_name = remote_url.split('/')[3] r = requests.get('https://api.github.com/repos/{}/{}/releases'.format(org_name, app)) if r.ok: - lastest_non_beta_release = parse_latest_non_beta_release(r.json()) - return Version(lastest_non_beta_release), org_name - # In case of an improper response or if there are no releases - return None + latest_non_beta_release = parse_latest_non_beta_release(r.json()) + if latest_non_beta_release: + return Version(latest_non_beta_release), org_name + def add_message_to_redis(update_json): # "update-message" will store the update message string From d29b0504fc8106511e3b77b8ae53629fb47eb9eb Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 11 Nov 2020 15:09:02 +0530 Subject: [PATCH 073/273] fix: Allow rename controllers in test via rename_doc --- frappe/core/doctype/doctype/doctype.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index da76da8db9..7063e64352 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -396,7 +396,7 @@ class DocType(Document): frappe.db.commit() # Do not rename and move files and folders for custom doctype - if not self.custom and not frappe.flags.in_test and not frappe.flags.in_patch: + if not self.custom and not frappe.flags.in_patch: self.rename_files_and_folders(old, new) def rename_files_and_folders(self, old, new): From 3581e591f3d83de5cab98042a602a37a86cee795 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 11 Nov 2020 19:40:41 +0530 Subject: [PATCH 074/273] fix: Strip html tags for frappe.msgprint output to stdout --- frappe/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/__init__.py b/frappe/__init__.py index fac0927428..63ac57909b 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -326,7 +326,7 @@ def msgprint(msg, title=None, raise_exception=0, as_table=False, as_list=False, :param is_minimizable: [optional] Allow users to minimize the modal :param wide: [optional] Show wide modal """ - from frappe.utils import encode + from frappe.utils import encode, strip_html_tags msg = safe_decode(msg) out = _dict(message=msg) @@ -353,7 +353,7 @@ def msgprint(msg, title=None, raise_exception=0, as_table=False, as_list=False, out.as_list = 1 if flags.print_messages and out.message: - print(f"Message: {repr(out.message).encode('utf-8')}") + print(f"Message: {strip_html_tags(out.message)}") if title: out.title = title From 2a615226d50b43b1dc2aa0c7acd28928da9aed36 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 11 Nov 2020 19:41:31 +0530 Subject: [PATCH 075/273] fix: Clear class_doctypes cache for doctype rename, deletes for all sites --- frappe/core/doctype/doctype/doctype.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 7063e64352..0144103f47 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -396,8 +396,17 @@ class DocType(Document): frappe.db.commit() # Do not rename and move files and folders for custom doctype - if not self.custom and not frappe.flags.in_patch: - self.rename_files_and_folders(old, new) + if not self.custom: + if not frappe.flags.in_patch: + self.rename_files_and_folders(old, new) + + for site in frappe.utils.get_sites(): + frappe.cache().delete(f"{site}:doctype_classes", old) + + def after_delete(self): + if not self.custom: + for site in frappe.utils.get_sites(): + frappe.cache().delete(f"{site}:doctype_classes", self.name) def rename_files_and_folders(self, old, new): # move files From 18c0270168e825a284388fbf073977e2f8fd032f Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 12 Nov 2020 09:11:19 +0530 Subject: [PATCH 076/273] fix: Delete controllers if delete_doc triggered via tests too --- frappe/model/delete_doc.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frappe/model/delete_doc.py b/frappe/model/delete_doc.py index a38470e3f5..0599368f33 100644 --- a/frappe/model/delete_doc.py +++ b/frappe/model/delete_doc.py @@ -76,7 +76,12 @@ def delete_doc(doctype=None, name=None, force=0, ignore_doctypes=None, for_reloa delete_from_table(doctype, name, ignore_doctypes, None) - if not (for_reload or frappe.flags.in_migrate or frappe.flags.in_install or frappe.flags.in_uninstall or frappe.flags.in_test): + if not doc.custom and not ( + for_reload + or frappe.flags.in_migrate + or frappe.flags.in_install + or frappe.flags.in_uninstall + ): try: delete_controllers(name, doc.module) except (FileNotFoundError, OSError, KeyError): From 71225783e6edab50e2236fee59d30a18bc3b26e9 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 12 Nov 2020 11:11:19 +0530 Subject: [PATCH 077/273] test: Rename doc tests --- frappe/tests/test_document.py | 78 ------------------------- frappe/tests/test_rename_doc.py | 100 ++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 78 deletions(-) create mode 100644 frappe/tests/test_rename_doc.py diff --git a/frappe/tests/test_document.py b/frappe/tests/test_document.py index 3c07f3e02a..2be92be1f5 100644 --- a/frappe/tests/test_document.py +++ b/frappe/tests/test_document.py @@ -249,84 +249,6 @@ class TestDocument(unittest.TestCase): self.assertEqual(cint(old_current) - 1, new_current) - def test_rename_doc(self): - from random import choice, sample - from frappe.model.base_document import get_controller - - available_documents = [] - doctype = "ToDo" - - # data generation: 4 todo documents - for num in range(1, 5): - doc = frappe.get_doc({ - "doctype": doctype, - "date": add_to_date(now(), days=num), - "description": "this is todo #{}".format(num) - }).insert() - available_documents.append(doc.name) - - # test 1: document renaming - old_name = choice(available_documents) - new_name = old_name + '.new' - self.assertEqual(new_name, frappe.rename_doc(doctype, old_name, new_name, force=True)) - available_documents.remove(old_name) - available_documents.append(new_name) - - # test 2: merge documents - first_todo, second_todo = sample(available_documents, 2) - - second_todo_doc = frappe.get_doc(doctype, second_todo) - second_todo_doc.priority = "High" - second_todo_doc.save() - - merged_todo = frappe.rename_doc(doctype, first_todo, second_todo, merge=True, force=True) - merged_todo_doc = frappe.get_doc(doctype, merged_todo) - available_documents.remove(first_todo) - - with self.assertRaises(DoesNotExistError): - frappe.get_doc(doctype, first_todo) - - self.assertEqual(merged_todo_doc.priority, second_todo_doc.priority) - - for docname in available_documents: - frappe.delete_doc(doctype, docname) - - # test 3: rename doctypes with controller code - doctype = frappe._dict({ - "old": "Test Rename Document Old", - "new": "Test Rename Document New", - }) - doc = frappe.get_doc({ - "doctype": "DocType", - "module": "Custom", - "name": doctype.old, - "custom": 0, - "fields": [ - { - "label": "Some Field", - "fieldname": "some_fieldname", - "fieldtype": "Data" - } - ], - "permissions": [ - {"role": "System Manager", "read": 1} - ], - }) - doc.insert() - # check if module exists exists; - # if custom, get_controller will return Document class - # if not custom, a different class will be returned - self.assertNotEqual(get_controller(doctype.old), frappe.model.document.Document) - - # rename doc via wrapper API accessible via /desk - frappe.rename_doc("DocType", doctype.old, doctype.new) - - # check if database and controllers are updated - self.assertTrue(frappe.db.exists("DocType", doctype.new)) - self.assertFalse(frappe.db.exists("DocType", doctype.old)) - with self.assertRaises(ImportError): - get_controller(doctype.old) - def test_non_negative_check(self): frappe.delete_doc_if_exists("Currency", "Frappe Coin", 1) diff --git a/frappe/tests/test_rename_doc.py b/frappe/tests/test_rename_doc.py new file mode 100644 index 0000000000..f69597b6de --- /dev/null +++ b/frappe/tests/test_rename_doc.py @@ -0,0 +1,100 @@ +import os +import unittest + +import frappe +from frappe.utils import add_to_date, now +from frappe.exceptions import DoesNotExistError + +from random import choice, sample +from frappe.model.base_document import get_controller +from frappe.modules.utils import get_doc_path + + +class TestRenameDoc(unittest.TestCase): + @classmethod + def setUpClass(self): + """Setting Up data for the tests defined under TestRenameDoc""" + # data generation: for base and merge tests + self.available_documents = [] + self.test_doctype = "ToDo" + + for num in range(1, 5): + doc = frappe.get_doc({ + "doctype": self.test_doctype, + "date": add_to_date(now(), days=num), + "description": "this is todo #{}".format(num), + }).insert() + self.available_documents.append(doc.name) + + # data generation: for controllers tests + self.doctype = frappe._dict({ + "old": "Test Rename Document Old", + "new": "Test Rename Document New", + }) + + frappe.get_doc({ + "doctype": "DocType", + "module": "Custom", + "name": self.doctype.old, + "custom": 0, + "fields": [ + {"label": "Some Field", "fieldname": "some_fieldname", "fieldtype": "Data"} + ], + "permissions": [{"role": "System Manager", "read": 1}], + }).insert() + + @classmethod + def tearDownClass(self): + """Deleting data generated for the tests defined under TestRenameDoc""" + # delete the documents created + for docname in self.available_documents: + frappe.delete_doc(self.test_doctype, docname) + + for dt in self.doctype.values(): + if frappe.db.exists("DocType", dt): + frappe.delete_doc("DocType", dt) + frappe.db.sql_ddl(f"DROP TABLE `tab{dt}`") + + def test_rename_doc(self): + """Rename an existing document via frappe.rename_doc""" + old_name = choice(self.available_documents) + new_name = old_name + ".new" + self.assertEqual(new_name, frappe.rename_doc(self.test_doctype, old_name, new_name, force=True)) + self.available_documents.remove(old_name) + self.available_documents.append(new_name) + + def test_merging_docs(self): + """Merge two documents via frappe.rename_doc""" + first_todo, second_todo = sample(self.available_documents, 2) + + second_todo_doc = frappe.get_doc(self.test_doctype, second_todo) + second_todo_doc.priority = "High" + second_todo_doc.save() + + merged_todo = frappe.rename_doc( + self.test_doctype, first_todo, second_todo, merge=True, force=True + ) + merged_todo_doc = frappe.get_doc(self.test_doctype, merged_todo) + self.available_documents.remove(first_todo) + + with self.assertRaises(DoesNotExistError): + frappe.get_doc(self.test_doctype, first_todo) + + self.assertEqual(merged_todo_doc.priority, second_todo_doc.priority) + + def test_rename_controllers(self): + """Rename doctypes with controller code paths""" + # check if module exists exists; + # if custom, get_controller will return Document class + # if not custom, a different class will be returned + self.assertNotEqual(get_controller(self.doctype.old), frappe.model.document.Document) + + old_doctype_path = get_doc_path("Custom", "DocType", self.doctype.old) + + # rename doc via wrapper API accessible via /desk + frappe.rename_doc("DocType", self.doctype.old, self.doctype.new) + + # check if database and controllers are updated + self.assertTrue(frappe.db.exists("DocType", self.doctype.new)) + self.assertFalse(frappe.db.exists("DocType", self.doctype.old)) + self.assertFalse(os.path.exists(old_doctype_path)) From f024b48f52c2ac6c8449e1ff587c1881db14d6f8 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 11 Nov 2020 20:11:30 +0530 Subject: [PATCH 078/273] fix: Remove unnecessary import Removed via 3581e591f3d83de5cab98042a602a37a86cee795 --- frappe/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/__init__.py b/frappe/__init__.py index 63ac57909b..e6eea7ed4a 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -326,7 +326,7 @@ def msgprint(msg, title=None, raise_exception=0, as_table=False, as_list=False, :param is_minimizable: [optional] Allow users to minimize the modal :param wide: [optional] Show wide modal """ - from frappe.utils import encode, strip_html_tags + from frappe.utils import strip_html_tags msg = safe_decode(msg) out = _dict(message=msg) From c5a420ffc79f0e1058ae1459523d5883d075d1d2 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 12 Nov 2020 10:43:36 +0530 Subject: [PATCH 079/273] fix: Allow doctype export with in_test flag set --- frappe/core/doctype/doctype/doctype.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 0144103f47..d421317a8a 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -291,7 +291,11 @@ class DocType(Document): allow_doctype_export = ( not self.custom and not frappe.flags.in_import - and (frappe.flags.allow_doctype_export or frappe.conf.developer_mode) + and ( + frappe.conf.developer_mode + or frappe.flags.allow_doctype_export + or frappe.flags.in_test + ) ) if allow_doctype_export: self.export_doc() From 6357bb63929b9b528fe6925fa184433f2db5a940 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 12 Nov 2020 11:51:42 +0530 Subject: [PATCH 080/273] test: Drop table if exists --- frappe/tests/test_rename_doc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/tests/test_rename_doc.py b/frappe/tests/test_rename_doc.py index f69597b6de..4db877e586 100644 --- a/frappe/tests/test_rename_doc.py +++ b/frappe/tests/test_rename_doc.py @@ -53,7 +53,7 @@ class TestRenameDoc(unittest.TestCase): for dt in self.doctype.values(): if frappe.db.exists("DocType", dt): frappe.delete_doc("DocType", dt) - frappe.db.sql_ddl(f"DROP TABLE `tab{dt}`") + frappe.db.sql_ddl(f"DROP TABLE IF EXISTS `tab{dt}`") def test_rename_doc(self): """Rename an existing document via frappe.rename_doc""" From b52cfd1903a050f0d3493f120d75f00c2d715168 Mon Sep 17 00:00:00 2001 From: Rohan Bansal Date: Thu, 12 Nov 2020 15:24:12 +0530 Subject: [PATCH 081/273] fix: add git URL check --- frappe/utils/change_log.py | 16 ++++++++++------ frappe/utils/data.py | 6 ++++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/frappe/utils/change_log.py b/frappe/utils/change_log.py index f7fac4cdf4..75421c43ea 100644 --- a/frappe/utils/change_log.py +++ b/frappe/utils/change_log.py @@ -1,15 +1,16 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # MIT License. See license.txt -from __future__ import unicode_literals -from six.moves import range -import json, os -from semantic_version import Version +import json +import os +import subprocess # nosec + import frappe import requests -import subprocess # nosec -from frappe.utils import cstr from frappe import _, safe_decode +from frappe.utils import cstr, is_git_url +from semantic_version import Version +from six.moves import range def get_change_log(user=None): @@ -204,6 +205,9 @@ def check_release_on_github(app): if 'https' not in remote_url: return + if is_git_url(remote_url): + return + org_name = remote_url.split('/')[3] r = requests.get('https://api.github.com/repos/{}/{}/releases'.format(org_name, app)) if r.ok: diff --git a/frappe/utils/data.py b/frappe/utils/data.py index 41f247da45..1ddf59a82c 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -1376,3 +1376,9 @@ def validate_json_string(string): json.loads(string) except (TypeError, ValueError): raise frappe.ValidationError + + +def is_git_url(url): + # modified to allow without the tailing .git from https://github.com/jonschlinkert/is-git-url.git + pattern = r"(?:git|ssh|https?|git@[-\w.]+):(\/\/)?(.*?)(\.git)?(\/?|\#[-\d\w._]+?)$" + return bool(re.match(pattern, url)) From a6c25fa9ad46884f01b8b982ceaa24ae6db0ff93 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 12 Nov 2020 16:21:51 +0530 Subject: [PATCH 082/273] ci: Run tests with set verbosity --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 63895675ea..2331217363 100644 --- a/.travis.yml +++ b/.travis.yml @@ -31,12 +31,12 @@ matrix: - name: "Python 3.7 MariaDB" python: 3.7 env: DB=mariadb TYPE=server - script: bench --site test_site run-tests --coverage + script: bench --verbose --site test_site run-tests --coverage - name: "Python 3.7 PostgreSQL" python: 3.7 env: DB=postgres TYPE=server - script: bench --site test_site run-tests --coverage + script: bench --verbose --site test_site run-tests --coverage - name: "Cypress" python: 3.7 From 0f13fe7da5e7158830ab77896a76116e71b02b64 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 13 Nov 2020 15:13:55 +0530 Subject: [PATCH 083/273] fix: Validate SQL files properly --- frappe/installer.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/frappe/installer.py b/frappe/installer.py index 6745a92345..3edea185cf 100755 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -415,23 +415,29 @@ def validate_database_sql(path, _raise=True): path (str): Path of the decompressed SQL file _raise (bool, optional): Raise exception if invalid file. Defaults to True. """ - to_raise = False + empty_file = False + missing_table = True + error_message = "" if not os.path.getsize(path): error_message = f"{path} is an empty file!" - to_raise = True + empty_file = True - if not _raise: + # dont bother checking if empty file + if not empty_file: with open(path, "r") as f: for line in f: if 'tabDefaultValue' in line: - error_message = "Table `tabDefaultValue` not found in file." - to_raise = True + missing_table = False + break + + if missing_table: + error_message = "Table `tabDefaultValue` not found in file." if error_message: import click click.secho(error_message, fg="red") - if _raise and to_raise: + if _raise and (missing_table or empty_file): raise frappe.InvalidDatabaseFile From 14fe6ccfa3e12afbe94ead781aa3179f79f20a2f Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 13 Nov 2020 19:42:38 +0530 Subject: [PATCH 084/273] fix: Add is_partial check in bench restore --- frappe/commands/site.py | 35 ++++++++++++++++++++++++++++++++--- frappe/installer.py | 8 ++++++++ frappe/utils/backups.py | 1 + 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/frappe/commands/site.py b/frappe/commands/site.py index c12afcb7ef..b55e7b7253 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -56,17 +56,41 @@ def new_site(site, mariadb_root_username=None, mariadb_root_password=None, admin @pass_context def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_password=None, db_name=None, verbose=None, install_app=None, admin_password=None, force=None, with_public_files=None, with_private_files=None): "Restore site database from an sql file" - from frappe.installer import extract_sql_from_archive, extract_files, is_downgrade, validate_database_sql + from frappe.installer import ( + extract_sql_from_archive, + extract_files, + is_downgrade, + is_partial, + validate_database_sql + ) + force = context.force or force decompressed_file_name = extract_sql_from_archive(sql_file_path) + # check if partial backup + if is_partial(decompressed_file_name): + click.secho( + "Partial Backup file detected. You cannot use a partial file to restore a Frappe Site.", + fg="red" + ) + click.secho( + "Use `bench partial-restore` to restore a partial backup to an existing site.", + fg="yellow" + ) + sys.exit(1) + + # check if valid SQL file validate_database_sql(decompressed_file_name, _raise=force) + site = get_site(context) frappe.init(site=site) # dont allow downgrading to older versions of frappe without force if not force and is_downgrade(decompressed_file_name, verbose=True): - warn_message = "This is not recommended and may lead to unexpected behaviour. Do you want to continue anyway?" + warn_message = ( + "This is not recommended and may lead to unexpected behaviour. " + "Do you want to continue anyway?" + ) click.confirm(warn_message, abort=True) _new_site(frappe.conf.db_name, site, mariadb_root_username=mariadb_root_username, @@ -89,9 +113,13 @@ def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_pas if decompressed_file_name != sql_file_path: os.remove(decompressed_file_name) - success_message = "Site {0} has been restored{1}".format(site, " with files" if (with_public_files or with_private_files) else "") + success_message = "Site {0} has been restored{1}".format( + site, + " with files" if (with_public_files or with_private_files) else "" + ) click.secho(success_message, fg="green") + @click.command('partial-restore') @click.argument('sql-file-path') @click.option("--verbose", "-v", is_flag=True) @@ -106,6 +134,7 @@ def partial_restore(context, sql_file_path, verbose): partial_restore(sql_file_path, verbose) frappe.destroy() + @click.command('reinstall') @click.option('--admin-password', help='Administrator Password for reinstalled site') @click.option('--mariadb-root-username', help='Root username for MariaDB') diff --git a/frappe/installer.py b/frappe/installer.py index 2285d0011a..32a61f35fb 100755 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -528,6 +528,14 @@ def is_downgrade(sql_file_path, verbose=False): return downgrade +def is_partial(sql_file_path): + with open(sql_file_path) as f: + header = " ".join([f.readline() for _ in range(5)]) + if "Partial Backup" in header: + return True + return False + + def partial_restore(sql_file_path, verbose=False): sql_file = extract_sql_from_archive(sql_file_path) diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index 178d213e59..fe045be81f 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -6,6 +6,7 @@ import os from calendar import timegm from datetime import datetime from glob import glob +import gzip # imports - third party imports import click From 2fd5a82bbdd970089e209229eaa93304493c8ab5 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 13 Nov 2020 19:43:07 +0530 Subject: [PATCH 085/273] fix: Add header content in database backups --- frappe/utils/backups.py | 41 ++++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index fe045be81f..b11c73c2b2 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -342,17 +342,36 @@ class BackupGenerator: def take_dump(self): import frappe.utils + from frappe.utils.change_log import get_app_branch + + database_header_content = [ + f"Backup generated by Frappe {frappe.__version__} on branch {get_app_branch('frappe') or 'N/A'}", + "", + ] # escape reserved characters - args = dict( + args = frappe._dict( [item[0], frappe.utils.esc(str(item[1]), "$ ")] for item in self.__dict__.copy().items() ) if self.backup_includes: - print("Backing Up Tables: {0}\n".format(", ".join(self.backup_includes))) + backup_info = ("Backing Up Tables: ", ", ".join(self.backup_includes)) elif self.backup_excludes: - print("Skipping Tables: {0}\n".format(", ".join(self.backup_excludes))) + backup_info = ("Skipping Tables: ", ", ".join(self.backup_excludes)) + + if self.partial: + print(''.join(backup_info), "\n") + database_header_content.extend([ + f"Partial Backup of Frappe Site {frappe.local.site}", + ("Backup contains: " if self.backup_includes else "Backup excludes: ") + backup_info[1], + "", + ]) + + generated_header = "\n".join([f"-- {x}" for x in database_header_content]) + "\n" + + with gzip.open(args.backup_path_db, "wt") as f: + f.write(generated_header) if self.db_type == "postgres": if self.backup_includes: @@ -366,7 +385,7 @@ class BackupGenerator: cmd_string = ( "pg_dump postgres://{user}:{password}@{db_host}:{db_port}/{db_name}" - " {include} {exclude} | gzip > {backup_path_db}" + " {include} {exclude} | gzip >> {backup_path_db}" ) else: @@ -383,16 +402,16 @@ class BackupGenerator: cmd_string = ( "mysqldump --single-transaction --quick --lock-tables=false -u {user}" " -p{password} {db_name} -h {db_host} -P {db_port} {include} {exclude}" - " | gzip > {backup_path_db}" + " | gzip >> {backup_path_db}" ) command = cmd_string.format( - user=args.get("user"), - password=args.get("password"), - db_host=args.get("db_host"), - db_port=args.get("db_port"), - db_name=args.get("db_name"), - backup_path_db=args.get("backup_path_db"), + user=args.user, + password=args.password, + db_host=args.db_host, + db_port=args.db_port, + db_name=args.db_name, + backup_path_db=args.backup_path_db, exclude=args.get("exclude", ""), include=args.get("include", ""), ) From 22b752ac21c69cf2f50df9bfae472c1dacf0128a Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Sat, 14 Nov 2020 11:11:19 +0530 Subject: [PATCH 086/273] test: Add tests for bench restore --- frappe/tests/test_commands.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index 3f19abdae4..422dbf2930 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -201,9 +201,7 @@ class TestCommands(BaseTestCommands): # test 5: take a backup with --compress self.execute("bench --site {site} backup --with-files --compress") - self.assertEquals(self.returncode, 0) - compressed_files = glob(site_backup_path + "/*.tgz") self.assertGreater(len(compressed_files), 0) @@ -244,6 +242,18 @@ class TestCommands(BaseTestCommands): database = fetch_latest_backups()["database"] self.assertTrue(exists_in_backup(backup["excludes"]["excludes"], database)) + def test_restore(self): + # test 1: bench restore from full backup + self.execute("bench --site {site} backup --ignore-backup-conf") + database = fetch_latest_backups()["database"] + self.execute("bench --site {site} restore {database}", {"database": database}) + + # test 2: restore from partial backup + self.execute("bench --site {site} backup --exclude 'ToDo") + database = fetch_latest_backups(partial=True)["database"] + self.execute("bench --site {site} restore {database}", {"database": database}) + self.assertEquals(self.returncode, 1) + def test_partial_restore(self): _now = now() for num in range(10): From e8c6a7afc2ad7d70bdf5daf3bb3cae382f088410 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Sat, 14 Nov 2020 14:48:39 +0530 Subject: [PATCH 087/273] fix: Manage private images via get_local_image --- frappe/core/doctype/file/file.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index b8bed89a4d..2a4e1983c9 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -612,7 +612,12 @@ def get_extension(filename, extn, content): return extn def get_local_image(file_url): - file_path = frappe.get_site_path("public", file_url.lstrip("/")) + if file_url.startswith("/private"): + file_url_path = (file_url.lstrip("/"), ) + else: + file_url_path = ("public", file_url.lstrip("/")) + + file_path = frappe.get_site_path(*file_url_path) try: image = Image.open(file_path) From 2bc477331d2eab4a90a62982c53bff746cb50358 Mon Sep 17 00:00:00 2001 From: Mohammad Hasnain Date: Mon, 16 Nov 2020 13:59:56 +0530 Subject: [PATCH 088/273] refactor: cleanup --- .../public/js/frappe/form/controls/comment.js | 2 +- .../public/js/frappe/form/footer/timeline.js | 52 ++++++++++++------- 2 files changed, 34 insertions(+), 20 deletions(-) diff --git a/frappe/public/js/frappe/form/controls/comment.js b/frappe/public/js/frappe/form/controls/comment.js index a64df56bca..d00c915065 100644 --- a/frappe/public/js/frappe/form/controls/comment.js +++ b/frappe/public/js/frappe/form/controls/comment.js @@ -60,7 +60,7 @@ frappe.ui.form.ControlComment = frappe.ui.form.ControlTextEditor.extend({ update_state() { const value = this.get_value(); - if (strip_html(value).trim() != "") { + if (strip_html(value).trim() != "" || value.includes('img')) { this.button.removeClass('btn-default').addClass('btn-primary'); } else { this.button.addClass('btn-default').removeClass('btn-primary'); diff --git a/frappe/public/js/frappe/form/footer/timeline.js b/frappe/public/js/frappe/form/footer/timeline.js index 84f34d4757..2e390dc3cb 100644 --- a/frappe/public/js/frappe/form/footer/timeline.js +++ b/frappe/public/js/frappe/form/footer/timeline.js @@ -30,7 +30,7 @@ frappe.ui.form.Timeline = class Timeline { render_input: true, only_input: true, on_submit: (val) => { - if(strip_html(val).trim() != "") { + if(strip_html(val).trim() != "" || val.includes('img')) { this.insert_comment(val, this.comment_area.button); } } @@ -547,10 +547,14 @@ frappe.ui.form.Timeline = class Timeline { log.color = 'dark'; log.sender = log.owner; log.comment_type = 'Milestone'; - log.content = __('{0} changed {1} to {2}', [ - frappe.user.full_name(log.owner).bold(), - frappe.meta.get_label(this.frm.doctype, log.track_field), - log.value.bold()]); + log.content = __( + '{0} changed {1} to {2}', + [ + frappe.user.full_name(log.owner).bold(), + frappe.meta.get_label(this.frm.doctype, log.track_field), + log.value.bold() + ] + ); return log; }); return milestones; @@ -613,11 +617,14 @@ frappe.ui.form.Timeline = class Timeline { const field_display_status = frappe.perm.get_field_display_status(df, null, me.frm.perm); if (field_display_status === 'Read' || field_display_status === 'Write') { - parts.push(__('{0} from {1} to {2}', [ - __(df.label), - me.format_content_for_timeline(p[1]), - me.format_content_for_timeline(p[2]) - ])); + parts.push(__( + '{0} from {1} to {2}', + [ + __(df.label), + me.format_content_for_timeline(p[1]), + me.format_content_for_timeline(p[2]) + ] + )); } } } @@ -648,13 +655,18 @@ frappe.ui.form.Timeline = class Timeline { null, me.frm.perm); if (field_display_status === 'Read' || field_display_status === 'Write') { - parts.push(__('{0} from {1} to {2} in row #{3}', [ - frappe.meta.get_label(me.frm.fields_dict[row[0]].grid.doctype, - p[0]), - me.format_content_for_timeline(p[1]), - me.format_content_for_timeline(p[2]), - row[1] - ])); + parts.push(__( + '{0} from {1} to {2} in row #{3}', + [ + frappe.meta.get_label( + me.frm.fields_dict[row[0]].grid.doctype, + p[0] + ), + me.format_content_for_timeline(p[1]), + me.format_content_for_timeline(p[2]), + row[1] + ] + )); } } return parts.length < 3; @@ -691,8 +703,10 @@ frappe.ui.form.Timeline = class Timeline { return p; }); if (parts.length) { - out.push(me.get_version_comment(version, __("{0} rows for {1}", - [__(key), parts.join(', ')]))); + out.push(me.get_version_comment(version, __( + "{0} rows for {1}", + [__(key), parts.join(', ')] + ))); } } }); From 4443dab3cbc4510574e306ba84d114e13ac8e6dc Mon Sep 17 00:00:00 2001 From: Mohammad Hasnain Mohsin Rajan Date: Mon, 16 Nov 2020 14:03:00 +0530 Subject: [PATCH 089/273] refactor: solve sider issues --- frappe/public/js/frappe/form/footer/timeline.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/form/footer/timeline.js b/frappe/public/js/frappe/form/footer/timeline.js index 2e390dc3cb..c23b6d8127 100644 --- a/frappe/public/js/frappe/form/footer/timeline.js +++ b/frappe/public/js/frappe/form/footer/timeline.js @@ -30,7 +30,7 @@ frappe.ui.form.Timeline = class Timeline { render_input: true, only_input: true, on_submit: (val) => { - if(strip_html(val).trim() != "" || val.includes('img')) { + if (strip_html(val).trim() != "" || val.includes('img')) { this.insert_comment(val, this.comment_area.button); } } From c4e57926f9899a3c0d1a0922aea651f66a3032a9 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 17 Nov 2020 12:33:21 +0530 Subject: [PATCH 090/273] feat(postgres): Show restore progress if pv is available --- frappe/database/postgres/setup_db.py | 50 +++++++++++++++++----------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/frappe/database/postgres/setup_db.py b/frappe/database/postgres/setup_db.py index 109ed0469e..bf344c3aa5 100644 --- a/frappe/database/postgres/setup_db.py +++ b/frappe/database/postgres/setup_db.py @@ -1,5 +1,7 @@ -import frappe, subprocess, os -from six.moves import input +import os + +import frappe + def setup_database(force, source_sql=None, verbose=False): root_conn = get_root_connection() @@ -19,6 +21,7 @@ def bootstrap_database(db_name, verbose, source_sql=None): frappe.connect(db_name=db_name) import_db_from_sql(source_sql, verbose) frappe.connect(db_name=db_name) + if 'tabDefaultValue' not in frappe.db.get_tables(): import sys from click import secho @@ -32,32 +35,40 @@ def bootstrap_database(db_name, verbose, source_sql=None): sys.exit(1) def import_db_from_sql(source_sql=None, verbose=False): - if verbose: - print("Starting Database Import...") + from shlex import split + from shutil import which + from subprocess import run, PIPE # we can't pass psql password in arguments in postgresql as mysql. So # set password connection parameter in environment variable subprocess_env = os.environ.copy() subprocess_env['PGPASSWORD'] = str(frappe.conf.db_password) + # bootstrap db if not source_sql: source_sql = os.path.join(os.path.dirname(__file__), 'framework_postgres.sql') - command = [ - 'psql', frappe.conf.db_name, - '-h', frappe.conf.db_host or 'localhost', - '-p', str(frappe.conf.db_port or '5432'), - '-U', frappe.conf.db_name, - '-f', source_sql - ] + pv = which('pv') + + _command = ( + f"psql {frappe.conf.db_name} " + f"-h {frappe.conf.db_host or 'localhost'} -p {str(frappe.conf.db_port or '5432')} " + f"-U {frappe.conf.db_name}" + ) + + if pv: + command = f"{pv} {source_sql} | " + _command + else: + command = _command + f"-f {source_sql}" + + print("Restoring Database file...") + if verbose: + print(command) + + restore_proc = run(command, env=subprocess_env, shell=True, stdout=PIPE) if verbose: - print(" ".join(command)) - - subprocess.check_output(command, env=subprocess_env) - - if verbose: - print(f"Imported from Database File: {source_sql}") + print(f"\nSTDOUT by psql:\n{restore_proc.stdout.decode()}\nImported from Database File: {source_sql}") def setup_help_database(help_db_name): root_conn = get_root_connection() @@ -68,19 +79,20 @@ def setup_help_database(help_db_name): root_conn.sql("GRANT ALL PRIVILEGES ON DATABASE `{0}` TO {0}".format(help_db_name)) def get_root_connection(root_login=None, root_password=None): - import getpass if not frappe.local.flags.root_connection: if not root_login: root_login = frappe.conf.get("root_login") or None if not root_login: + from six.moves import input root_login = input("Enter postgres super user: ") if not root_password: root_password = frappe.conf.get("root_password") or None if not root_password: - root_password = getpass.getpass("Postgres super user password: ") + from getpass import getpass + root_password = getpass("Postgres super user password: ") frappe.local.flags.root_connection = frappe.database.get_db(user=root_login, password=root_password) From 84e2fcd6a8b7bdc0ec6f6eaabe1b4b6d7582cf3c Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 17 Nov 2020 12:33:59 +0530 Subject: [PATCH 091/273] fix: Add missing quote --- frappe/tests/test_commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index 422dbf2930..3c5fddeef0 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -249,7 +249,7 @@ class TestCommands(BaseTestCommands): self.execute("bench --site {site} restore {database}", {"database": database}) # test 2: restore from partial backup - self.execute("bench --site {site} backup --exclude 'ToDo") + self.execute("bench --site {site} backup --exclude 'ToDo'") database = fetch_latest_backups(partial=True)["database"] self.execute("bench --site {site} restore {database}", {"database": database}) self.assertEquals(self.returncode, 1) From 3b5ec6aa88fa94a83aa00358fb2f85f7e72e0ab5 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 17 Nov 2020 12:45:55 +0530 Subject: [PATCH 092/273] fix: Sider + improper command ending --- frappe/database/postgres/setup_db.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frappe/database/postgres/setup_db.py b/frappe/database/postgres/setup_db.py index bf344c3aa5..3ee6b6a286 100644 --- a/frappe/database/postgres/setup_db.py +++ b/frappe/database/postgres/setup_db.py @@ -35,7 +35,6 @@ def bootstrap_database(db_name, verbose, source_sql=None): sys.exit(1) def import_db_from_sql(source_sql=None, verbose=False): - from shlex import split from shutil import which from subprocess import run, PIPE @@ -59,7 +58,7 @@ def import_db_from_sql(source_sql=None, verbose=False): if pv: command = f"{pv} {source_sql} | " + _command else: - command = _command + f"-f {source_sql}" + command = _command + f" -f {source_sql}" print("Restoring Database file...") if verbose: From 7d091c19575a28f28803f2062a840d9c88d81432 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Mon, 19 Oct 2020 12:04:03 +0530 Subject: [PATCH 093/273] fix: Move attachment limit validation to file uploader --- frappe/public/js/frappe/file_uploader/index.js | 6 ++++++ frappe/public/js/frappe/form/form.js | 6 +----- frappe/public/js/frappe/form/sidebar/attachments.js | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/frappe/public/js/frappe/file_uploader/index.js b/frappe/public/js/frappe/file_uploader/index.js index 62a7bff822..5336cd7c52 100644 --- a/frappe/public/js/frappe/file_uploader/index.js +++ b/frappe/public/js/frappe/file_uploader/index.js @@ -15,7 +15,13 @@ export default class FileUploader { allow_multiple, as_dataurl, disable_file_browser, + frm } = {}) { + if (frm && frm.attachments.max_reached()) { + frappe.throw(__("Maximum Attachment Limit for this record reached.")); + return; + } + if (!wrapper) { this.make_dialog(); } else { diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index bb9e8c22d1..90b628f269 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -232,14 +232,10 @@ frappe.ui.form.Form = class FrappeForm { throw "attach error"; } - if(me.attachments.max_reached()) { - frappe.msgprint(__("Maximum Attachment Limit for this record reached.")); - throw "attach error"; - } - new frappe.ui.FileUploader({ doctype: me.doctype, docname: me.docname, + frm: me, files: dataTransfer.files, folder: 'Home/Attachments', on_success(file_doc) { diff --git a/frappe/public/js/frappe/form/sidebar/attachments.js b/frappe/public/js/frappe/form/sidebar/attachments.js index 165527e281..dc0c839737 100644 --- a/frappe/public/js/frappe/form/sidebar/attachments.js +++ b/frappe/public/js/frappe/form/sidebar/attachments.js @@ -140,7 +140,6 @@ frappe.ui.form.Attachments = Class.extend({ }); }, new_attachment: function(fieldname) { - var me = this; if (this.dialog) { // remove upload dialog this.dialog.$wrapper.remove(); @@ -149,6 +148,7 @@ frappe.ui.form.Attachments = Class.extend({ new frappe.ui.FileUploader({ doctype: this.frm.doctype, docname: this.frm.docname, + frm: this.frm, folder: 'Home/Attachments', on_success: (file_doc) => { this.attachment_uploaded(file_doc); From b33461bb53f05233017d27f19d83846c59e235e4 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Mon, 19 Oct 2020 12:04:57 +0530 Subject: [PATCH 094/273] feat: Add server-side validation for attachment limit --- frappe/core/doctype/file/file.py | 21 +++++++++++++++++++++ frappe/exceptions.py | 1 + 2 files changed, 22 insertions(+) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index b8bed89a4d..9b0b3f3fd1 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -93,6 +93,7 @@ class File(Document): self.set_is_private() self.set_file_name() self.validate_duplicate_entry() + self.validate_attachment_limit() self.validate_folder() if not self.file_url and not self.flags.ignore_file_validate: @@ -140,6 +141,26 @@ class File(Document): if self.file_url and (self.is_private != self.file_url.startswith('/private')): frappe.throw(_('Invalid file URL. Please contact System Administrator.')) + def validate_attachment_limit(self): + attachment_limit = 0 + if self.attached_to_doctype and self.attached_to_name: + attachment_limit = frappe.get_meta(self.attached_to_doctype).max_attachments + + if attachment_limit: + current_attachment_count = len(frappe.get_all('File', filters={ + 'attached_to_doctype': self.attached_to_doctype, + 'attached_to_name': self.attached_to_name, + }, limit=(attachment_limit + 1))) + + if current_attachment_count >= attachment_limit: + frappe.throw(_("Attachment Limit reached for {0} {1}.").format( + self.attached_to_doctype, + self.attached_to_name + ), + exc=frappe.exceptions.AttachmentLimitReached, + title=_('Maximum Attachment Limit Reached') + ) + def set_folder_name(self): """Make parent folders if not exists based on reference doctype and name""" if self.attached_to_doctype and not self.folder: diff --git a/frappe/exceptions.py b/frappe/exceptions.py index 267f5410af..81fccf5880 100644 --- a/frappe/exceptions.py +++ b/frappe/exceptions.py @@ -106,6 +106,7 @@ class InvalidDates(ValidationError): pass class DataTooLongException(ValidationError): pass class FileAlreadyAttachedException(Exception): pass class DocumentAlreadyRestored(Exception): pass +class AttachmentLimitReached(Exception): pass # OAuth exceptions class InvalidAuthorizationHeader(CSRFTokenError): pass class InvalidAuthorizationPrefix(CSRFTokenError): pass From 23efc9919ab24c58d60256a02fb067e5e11ff9d0 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Mon, 19 Oct 2020 12:37:02 +0530 Subject: [PATCH 095/273] fix: Update validation message --- frappe/core/doctype/file/file.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index 9b0b3f3fd1..bb7765e055 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -153,12 +153,12 @@ class File(Document): }, limit=(attachment_limit + 1))) if current_attachment_count >= attachment_limit: - frappe.throw(_("Attachment Limit reached for {0} {1}.").format( + frappe.throw(_("Maximum Attachment Limit reached for {0} {1}.").format( self.attached_to_doctype, self.attached_to_name ), exc=frappe.exceptions.AttachmentLimitReached, - title=_('Maximum Attachment Limit Reached') + title=_('Attachment Limit Reached') ) def set_folder_name(self): From a2471ace211831c644454f13df8a4b384b251af9 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Mon, 19 Oct 2020 12:43:14 +0530 Subject: [PATCH 096/273] fix: Update attachment limit --- frappe/public/js/frappe/file_uploader/index.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/file_uploader/index.js b/frappe/public/js/frappe/file_uploader/index.js index 5336cd7c52..36de853b8f 100644 --- a/frappe/public/js/frappe/file_uploader/index.js +++ b/frappe/public/js/frappe/file_uploader/index.js @@ -18,7 +18,10 @@ export default class FileUploader { frm } = {}) { if (frm && frm.attachments.max_reached()) { - frappe.throw(__("Maximum Attachment Limit for this record reached.")); + frappe.throw({ + title: __("Attachment Limit Reached"), + message: __("Maximum attachment limit for this record reached."), + }); return; } From 42fc8fb25c8ec59d99468d99a316518aff62f158 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Mon, 19 Oct 2020 13:34:04 +0530 Subject: [PATCH 097/273] fix: Convert limit to integer --- frappe/core/doctype/file/file.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index bb7765e055..a8a999ff9a 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -144,13 +144,13 @@ class File(Document): def validate_attachment_limit(self): attachment_limit = 0 if self.attached_to_doctype and self.attached_to_name: - attachment_limit = frappe.get_meta(self.attached_to_doctype).max_attachments + attachment_limit = cint(frappe.get_meta(self.attached_to_doctype).max_attachments) if attachment_limit: current_attachment_count = len(frappe.get_all('File', filters={ 'attached_to_doctype': self.attached_to_doctype, 'attached_to_name': self.attached_to_name, - }, limit=(attachment_limit + 1))) + }, limit=attachment_limit + 1)) if current_attachment_count >= attachment_limit: frappe.throw(_("Maximum Attachment Limit reached for {0} {1}.").format( From e64483ad89b7d6af385256aaccb9a8acbcff3b0a Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Mon, 19 Oct 2020 13:35:16 +0530 Subject: [PATCH 098/273] test: Add test for attachment limit validation --- frappe/core/doctype/file/test_file.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index 85397ea1ee..cb44d33781 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -160,6 +160,29 @@ class TestSameContent(unittest.TestCase): def test_saved_content(self): self.assertFalse(os.path.exists(get_files_path(self.dup_filename))) + def test_attachment_limit(self): + doctype, docname = make_test_doc() + from frappe.custom.doctype.property_setter.property_setter import make_property_setter + limit_property = make_property_setter('ToDo', None, 'max_attachments', 1, 'int', for_doctype=True) + _file1 = frappe.get_doc({ + "doctype": "File", + "file_name": 'test-attachment', + "attached_to_doctype": doctype, + "attached_to_name": docname, + "content": 'test'}) + + _file1.insert() + + _file2 = frappe.get_doc({ + "doctype": "File", + "file_name": 'test-attachment', + "attached_to_doctype": doctype, + "attached_to_name": docname, + "content": 'test2'}) + + self.assertRaises(frappe.exceptions.AttachmentLimitReached, _file2.insert) + limit_property.delete() + frappe.clear_cache(doctype='ToDo') def tearDown(self): # File gets deleted on rollback, so blank From a7264405b5a09ac5f7d4121e914a570a5a6de2b6 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Tue, 20 Oct 2020 10:27:16 +0530 Subject: [PATCH 099/273] style: Fix formatting --- frappe/core/doctype/file/file.py | 6 +++--- frappe/core/doctype/file/test_file.py | 14 ++++++++------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index a8a999ff9a..74c6c3f22e 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -153,9 +153,9 @@ class File(Document): }, limit=attachment_limit + 1)) if current_attachment_count >= attachment_limit: - frappe.throw(_("Maximum Attachment Limit reached for {0} {1}.").format( - self.attached_to_doctype, - self.attached_to_name + frappe.throw( + _("Maximum Attachment Limit reached for {0} {1}.").format( + self.attached_to_doctype, self.attached_to_name ), exc=frappe.exceptions.AttachmentLimitReached, title=_('Attachment Limit Reached') diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index cb44d33781..e627558680 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -164,23 +164,25 @@ class TestSameContent(unittest.TestCase): doctype, docname = make_test_doc() from frappe.custom.doctype.property_setter.property_setter import make_property_setter limit_property = make_property_setter('ToDo', None, 'max_attachments', 1, 'int', for_doctype=True) - _file1 = frappe.get_doc({ + file1 = frappe.get_doc({ "doctype": "File", "file_name": 'test-attachment', "attached_to_doctype": doctype, "attached_to_name": docname, - "content": 'test'}) + "content": 'test' + }) - _file1.insert() + file1.insert() - _file2 = frappe.get_doc({ + file2 = frappe.get_doc({ "doctype": "File", "file_name": 'test-attachment', "attached_to_doctype": doctype, "attached_to_name": docname, - "content": 'test2'}) + "content": 'test2' + }) - self.assertRaises(frappe.exceptions.AttachmentLimitReached, _file2.insert) + self.assertRaises(frappe.exceptions.AttachmentLimitReached, file2.insert) limit_property.delete() frappe.clear_cache(doctype='ToDo') From 1685afc49eed63a370ec98880e6c34266ad1e5db Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Mon, 26 Oct 2020 13:53:01 +0530 Subject: [PATCH 100/273] fix: Update limit validation message --- frappe/core/doctype/file/file.py | 4 ++-- .../public/js/frappe/file_uploader/index.js | 9 ++------- .../js/frappe/form/sidebar/attachments.js | 20 +++++++++++-------- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index 74c6c3f22e..48247860b3 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -154,8 +154,8 @@ class File(Document): if current_attachment_count >= attachment_limit: frappe.throw( - _("Maximum Attachment Limit reached for {0} {1}.").format( - self.attached_to_doctype, self.attached_to_name + _("Maximum Attachment Limit of {0} has been reached for {1} {2}.").format( + frappe.bold(attachment_limit), self.attached_to_doctype, self.attached_to_name ), exc=frappe.exceptions.AttachmentLimitReached, title=_('Attachment Limit Reached') diff --git a/frappe/public/js/frappe/file_uploader/index.js b/frappe/public/js/frappe/file_uploader/index.js index 36de853b8f..646f60715a 100644 --- a/frappe/public/js/frappe/file_uploader/index.js +++ b/frappe/public/js/frappe/file_uploader/index.js @@ -17,13 +17,8 @@ export default class FileUploader { disable_file_browser, frm } = {}) { - if (frm && frm.attachments.max_reached()) { - frappe.throw({ - title: __("Attachment Limit Reached"), - message: __("Maximum attachment limit for this record reached."), - }); - return; - } + + frm && frm.attachments.max_reached(true); if (!wrapper) { this.make_dialog(); diff --git a/frappe/public/js/frappe/form/sidebar/attachments.js b/frappe/public/js/frappe/form/sidebar/attachments.js index dc0c839737..56b484e7c4 100644 --- a/frappe/public/js/frappe/form/sidebar/attachments.js +++ b/frappe/public/js/frappe/form/sidebar/attachments.js @@ -16,15 +16,19 @@ frappe.ui.form.Attachments = Class.extend({ this.add_attachment_wrapper = this.parent.find(".add_attachment").parent(); this.attachments_label = this.parent.find(".attachments-label"); }, - max_reached: function() { - // no of attachments - var n = Object.keys(this.get_attachments()).length; - - // button if the number of attachments is less than max - if(n < this.frm.meta.max_attachments || !this.frm.meta.max_attachments) { - return false; + max_reached: function(raise_exception=false) { + const attachment_count = Object.keys(this.get_attachments()).length; + const attachment_limit = this.frm.meta.max_attachments; + if (attachment_limit && attachment_count >= attachment_limit) { + if (raise_exception) { + frappe.throw({ + title: __("Attachment Limit Reached"), + message: __("Maximum attachment limit of {0} has been reached.", [cstr(attachment_limit).bold()]), + }); + } + return true; } - return true; + return false; }, refresh: function() { var me = this; From 291179904615480bcaaa623e9b2bc055188b8ed9 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 17 Nov 2020 15:14:23 +0530 Subject: [PATCH 101/273] fix: Re-write restore tests to process another site --- frappe/tests/test_commands.py | 36 +++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index 3c5fddeef0..e282f1027d 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -243,15 +243,39 @@ class TestCommands(BaseTestCommands): self.assertTrue(exists_in_backup(backup["excludes"]["excludes"], database)) def test_restore(self): + # step 0: create a site to run the test on + global_config = { + "admin_password": frappe.conf.admin_password, + "root_login": frappe.conf.root_login, + "root_password": frappe.conf.root_password, + "db_type": frappe.conf.db_type + } + site_data = { + "another_site": f"{frappe.local.site}-restore.test", + **global_config + } + for key, value in global_config.items(): + if value: + self.execute(f"bench set-config {key} {value} -g") + self.execute("bench new-site {another_site} --admin-password {admin_password} --db-type {db_type} --force", site_data) + # test 1: bench restore from full backup - self.execute("bench --site {site} backup --ignore-backup-conf") - database = fetch_latest_backups()["database"] - self.execute("bench --site {site} restore {database}", {"database": database}) + self.execute("bench --site {another_site} backup --ignore-backup-conf", site_data) + self.execute("bench --site {another_site} execute frappe.utils.backups.fetch_latest_backups", site_data) + site_data.update({"database": json.loads(self.stdout)["database"]}) + self.execute("bench --site {another_site} restore {database}", site_data) # test 2: restore from partial backup - self.execute("bench --site {site} backup --exclude 'ToDo'") - database = fetch_latest_backups(partial=True)["database"] - self.execute("bench --site {site} restore {database}", {"database": database}) + self.execute("bench --site {another_site} backup --exclude 'ToDo'", site_data) + site_data.update({"kw": "\"{'partial':True}\""}) + self.execute( + "bench --site {another_site} execute frappe.utils.backups.fetch_latest_backups --kwargs {kw}", + site_data + ) + site_data.update({ + "database": json.loads(self.stdout)["database"] + }) + self.execute("bench --site {another_site} restore {database}", site_data) self.assertEquals(self.returncode, 1) def test_partial_restore(self): From f1cd3388ba0b71a52cf87bd7e1af16f4e834b35f Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 17 Nov 2020 16:28:15 +0530 Subject: [PATCH 102/273] style: Black-ish + fixed typos + Optimized imports --- frappe/build.py | 2 +- .../frappe_providers/frappecloud.py | 2 +- frappe/tests/test_commands.py | 83 ++++++++++++------- 3 files changed, 53 insertions(+), 34 deletions(-) diff --git a/frappe/build.py b/frappe/build.py index f14b250a92..f47a7cb32b 100644 --- a/frappe/build.py +++ b/frappe/build.py @@ -105,7 +105,7 @@ def download_frappe_assets(verbose=True): if frappe_head: try: url = get_assets_link(frappe_head) - click.secho("Retreiving assets...", fg="yellow") + click.secho("Retrieving assets...", fg="yellow") prefix = mkdtemp(prefix="frappe-assets-", suffix=frappe_head) assets_archive = download_file(url, prefix) print("\n{0} Downloaded Frappe assets from {1}".format(green('✔'), url)) diff --git a/frappe/integrations/frappe_providers/frappecloud.py b/frappe/integrations/frappe_providers/frappecloud.py index e09f09a44b..f60344ee8f 100644 --- a/frappe/integrations/frappe_providers/frappecloud.py +++ b/frappe/integrations/frappe_providers/frappecloud.py @@ -6,7 +6,7 @@ import frappe def frappecloud_migrator(local_site): - print("Retreiving Site Migrator...") + print("Retrieving Site Migrator...") remote_site = frappe.conf.frappecloud_url or "frappecloud.com" request_url = "https://{}/api/method/press.api.script".format(remote_site) request = requests.get(request_url) diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index e282f1027d..8c76ce2f48 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -1,24 +1,24 @@ # Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors # imports - standard imports +import gzip import json import os import shlex import subprocess import sys import unittest -import gzip -from glob import glob +import glob # imports - module imports import frappe +import frappe.recorder +from frappe.installer import add_to_installed_apps from frappe.utils import add_to_date, now from frappe.utils.backups import fetch_latest_backups -import frappe.recorder # TODO: check frappe.cli.coloured_output to set coloured output! - def supports_color(): """ Returns True if the running system's terminal supports color, and False @@ -32,12 +32,12 @@ def supports_color(): class color(dict): - nc = '\033[0m' - blue = '\033[94m' - green = '\033[92m' - yellow = '\033[93m' - red = '\033[91m' - silver = '\033[90m' + nc = "\033[0m" + blue = "\033[94m" + green = "\033[92m" + yellow = "\033[93m" + red = "\033[91m" + silver = "\033[90m" def __getattr__(self, key): if supports_color(): @@ -82,6 +82,7 @@ def exists_in_backup(doctypes, file): content = f.read().decode("utf8") return all([predicate.format(doctype).lower() in content.lower() for doctype in doctypes]) + class BaseTestCommands(unittest.TestCase): def execute(self, command, kwargs=None): site = {"site": frappe.local.site} @@ -109,6 +110,7 @@ class BaseTestCommands(unittest.TestCase): ]).strip() return "{}\n\n{}".format(output, cmd_execution_summary) + class TestCommands(BaseTestCommands): def test_execute(self): # test 1: execute a command expecting a numeric output @@ -127,7 +129,7 @@ class TestCommands(BaseTestCommands): # The returned value has quotes which have been trimmed for the test self.execute("""bench --site {site} execute frappe.bold --kwargs '{{"text": "DocType"}}'""") self.assertEquals(self.returncode, 0) - self.assertEquals(self.stdout[1:-1], frappe.bold(text='DocType')) + self.assertEquals(self.stdout[1:-1], frappe.bold(text="DocType")) def test_backup(self): backup = { @@ -184,16 +186,19 @@ class TestCommands(BaseTestCommands): "db_path": "database.sql.gz", "files_path": "public.tar", "private_path": "private.tar", - "conf_path": "config.json" + "conf_path": "config.json", }.items() } - self.execute("""bench + self.execute( + """bench --site {site} backup --with-files --backup-path-db {db_path} --backup-path-files {files_path} --backup-path-private-files {private_path} - --backup-path-conf {conf_path}""", kwargs) + --backup-path-conf {conf_path}""", + kwargs, + ) self.assertEquals(self.returncode, 0) for path in kwargs.values(): @@ -202,7 +207,7 @@ class TestCommands(BaseTestCommands): # test 5: take a backup with --compress self.execute("bench --site {site} backup --with-files --compress") self.assertEquals(self.returncode, 0) - compressed_files = glob(site_backup_path + "/*.tgz") + compressed_files = glob.glob(site_backup_path + "/*.tgz") self.assertGreater(len(compressed_files), 0) # test 6: take a backup with --verbose @@ -210,14 +215,20 @@ class TestCommands(BaseTestCommands): self.assertEquals(self.returncode, 0) # test 7: take a backup with frappe.conf.backup.includes - self.execute("bench --site {site} set-config backup '{includes}' --as-dict", {"includes": json.dumps(backup["includes"])}) + self.execute( + "bench --site {site} set-config backup '{includes}' --as-dict", + {"includes": json.dumps(backup["includes"])}, + ) self.execute("bench --site {site} backup --verbose") self.assertEquals(self.returncode, 0) database = fetch_latest_backups(partial=True)["database"] self.assertTrue(exists_in_backup(backup["includes"]["includes"], database)) # test 8: take a backup with frappe.conf.backup.excludes - self.execute("bench --site {site} set-config backup '{excludes}' --as-dict", {"excludes": json.dumps(backup["excludes"])}) + self.execute( + "bench --site {site} set-config backup '{excludes}' --as-dict", + {"excludes": json.dumps(backup["excludes"])}, + ) self.execute("bench --site {site} backup --verbose") self.assertEquals(self.returncode, 0) database = fetch_latest_backups(partial=True)["database"] @@ -225,13 +236,19 @@ class TestCommands(BaseTestCommands): self.assertTrue(exists_in_backup(backup["includes"]["includes"], database)) # test 9: take a backup with --include (with frappe.conf.excludes still set) - self.execute("bench --site {site} backup --include '{include}'", {"include": ",".join(backup["includes"]["includes"])}) + self.execute( + "bench --site {site} backup --include '{include}'", + {"include": ",".join(backup["includes"]["includes"])}, + ) self.assertEquals(self.returncode, 0) database = fetch_latest_backups(partial=True)["database"] self.assertTrue(exists_in_backup(backup["includes"]["includes"], database)) # test 10: take a backup with --exclude - self.execute("bench --site {site} backup --exclude '{exclude}'", {"exclude": ",".join(backup["excludes"]["excludes"])}) + self.execute( + "bench --site {site} backup --exclude '{exclude}'", + {"exclude": ",".join(backup["excludes"]["excludes"])}, + ) self.assertEquals(self.returncode, 0) database = fetch_latest_backups(partial=True)["database"] self.assertFalse(exists_in_backup(backup["excludes"]["excludes"], database)) @@ -248,20 +265,24 @@ class TestCommands(BaseTestCommands): "admin_password": frappe.conf.admin_password, "root_login": frappe.conf.root_login, "root_password": frappe.conf.root_password, - "db_type": frappe.conf.db_type - } - site_data = { - "another_site": f"{frappe.local.site}-restore.test", - **global_config + "db_type": frappe.conf.db_type, } + site_data = {"another_site": f"{frappe.local.site}-restore.test", **global_config} for key, value in global_config.items(): if value: self.execute(f"bench set-config {key} {value} -g") - self.execute("bench new-site {another_site} --admin-password {admin_password} --db-type {db_type} --force", site_data) + self.execute( + "bench new-site {another_site} --admin-password {admin_password} --db-type" + " {db_type}", + site_data, + ) # test 1: bench restore from full backup self.execute("bench --site {another_site} backup --ignore-backup-conf", site_data) - self.execute("bench --site {another_site} execute frappe.utils.backups.fetch_latest_backups", site_data) + self.execute( + "bench --site {another_site} execute frappe.utils.backups.fetch_latest_backups", + site_data, + ) site_data.update({"database": json.loads(self.stdout)["database"]}) self.execute("bench --site {another_site} restore {database}", site_data) @@ -269,12 +290,11 @@ class TestCommands(BaseTestCommands): self.execute("bench --site {another_site} backup --exclude 'ToDo'", site_data) site_data.update({"kw": "\"{'partial':True}\""}) self.execute( - "bench --site {another_site} execute frappe.utils.backups.fetch_latest_backups --kwargs {kw}", - site_data + "bench --site {another_site} execute" + " frappe.utils.backups.fetch_latest_backups --kwargs {kw}", + site_data, ) - site_data.update({ - "database": json.loads(self.stdout)["database"] - }) + site_data.update({"database": json.loads(self.stdout)["database"]}) self.execute("bench --site {another_site} restore {database}", site_data) self.assertEquals(self.returncode, 1) @@ -314,7 +334,6 @@ class TestCommands(BaseTestCommands): self.assertEqual(frappe.recorder.status(), False) def test_remove_from_installed_apps(self): - from frappe.installer import add_to_installed_apps app = "test_remove_app" add_to_installed_apps(app) From 39da1e0363b0d15d4f2c9eb779ea3eb093d960fc Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 18 Nov 2020 01:31:18 +0530 Subject: [PATCH 103/273] fix: Raise error if executable not found in PATH --- frappe/exceptions.py | 3 ++- frappe/utils/backups.py | 29 +++++++++++++++++++++++------ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/frappe/exceptions.py b/frappe/exceptions.py index 267f5410af..9b63b23787 100644 --- a/frappe/exceptions.py +++ b/frappe/exceptions.py @@ -110,4 +110,5 @@ class DocumentAlreadyRestored(Exception): pass class InvalidAuthorizationHeader(CSRFTokenError): pass class InvalidAuthorizationPrefix(CSRFTokenError): pass class InvalidAuthorizationToken(CSRFTokenError): pass -class InvalidDatabaseFile(ValidationError): pass \ No newline at end of file +class InvalidDatabaseFile(ValidationError): pass +class ExecutableNotFound(FileNotFoundError): pass diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index b11c73c2b2..3ae300d3c4 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -2,11 +2,12 @@ # MIT License. See license.txt # imports - standard imports +import gzip import os from calendar import timegm from datetime import datetime from glob import glob -import gzip +from shutil import which # imports - third party imports import click @@ -14,7 +15,7 @@ import click # imports - module imports import frappe from frappe import _, conf -from frappe.utils import get_url, now, now_datetime, get_file_size +from frappe.utils import get_file_size, get_url, now, now_datetime # backup variable for backwards compatibility verbose = False @@ -344,6 +345,20 @@ class BackupGenerator: import frappe.utils from frappe.utils.change_log import get_app_branch + db_exc = { + "mariadb": ("mysqldump", which("mysqldump")), + "postgres": ("pg_dump", which("pg_dump")), + }[self.db_type] + gzip_exc = which("gzip") + + if not (gzip_exc and db_exc[1]): + _exc = "gzip" if not gzip_exc else db_exc[0] + frappe.throw( + f"{_exc} not found in PATH! This is required to take a backup.", + exc=frappe.ExecutableNotFound + ) + db_exc = db_exc[0] + database_header_content = [ f"Backup generated by Frappe {frappe.__version__} on branch {get_app_branch('frappe') or 'N/A'}", "", @@ -384,8 +399,8 @@ class BackupGenerator: ) cmd_string = ( - "pg_dump postgres://{user}:{password}@{db_host}:{db_port}/{db_name}" - " {include} {exclude} | gzip >> {backup_path_db}" + "{db_exc} postgres://{user}:{password}@{db_host}:{db_port}/{db_name}" + " {include} {exclude} | {gzip} >> {backup_path_db}" ) else: @@ -400,20 +415,22 @@ class BackupGenerator: ) cmd_string = ( - "mysqldump --single-transaction --quick --lock-tables=false -u {user}" + "{db_exc} --single-transaction --quick --lock-tables=false -u {user}" " -p{password} {db_name} -h {db_host} -P {db_port} {include} {exclude}" - " | gzip >> {backup_path_db}" + " | {gzip} >> {backup_path_db}" ) command = cmd_string.format( user=args.user, password=args.password, + db_exc=db_exc, db_host=args.db_host, db_port=args.db_port, db_name=args.db_name, backup_path_db=args.backup_path_db, exclude=args.get("exclude", ""), include=args.get("include", ""), + gzip=gzip_exc, ) if self.verbose: From b90980406c4ac7474a3acf27e91afef5027ad34d Mon Sep 17 00:00:00 2001 From: prssanna Date: Tue, 17 Nov 2020 18:53:27 +0530 Subject: [PATCH 104/273] fix: increase batch limit --- frappe/core/doctype/prepared_report/prepared_report.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/core/doctype/prepared_report/prepared_report.py b/frappe/core/doctype/prepared_report/prepared_report.py index 2c4d933440..1d0d6ebb09 100644 --- a/frappe/core/doctype/prepared_report/prepared_report.py +++ b/frappe/core/doctype/prepared_report/prepared_report.py @@ -89,7 +89,7 @@ def delete_expired_prepared_reports(): 'creation': ['<', frappe.utils.add_days(frappe.utils.now(), -expiry_period)] }) - batches = frappe.utils.create_batch(prepared_reports_to_delete, 50) + batches = frappe.utils.create_batch(prepared_reports_to_delete, 100) for batch in batches: args = { 'reports': batch, From 7f012b73b42616e8b622745831fa31e0212d678c Mon Sep 17 00:00:00 2001 From: Mohammad Hasnain Date: Tue, 17 Nov 2020 19:18:44 +0530 Subject: [PATCH 105/273] feat: solve translation string issues --- .../public/js/frappe/form/footer/timeline.js | 36 +++---------------- 1 file changed, 4 insertions(+), 32 deletions(-) diff --git a/frappe/public/js/frappe/form/footer/timeline.js b/frappe/public/js/frappe/form/footer/timeline.js index c23b6d8127..159ab8a61b 100644 --- a/frappe/public/js/frappe/form/footer/timeline.js +++ b/frappe/public/js/frappe/form/footer/timeline.js @@ -547,14 +547,7 @@ frappe.ui.form.Timeline = class Timeline { log.color = 'dark'; log.sender = log.owner; log.comment_type = 'Milestone'; - log.content = __( - '{0} changed {1} to {2}', - [ - frappe.user.full_name(log.owner).bold(), - frappe.meta.get_label(this.frm.doctype, log.track_field), - log.value.bold() - ] - ); + log.content = __('{0} changed {1} to {2}', [ frappe.user.full_name(log.owner).bold(), frappe.meta.get_label(this.frm.doctype, log.track_field), log.value.bold()]); return log; }); return milestones; @@ -617,14 +610,7 @@ frappe.ui.form.Timeline = class Timeline { const field_display_status = frappe.perm.get_field_display_status(df, null, me.frm.perm); if (field_display_status === 'Read' || field_display_status === 'Write') { - parts.push(__( - '{0} from {1} to {2}', - [ - __(df.label), - me.format_content_for_timeline(p[1]), - me.format_content_for_timeline(p[2]) - ] - )); + parts.push(__('{0} from {1} to {2}', [ __(df.label), me.format_content_for_timeline(p[1]), me.format_content_for_timeline(p[2])])); } } } @@ -655,18 +641,7 @@ frappe.ui.form.Timeline = class Timeline { null, me.frm.perm); if (field_display_status === 'Read' || field_display_status === 'Write') { - parts.push(__( - '{0} from {1} to {2} in row #{3}', - [ - frappe.meta.get_label( - me.frm.fields_dict[row[0]].grid.doctype, - p[0] - ), - me.format_content_for_timeline(p[1]), - me.format_content_for_timeline(p[2]), - row[1] - ] - )); + parts.push(__('{0} from {1} to {2} in row #{3}', [ frappe.meta.get_label( me.frm.fields_dict[row[0]].grid.doctype, p[0]), me.format_content_for_timeline(p[1]), me.format_content_for_timeline(p[2]), row[1] ])); } } return parts.length < 3; @@ -703,10 +678,7 @@ frappe.ui.form.Timeline = class Timeline { return p; }); if (parts.length) { - out.push(me.get_version_comment(version, __( - "{0} rows for {1}", - [__(key), parts.join(', ')] - ))); + out.push(me.get_version_comment(version, __("{0} rows for {1}", [__(key), parts.join(', ')]))); } } }); From cb6c704671dfe96063d3e59bef7e18f84430890a Mon Sep 17 00:00:00 2001 From: Saurabh Date: Mon, 2 Nov 2020 16:43:36 +0530 Subject: [PATCH 106/273] fix: Define chunk size based on backup file size to avoid timeout issues (#11526) Co-authored-by: Suraj Shetty <13928957+surajshetty3416@users.noreply.github.com> (cherry picked from commit 5020bbe4f68abaf5f9c019efff6105573d2ed5c0) # Conflicts: # frappe/integrations/offsite_backup_utils.py --- .../dropbox_settings/dropbox_settings.py | 5 ++-- frappe/integrations/offsite_backup_utils.py | 28 ++++++++++++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py b/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py index 6b95a3f5bf..71445b44d7 100644 --- a/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py +++ b/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py @@ -9,7 +9,7 @@ import frappe import os from frappe import _ from frappe.model.document import Document -from frappe.integrations.offsite_backup_utils import get_latest_backup_file, send_email, validate_file_size +from frappe.integrations.offsite_backup_utils import get_latest_backup_file, send_email, validate_file_size, get_chunk_site from frappe.integrations.utils import make_post_request from frappe.utils import (cint, get_request_site_address, get_files_path, get_backups_path, get_url, encode) @@ -167,8 +167,9 @@ def upload_file_to_dropbox(filename, folder, dropbox_client): return create_folder_if_not_exists(folder, dropbox_client) - chunk_size = 15 * 1024 * 1024 file_size = os.path.getsize(encode(filename)) + chunk_size = get_chunk_site(file_size) + mode = (dropbox.files.WriteMode.overwrite) f = open(encode(filename), 'rb') diff --git a/frappe/integrations/offsite_backup_utils.py b/frappe/integrations/offsite_backup_utils.py index db176538e4..39ffbd2c0e 100644 --- a/frappe/integrations/offsite_backup_utils.py +++ b/frappe/integrations/offsite_backup_utils.py @@ -6,7 +6,11 @@ from __future__ import unicode_literals import frappe import glob import os +<<<<<<< HEAD from frappe.utils import split_emails, get_backups_path +======= +from frappe.utils import split_emails, now_datetime, cint +>>>>>>> 5020bbe4f6... fix: Define chunk size based on backup file size to avoid timeout issues (#11526) def send_email(success, service_name, doctype, email_field, error_status=None): @@ -81,6 +85,22 @@ def get_file_size(file_path, unit): return file_size +def get_chunk_site(file_size): + ''' this function will return chunk size in megabytes based on file size ''' + + file_size_in_gb = cint(file_size/1024/1024) + + MB = 1024 * 1024 + if file_size_in_gb > 5000: + return 200 * MB + elif file_size_in_gb >= 3000: + return 150 * MB + elif file_size_in_gb >= 1000: + return 100 * MB + elif file_size_in_gb >= 500: + return 50 * MB + else: + return 15 * MB def validate_file_size(): frappe.flags.create_new_backup = True @@ -97,5 +117,11 @@ def generate_files_backup(): frappe.conf.db_password, db_host = frappe.db.host, db_type=frappe.conf.db_type, db_port=frappe.conf.db_port) +<<<<<<< HEAD backup.set_backup_file_name() - backup.zip_files() \ No newline at end of file + backup.zip_files() +======= + odb.todays_date = now_datetime().strftime('%Y%m%d_%H%M%S') + odb.set_backup_file_name() + odb.zip_files() +>>>>>>> 5020bbe4f6... fix: Define chunk size based on backup file size to avoid timeout issues (#11526) From 51a1bf9a736e460f04efed4a0685571c7d19648b Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 18 Nov 2020 12:13:57 +0530 Subject: [PATCH 107/273] fix: Delete with force to bypass linked docs errors --- frappe/installer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/installer.py b/frappe/installer.py index be9b04d453..6d6ed4eb94 100755 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -180,11 +180,11 @@ def remove_app(app_name, dry_run=False, yes=False, no_backup=False, force=False) for record in frappe.get_all(doctype, filters={"module": module_name}, pluck="name"): print(f"* removing {doctype} '{record}'...") if not dry_run: - frappe.delete_doc(doctype, record, ignore_on_trash=True) + frappe.delete_doc(doctype, record, ignore_on_trash=True, force=True) print(f"* removing Module Def '{module_name}'...") if not dry_run: - frappe.delete_doc("Module Def", module_name, ignore_on_trash=True) + frappe.delete_doc("Module Def", module_name, ignore_on_trash=True, force=True) for doctype in set(drop_doctypes): print(f"* dropping Table for '{doctype}'...") From 887f4b0d614ea05b77980ed6af8bfb73e62db3e0 Mon Sep 17 00:00:00 2001 From: Suraj Shetty <13928957+surajshetty3416@users.noreply.github.com> Date: Wed, 18 Nov 2020 13:54:57 +0530 Subject: [PATCH 108/273] fix: Resolve conflicts --- frappe/integrations/offsite_backup_utils.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/frappe/integrations/offsite_backup_utils.py b/frappe/integrations/offsite_backup_utils.py index 39ffbd2c0e..ebbeb41cce 100644 --- a/frappe/integrations/offsite_backup_utils.py +++ b/frappe/integrations/offsite_backup_utils.py @@ -6,12 +6,7 @@ from __future__ import unicode_literals import frappe import glob import os -<<<<<<< HEAD -from frappe.utils import split_emails, get_backups_path -======= -from frappe.utils import split_emails, now_datetime, cint ->>>>>>> 5020bbe4f6... fix: Define chunk size based on backup file size to avoid timeout issues (#11526) - +from frappe.utils import split_emails, get_backups_path, cint def send_email(success, service_name, doctype, email_field, error_status=None): recipients = get_recipients(doctype, email_field) @@ -117,11 +112,5 @@ def generate_files_backup(): frappe.conf.db_password, db_host = frappe.db.host, db_type=frappe.conf.db_type, db_port=frappe.conf.db_port) -<<<<<<< HEAD backup.set_backup_file_name() backup.zip_files() -======= - odb.todays_date = now_datetime().strftime('%Y%m%d_%H%M%S') - odb.set_backup_file_name() - odb.zip_files() ->>>>>>> 5020bbe4f6... fix: Define chunk size based on backup file size to avoid timeout issues (#11526) From 189c6cb7715156de759ce2d60310e328dd719789 Mon Sep 17 00:00:00 2001 From: Suraj Shetty <13928957+surajshetty3416@users.noreply.github.com> Date: Wed, 18 Nov 2020 14:01:14 +0530 Subject: [PATCH 109/273] style: Remove unused import --- frappe/integrations/offsite_backup_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/integrations/offsite_backup_utils.py b/frappe/integrations/offsite_backup_utils.py index ebbeb41cce..48a2c89107 100644 --- a/frappe/integrations/offsite_backup_utils.py +++ b/frappe/integrations/offsite_backup_utils.py @@ -6,7 +6,7 @@ from __future__ import unicode_literals import frappe import glob import os -from frappe.utils import split_emails, get_backups_path, cint +from frappe.utils import split_emails, cint def send_email(success, service_name, doctype, email_field, error_status=None): recipients = get_recipients(doctype, email_field) From 4d8ef4690eb9e364e244f296f935dab45733d5f6 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Thu, 12 Nov 2020 17:42:25 +0530 Subject: [PATCH 110/273] feat: Ability to set weekdays for Auto Repeat with weekly frequency --- .../doctype/auto_repeat/auto_repeat.json | 18 ++++++- .../doctype/auto_repeat/auto_repeat.py | 54 +++++++++++++------ .../doctype/auto_repeat_day/__init__.py | 0 .../auto_repeat_day/auto_repeat_day.json | 33 ++++++++++++ .../auto_repeat_day/auto_repeat_day.py | 10 ++++ 5 files changed, 99 insertions(+), 16 deletions(-) create mode 100644 frappe/automation/doctype/auto_repeat_day/__init__.py create mode 100644 frappe/automation/doctype/auto_repeat_day/auto_repeat_day.json create mode 100644 frappe/automation/doctype/auto_repeat_day/auto_repeat_day.py diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.json b/frappe/automation/doctype/auto_repeat/auto_repeat.json index 8ee6ca1d45..87cf423e2c 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.json +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_import": 1, "allow_rename": 1, "autoname": "format:AUT-AR-{#####}", @@ -21,6 +22,8 @@ "repeat_on_last_day", "column_break_12", "next_schedule_date", + "section_break_12", + "repeat_on_days", "notification", "notify_by_email", "recipients", @@ -186,9 +189,22 @@ "fieldname": "repeat_on_last_day", "fieldtype": "Check", "label": "Repeat on Last Day of the Month" + }, + { + "depends_on": "eval:doc.frequency==='Weekly';", + "fieldname": "repeat_on_days", + "fieldtype": "Table", + "label": "Repeat on Days", + "options": "Auto Repeat Day" + }, + { + "depends_on": "eval:doc.frequency==='Weekly';", + "fieldname": "section_break_12", + "fieldtype": "Section Break" } ], - "modified": "2019-07-17 11:30:51.412317", + "links": [], + "modified": "2020-11-10 22:44:51.815740", "modified_by": "Administrator", "module": "Automation", "name": "Auto Repeat", diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.py b/frappe/automation/doctype/auto_repeat/auto_repeat.py index fcf24bf1a9..dc6ec554d5 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.py @@ -5,6 +5,7 @@ from __future__ import unicode_literals import frappe from frappe import _ +from datetime import timedelta from frappe.desk.form import assign_to from frappe.utils.jinja import validate_template from dateutil.relativedelta import relativedelta @@ -15,6 +16,7 @@ from frappe.core.doctype.communication.email import make from frappe.utils.background_jobs import get_jobs month_map = {'Monthly': 1, 'Quarterly': 3, 'Half-yearly': 6, 'Yearly': 12} +week_map = {'Monday': 0, 'Tuesday': 1, 'Wednesday': 2, 'Thursday': 3, 'Friday': 4, 'Saturday': 5, 'Sunday': 6} class AutoRepeat(Document): @@ -48,7 +50,7 @@ class AutoRepeat(Document): if self.disabled: self.next_schedule_date = None else: - self.next_schedule_date = get_next_schedule_date(self.start_date, self.frequency, self.start_date, self.repeat_on_day, self.repeat_on_last_day, self.end_date) + self.next_schedule_date = get_next_schedule_date(schedule_date=self.start_date, auto_repeat_doc=self) def unlink_if_applicable(self): if self.status == 'Completed' or self.disabled: @@ -107,7 +109,7 @@ class AutoRepeat(Document): end_date = getdate(self.end_date) if not self.end_date: - next_date = get_next_schedule_date(start_date, self.frequency, self.start_date, self.repeat_on_day, self.repeat_on_last_day) + next_date = get_next_schedule_date(schedule_date=start_date, auto_repeat_doc=self) row = { "reference_document": self.reference_document, "frequency": self.frequency, @@ -117,7 +119,7 @@ class AutoRepeat(Document): if self.end_date: next_date = get_next_schedule_date( - start_date, self.frequency, self.start_date, self.repeat_on_day, self.repeat_on_last_day, for_full_schedule=True) + schedule_date=start_date, auto_repeat_doc=self, for_full_schedule=True) while (getdate(next_date) < getdate(end_date)): row = { @@ -127,7 +129,7 @@ class AutoRepeat(Document): } schedule_details.append(row) next_date = get_next_schedule_date( - next_date, self.frequency, self.start_date, self.repeat_on_day, self.repeat_on_last_day, end_date, for_full_schedule=True) + schedule_date=next_date, auto_repeat_doc=self, for_full_schedule=True) return schedule_details @@ -282,31 +284,34 @@ class AutoRepeat(Document): ) -def get_next_schedule_date(schedule_date, frequency, start_date, repeat_on_day=None, repeat_on_last_day=False, end_date=None, for_full_schedule=False): - if month_map.get(frequency): - month_count = month_map.get(frequency) + month_diff(schedule_date, start_date) - 1 +def get_next_schedule_date(schedule_date, auto_repeat_doc=None, for_full_schedule=False): + if month_map.get(auto_repeat_doc.frequency): + month_count = month_map.get(auto_repeat_doc.frequency) + month_diff(schedule_date, auto_repeat_doc.start_date) - 1 else: month_count = 0 day_count = 0 - if month_count and repeat_on_last_day: + if month_count and auto_repeat_doc.repeat_on_last_day: day_count = 31 - next_date = get_next_date(start_date, month_count, day_count) + next_date = get_next_date(auto_repeat_doc.start_date, month_count, day_count) elif month_count and repeat_on_day: day_count = repeat_on_day - next_date = get_next_date(start_date, month_count, day_count) + next_date = get_next_date(auto_repeat_doc.start_date, month_count, day_count) elif month_count: - next_date = get_next_date(start_date, month_count) + next_date = get_next_date(auto_repeat_doc.start_date, month_count) else: - days = 7 if frequency == 'Weekly' else 1 + if auto_repeat_doc.frequency == "Weekly": + days = get_offset_for_weekly_frequency(auto_repeat_doc) + else: + days = 1 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) + month_count += month_map.get(auto_repeat_doc.frequency) + next_date = get_next_date(auto_repeat_doc.start_date, month_count, day_count) elif days: next_date = add_days(next_date, days) @@ -318,6 +323,25 @@ def get_next_date(dt, mcount, day=None): dt += relativedelta(months=mcount, day=day) return dt + +def get_offset_for_weekly_frequency(auto_repeat_doc): + if not auto_repeat_doc.repeat_on_days: + return 7 + + repeat_on_days = [entry.day for entry in auto_repeat_doc.repeat_on_days] + current_day = getdate().weekday() + weekday = get_next_weekday(current_day, repeat_on_days) + return timedelta((7 + week_map.get(weekday) - current_day) % 7).days + + +def get_next_weekday(current_day, weekdays): + days = list(week_map.keys()) + days = days[current_day:] + days[:current_day] + for entry in days: + if entry in weekdays: + return entry + + #called through hooks def make_auto_repeat_entry(): enqueued_method = 'frappe.automation.doctype.auto_repeat.auto_repeat.create_repeated_entries' @@ -337,7 +361,7 @@ def create_repeated_entries(data): if schedule_date == current_date and not doc.disabled: doc.create_documents() - schedule_date = get_next_schedule_date(schedule_date, doc.frequency, doc.start_date, doc.repeat_on_day, doc.repeat_on_last_day, doc.end_date) + schedule_date = get_next_schedule_date(schedule_date=schedule_date, auto_repeat_doc=doc) if schedule_date and not doc.disabled: frappe.db.set_value('Auto Repeat', doc.name, 'next_schedule_date', schedule_date) diff --git a/frappe/automation/doctype/auto_repeat_day/__init__.py b/frappe/automation/doctype/auto_repeat_day/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/automation/doctype/auto_repeat_day/auto_repeat_day.json b/frappe/automation/doctype/auto_repeat_day/auto_repeat_day.json new file mode 100644 index 0000000000..6f5c3060cd --- /dev/null +++ b/frappe/automation/doctype/auto_repeat_day/auto_repeat_day.json @@ -0,0 +1,33 @@ +{ + "actions": [], + "creation": "2020-11-10 22:30:53.690228", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "day" + ], + "fields": [ + { + "fieldname": "day", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Day", + "options": "Monday\nTuesday\nWednesday\nThursday\nFriday\nSaturday\nSunday", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2020-11-10 22:30:53.690228", + "modified_by": "Administrator", + "module": "Automation", + "name": "Auto Repeat Day", + "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/automation/doctype/auto_repeat_day/auto_repeat_day.py b/frappe/automation/doctype/auto_repeat_day/auto_repeat_day.py new file mode 100644 index 0000000000..3a7ced1370 --- /dev/null +++ b/frappe/automation/doctype/auto_repeat_day/auto_repeat_day.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 AutoRepeatDay(Document): + pass From 5e7e7cc922fe0cad987f32ede3d39ea9ef3f02c8 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Thu, 19 Nov 2020 14:13:27 +0530 Subject: [PATCH 111/273] fix: full schedule calculation for weekdays --- .../doctype/auto_repeat/auto_repeat.js | 4 +- .../doctype/auto_repeat/auto_repeat.py | 37 +++++++++++++++---- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.js b/frappe/automation/doctype/auto_repeat/auto_repeat.js index a11de1d881..2b1102f681 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.js +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.js @@ -86,10 +86,10 @@ frappe.ui.form.on('Auto Repeat', { frappe.auto_repeat.render_schedule = function(frm) { if (!frm.is_dirty() && frm.doc.status !== 'Disabled') { - frappe.call({ + frm.call({ method: "get_auto_repeat_schedule", doc: frm.doc - }).done((r) => { + }).then((r) => { frm.dashboard.wrapper.empty(); frm.dashboard.add_section( frappe.render_template("auto_repeat_schedule", { diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.py b/frappe/automation/doctype/auto_repeat/auto_repeat.py index dc6ec554d5..d0f01f4ad5 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.py @@ -14,6 +14,7 @@ from frappe.utils import cstr, getdate, split_emails, add_days, today, get_last_ from frappe.model.document import Document from frappe.core.doctype.communication.email import make from frappe.utils.background_jobs import get_jobs +from frappe.automation.doctype.assignment_rule.assignment_rule import get_repeated month_map = {'Monthly': 1, 'Quarterly': 3, 'Half-yearly': 6, 'Yearly': 12} week_map = {'Monday': 0, 'Tuesday': 1, 'Wednesday': 2, 'Thursday': 3, 'Friday': 4, 'Saturday': 5, 'Sunday': 6} @@ -25,6 +26,7 @@ class AutoRepeat(Document): self.validate_reference_doctype() self.validate_dates() self.validate_email_id() + self.validate_auto_repeat_days() self.set_dates() self.update_auto_repeat_id() self.unlink_if_applicable() @@ -84,6 +86,12 @@ class AutoRepeat(Document): else: frappe.throw(_("'Recipients' not specified")) + def validate_auto_repeat_days(self): + auto_repeat_days = get_auto_repeat_days(self) + if not len(set(auto_repeat_days)) == len(auto_repeat_days): + repeated_days = get_repeated(auto_repeat_days) + frappe.throw(_('Auto Repeat Day {0} has been repeated.').format(frappe.bold(repeated_days))) + def update_auto_repeat_id(self): #check if document is already on auto repeat auto_repeat = frappe.db.get_value(self.reference_doctype, self.reference_document, "auto_repeat") @@ -301,7 +309,7 @@ def get_next_schedule_date(schedule_date, auto_repeat_doc=None, for_full_schedul next_date = get_next_date(auto_repeat_doc.start_date, month_count) else: if auto_repeat_doc.frequency == "Weekly": - days = get_offset_for_weekly_frequency(auto_repeat_doc) + days = get_offset_for_weekly_frequency(schedule_date, auto_repeat_doc) else: days = 1 next_date = add_days(schedule_date, days) @@ -324,24 +332,37 @@ def get_next_date(dt, mcount, day=None): return dt -def get_offset_for_weekly_frequency(auto_repeat_doc): +def get_offset_for_weekly_frequency(schedule_date, auto_repeat_doc): if not auto_repeat_doc.repeat_on_days: return 7 - repeat_on_days = [entry.day for entry in auto_repeat_doc.repeat_on_days] - current_day = getdate().weekday() - weekday = get_next_weekday(current_day, repeat_on_days) - return timedelta((7 + week_map.get(weekday) - current_day) % 7).days + repeat_on_days = get_auto_repeat_days(auto_repeat_doc) + current_schedule_day = getdate(schedule_date).weekday() + + if len(repeat_on_days) > 1 or list(week_map.keys())[current_schedule_day] not in repeat_on_days: + weekday = get_next_weekday(current_schedule_day, repeat_on_days) + next_weekday_number = week_map.get(weekday) + return timedelta((7 + next_weekday_number - current_schedule_day) % 7).days + else: + return 7 -def get_next_weekday(current_day, weekdays): +def get_next_weekday(current_schedule_day, weekdays): days = list(week_map.keys()) - days = days[current_day:] + days[:current_day] + if current_schedule_day > 0: + days = days[(current_schedule_day + 1):] + days[:current_schedule_day] + else: + days = days[(current_schedule_day + 1):] + for entry in days: if entry in weekdays: return entry +def get_auto_repeat_days(doc): + return [d.day for d in doc.get('repeat_on_days', [])] + + #called through hooks def make_auto_repeat_entry(): enqueued_method = 'frappe.automation.doctype.auto_repeat.auto_repeat.create_repeated_entries' From 6f6a20d6b5e880a7dde0f23976088ae8626978e9 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Thu, 19 Nov 2020 14:35:30 +0530 Subject: [PATCH 112/273] fix: handle schedule for a past start date --- frappe/automation/doctype/auto_repeat/auto_repeat.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.py b/frappe/automation/doctype/auto_repeat/auto_repeat.py index d0f01f4ad5..fe2fccf99d 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.py @@ -311,6 +311,7 @@ def get_next_schedule_date(schedule_date, auto_repeat_doc=None, for_full_schedul if auto_repeat_doc.frequency == "Weekly": days = get_offset_for_weekly_frequency(schedule_date, auto_repeat_doc) else: + # daily frequency days = 1 next_date = add_days(schedule_date, days) @@ -321,6 +322,11 @@ def get_next_schedule_date(schedule_date, auto_repeat_doc=None, for_full_schedul month_count += month_map.get(auto_repeat_doc.frequency) next_date = get_next_date(auto_repeat_doc.start_date, month_count, day_count) elif days: + if auto_repeat_doc.frequency == "Weekly": + days = get_offset_for_weekly_frequency(next_date, auto_repeat_doc) + else: + # daily frequency + days = 1 next_date = add_days(next_date, days) return next_date @@ -333,15 +339,20 @@ def get_next_date(dt, mcount, day=None): def get_offset_for_weekly_frequency(schedule_date, auto_repeat_doc): + # if weekdays are not set, offset is 7 from current schedule date if not auto_repeat_doc.repeat_on_days: return 7 repeat_on_days = get_auto_repeat_days(auto_repeat_doc) current_schedule_day = getdate(schedule_date).weekday() + # if repeats on more than 1 day or + # start date's weekday is not in repeat days, then get next weekday + # else offset is 7 if len(repeat_on_days) > 1 or list(week_map.keys())[current_schedule_day] not in repeat_on_days: weekday = get_next_weekday(current_schedule_day, repeat_on_days) next_weekday_number = week_map.get(weekday) + # offset for upcoming weekday return timedelta((7 + next_weekday_number - current_schedule_day) % 7).days else: return 7 From 4b7120c5c4dd2d6286666d9a04f676d6c00c21da Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Thu, 19 Nov 2020 15:30:07 +0530 Subject: [PATCH 113/273] test: Auto Repeat with weekly frequency --- .../doctype/auto_repeat/auto_repeat.py | 7 +-- .../doctype/auto_repeat/test_auto_repeat.py | 50 ++++++++++++++++++- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.py b/frappe/automation/doctype/auto_repeat/auto_repeat.py index fe2fccf99d..a41fef1f6b 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.py @@ -302,8 +302,8 @@ def get_next_schedule_date(schedule_date, auto_repeat_doc=None, for_full_schedul if month_count and auto_repeat_doc.repeat_on_last_day: day_count = 31 next_date = get_next_date(auto_repeat_doc.start_date, month_count, day_count) - elif month_count and repeat_on_day: - day_count = repeat_on_day + elif month_count and auto_repeat_doc.repeat_on_day: + day_count = auto_repeat_doc.repeat_on_day next_date = get_next_date(auto_repeat_doc.start_date, month_count, day_count) elif month_count: next_date = get_next_date(auto_repeat_doc.start_date, month_count) @@ -345,11 +345,12 @@ def get_offset_for_weekly_frequency(schedule_date, auto_repeat_doc): repeat_on_days = get_auto_repeat_days(auto_repeat_doc) current_schedule_day = getdate(schedule_date).weekday() + weekdays = list(week_map.keys()) # if repeats on more than 1 day or # start date's weekday is not in repeat days, then get next weekday # else offset is 7 - if len(repeat_on_days) > 1 or list(week_map.keys())[current_schedule_day] not in repeat_on_days: + if len(repeat_on_days) > 1 or weekdays[current_schedule_day] not in repeat_on_days: weekday = get_next_weekday(current_schedule_day, repeat_on_days) next_weekday_number = week_map.get(weekday) # offset for upcoming weekday diff --git a/frappe/automation/doctype/auto_repeat/test_auto_repeat.py b/frappe/automation/doctype/auto_repeat/test_auto_repeat.py index 60fa9cb59e..69e9d98c0a 100644 --- a/frappe/automation/doctype/auto_repeat/test_auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/test_auto_repeat.py @@ -10,6 +10,7 @@ from frappe.custom.doctype.custom_field.custom_field import create_custom_field from frappe.automation.doctype.auto_repeat.auto_repeat import get_auto_repeat_entries, create_repeated_entries from frappe.utils import today, add_days, getdate, add_months +week_map = {'Monday': 0, 'Tuesday': 1, 'Wednesday': 2, 'Thursday': 3, 'Friday': 4, 'Saturday': 5, 'Sunday': 6} def add_custom_fields(): df = dict( @@ -42,6 +43,52 @@ class TestAutoRepeat(unittest.TestCase): self.assertEqual(todo.get('description'), new_todo.get('description')) + def test_weekly_auto_repeat(self): + todo = frappe.get_doc( + dict(doctype='ToDo', description='test weekly todo', assigned_by='Administrator')).insert() + + doc = make_auto_repeat(reference_doctype='ToDo', + frequency='Weekly', reference_document=todo.name, start_date=add_days(today(), -7)) + + self.assertEqual(doc.next_schedule_date, today()) + data = get_auto_repeat_entries(getdate(today())) + create_repeated_entries(data) + frappe.db.commit() + + todo = frappe.get_doc(doc.reference_doctype, doc.reference_document) + self.assertEqual(todo.auto_repeat, doc.name) + + new_todo = frappe.db.get_value('ToDo', + {'auto_repeat': doc.name, 'name': ('!=', todo.name)}, 'name') + + new_todo = frappe.get_doc('ToDo', new_todo) + + self.assertEqual(todo.get('description'), new_todo.get('description')) + + def test_weekly_auto_repeat_with_weekdays(self): + todo = frappe.get_doc( + dict(doctype='ToDo', description='test auto repeat with weekdays', assigned_by='Administrator')).insert() + + weekdays = list(week_map.keys()) + current_weekday = getdate().weekday() + days = [ + {'day': weekdays[current_weekday]}, + {'day': weekdays[current_weekday + 2]} + ] + doc = make_auto_repeat(reference_doctype='ToDo', + frequency='Weekly', reference_document=todo.name, start_date=add_days(today(), -7), days=days) + + self.assertEqual(doc.next_schedule_date, today()) + data = get_auto_repeat_entries(getdate(today())) + create_repeated_entries(data) + frappe.db.commit() + + todo = frappe.get_doc(doc.reference_doctype, doc.reference_document) + self.assertEqual(todo.auto_repeat, doc.name) + + doc.reload() + self.assertEqual(doc.next_schedule_date, add_days(getdate(), 2)) + def test_monthly_auto_repeat(self): start_date = today() end_date = add_months(start_date, 12) @@ -124,7 +171,8 @@ def make_auto_repeat(**args): 'notify_by_email': args.notify or 0, 'recipients': args.recipients or "", 'subject': args.subject or "", - 'message': args.message or "" + 'message': args.message or "", + 'repeat_on_days': args.days or [] }).insert(ignore_permissions=True) return doc From 4ebc7be1a3ad256aa2d8be9f633bf8063b526df3 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Thu, 19 Nov 2020 16:25:12 +0530 Subject: [PATCH 114/273] chore: code clean-up --- .../doctype/auto_repeat/auto_repeat.py | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.py b/frappe/automation/doctype/auto_repeat/auto_repeat.py index a41fef1f6b..c47b672595 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.py @@ -308,11 +308,7 @@ def get_next_schedule_date(schedule_date, auto_repeat_doc=None, for_full_schedul elif month_count: next_date = get_next_date(auto_repeat_doc.start_date, month_count) else: - if auto_repeat_doc.frequency == "Weekly": - days = get_offset_for_weekly_frequency(schedule_date, auto_repeat_doc) - else: - # daily frequency - days = 1 + days = get_days(schedule_date, auto_repeat_doc) next_date = add_days(schedule_date, days) # next schedule date should be after or on current date @@ -322,11 +318,7 @@ def get_next_schedule_date(schedule_date, auto_repeat_doc=None, for_full_schedul month_count += month_map.get(auto_repeat_doc.frequency) next_date = get_next_date(auto_repeat_doc.start_date, month_count, day_count) elif days: - if auto_repeat_doc.frequency == "Weekly": - days = get_offset_for_weekly_frequency(next_date, auto_repeat_doc) - else: - # daily frequency - days = 1 + days = get_days(next_date, auto_repeat_doc) next_date = add_days(next_date, days) return next_date @@ -338,6 +330,16 @@ def get_next_date(dt, mcount, day=None): return dt +def get_days(schedule_date, auto_repeat_doc): + if auto_repeat_doc.frequency == "Weekly": + days = get_offset_for_weekly_frequency(schedule_date, auto_repeat_doc) + else: + # daily frequency + days = 1 + + return days + + def get_offset_for_weekly_frequency(schedule_date, auto_repeat_doc): # if weekdays are not set, offset is 7 from current schedule date if not auto_repeat_doc.repeat_on_days: @@ -355,8 +357,7 @@ def get_offset_for_weekly_frequency(schedule_date, auto_repeat_doc): next_weekday_number = week_map.get(weekday) # offset for upcoming weekday return timedelta((7 + next_weekday_number - current_schedule_day) % 7).days - else: - return 7 + return 7 def get_next_weekday(current_schedule_day, weekdays): @@ -385,6 +386,7 @@ def make_auto_repeat_entry(): data = get_auto_repeat_entries(date) frappe.enqueue(enqueued_method, data=data) + def create_repeated_entries(data): for d in data: doc = frappe.get_doc('Auto Repeat', d.name) @@ -398,6 +400,7 @@ def create_repeated_entries(data): if schedule_date and not doc.disabled: frappe.db.set_value('Auto Repeat', doc.name, 'next_schedule_date', schedule_date) + def get_auto_repeat_entries(date=None): if not date: date = getdate(today()) @@ -406,6 +409,7 @@ def get_auto_repeat_entries(date=None): ['status', '=', 'Active'] ]) + #called through hooks def set_auto_repeat_as_completed(): auto_repeat = frappe.get_all("Auto Repeat", filters = {'status': ['!=', 'Disabled']}) @@ -415,6 +419,7 @@ def set_auto_repeat_as_completed(): doc.status = 'Completed' doc.save() + @frappe.whitelist() def make_auto_repeat(doctype, docname, frequency = 'Daily', start_date = None, end_date = None): if not start_date: From 17ec45b92a11ac7f11c547f06240a4987b284342 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Thu, 19 Nov 2020 16:41:17 +0530 Subject: [PATCH 115/273] fix: test case for edge case scenario --- frappe/automation/doctype/auto_repeat/test_auto_repeat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/automation/doctype/auto_repeat/test_auto_repeat.py b/frappe/automation/doctype/auto_repeat/test_auto_repeat.py index 69e9d98c0a..3cd10e8a61 100644 --- a/frappe/automation/doctype/auto_repeat/test_auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/test_auto_repeat.py @@ -73,7 +73,7 @@ class TestAutoRepeat(unittest.TestCase): current_weekday = getdate().weekday() days = [ {'day': weekdays[current_weekday]}, - {'day': weekdays[current_weekday + 2]} + {'day': weekdays[(current_weekday + 2) % 7]} ] doc = make_auto_repeat(reference_doctype='ToDo', frequency='Weekly', reference_document=todo.name, start_date=add_days(today(), -7), days=days) From 2c4b5c67b03c199c572cd1c0ae48b48fff9417b5 Mon Sep 17 00:00:00 2001 From: conncampbell Date: Sun, 8 Nov 2020 10:02:35 -0700 Subject: [PATCH 116/273] fix: Read-only table has read-only form fields. --- cypress/integration/depends_on.js | 57 +++- cypress/support/commands.js | 41 ++- .../js/frappe/form/controls/base_control.js | 27 +- .../public/js/frappe/form/controls/table.js | 3 +- frappe/public/js/frappe/form/grid.js | 2 + frappe/public/js/frappe/form/grid_row_form.js | 3 + frappe/public/js/frappe/form/layout.js | 259 +++++++++--------- .../js/frappe/web_form/webform_script.js | 4 +- frappe/tests/ui_test_helpers.py | 18 ++ 9 files changed, 273 insertions(+), 141 deletions(-) diff --git a/cypress/integration/depends_on.js b/cypress/integration/depends_on.js index 93417014c5..aa80afb59a 100644 --- a/cypress/integration/depends_on.js +++ b/cypress/integration/depends_on.js @@ -3,7 +3,31 @@ context('Depends On', () => { cy.login(); cy.visit('/desk#workspace/Website'); return cy.window().its('frappe').then(frappe => { - return frappe.call('frappe.tests.ui_test_helpers.create_doctype', { + return frappe.xcall('frappe.tests.ui_test_helpers.create_child_doctype', { + name: 'Child Test Depends On', + fields: [ + { + "label": "Child Test Field", + "fieldname": "child_test_field", + "fieldtype": "Data", + "in_list_view": 1, + }, + { + "label": "Child Dependant Field", + "fieldname": "child_dependant_field", + "fieldtype": "Data", + "in_list_view": 1, + }, + { + "label": "Child Display Dependant Field", + "fieldname": "child_display_dependant_field", + "fieldtype": "Data", + "in_list_view": 1, + }, + ] + }); + }).then(frappe => { + return frappe.xcall('frappe.tests.ui_test_helpers.create_doctype', { name: 'Test Depends On', fields: [ { @@ -24,6 +48,13 @@ context('Depends On', () => { "fieldtype": "Data", 'depends_on': "eval:doc.test_field=='Value'" }, + { + "label": "Child Test Depends On Field", + "fieldname": "child_test_depends_on_field", + "fieldtype": "Table", + 'read_only_depends_on': "eval:doc.test_field=='Some Other Value'", + 'options': "Child Test Depends On" + }, ] }); }); @@ -48,6 +79,30 @@ context('Depends On', () => { cy.get('body').click(); cy.get('.control-input [data-fieldname="dependant_field"]').should('not.be.disabled'); }); + it('should set the table and its fields as read only depending on other fields value', () => { + cy.new_form('Test Depends On'); + cy.fill_field('dependant_field', 'Some Value'); + //cy.fill_field('test_field', 'Some Other Value'); + cy.get('.frappe-control[data-fieldname="child_test_depends_on_field"]').as('table'); + cy.get('@table').find('button.grid-add-row').click(); + cy.get('@table').find('[data-idx="1"]').as('row1'); + cy.get('@row1').find('.btn-open-row').click(); + cy.get('@row1').find('.form-in-grid').as('row1-form_in_grid'); + //cy.get('@row1-form_in_grid').find('') + cy.fill_table_field('child_test_depends_on_field', '1', 'child_test_field', 'Some Value'); + cy.fill_table_field('child_test_depends_on_field', '1', 'child_dependant_field', 'Some Other Value'); + + cy.get('@row1-form_in_grid').find('.octicon-triangle-up').click(); + + // set the table to read-only + cy.fill_field('test_field', 'Some Other Value'); + + // grid row form fields should be read-only + cy.get('@row1').find('.btn-open-row').click(); + + cy.get('@row1-form_in_grid').find('.control-input [data-fieldname="child_test_field"]').should('be.disabled'); + cy.get('@row1-form_in_grid').find('.control-input [data-fieldname="child_dependant_field"]').should('be.disabled'); + }); it('should display the field depending on other fields value', () => { cy.new_form('Test Depends On'); cy.get('.control-input [data-fieldname="display_dependant_field"]').should('not.be.visible'); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 7816d5526f..3e54a9cd4c 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -160,7 +160,7 @@ Cypress.Commands.add('remove_doc', (doctype, name) => { Cypress.Commands.add('create_records', doc => { return cy - .call('frappe.tests.ui_test_helpers.create_if_not_exists', { doc }) + .call('frappe.tests.ui_test_helpers.create_if_not_exists', {doc}) .then(r => r.message); }); @@ -186,7 +186,7 @@ Cypress.Commands.add('fill_field', (fieldname, value, fieldtype = 'Data') => { if (fieldtype === 'Select') { cy.get('@input').select(value); } else { - cy.get('@input').type(value, { waitForAnimations: false, force: true }); + cy.get('@input').type(value, {waitForAnimations: false, force: true}); } return cy.get('@input'); }); @@ -204,8 +204,43 @@ Cypress.Commands.add('get_field', (fieldname, fieldtype = 'Data') => { return cy.get(selector); }); +Cypress.Commands.add('fill_table_field', (tablefieldname, row_idx, fieldname, value, fieldtype = 'Data') => { + cy.get_table_field(tablefieldname, row_idx, fieldname, fieldtype).as('input'); + + if (['Date', 'Time', 'Datetime'].includes(fieldtype)) { + cy.get('@input').click().wait(200); + cy.get('.datepickers-container .datepicker.active').should('exist'); + } + if (fieldtype === 'Time') { + cy.get('@input').clear().wait(200); + } + + if (fieldtype === 'Select') { + cy.get('@input').select(value); + } else { + cy.get('@input').type(value, {waitForAnimations: false, force: true}); + } + return cy.get('@input'); +}); + +Cypress.Commands.add('get_table_field', (tablefieldname, row_idx, fieldname, fieldtype = 'Data') => { + let selector = `.frappe-control[data-fieldname="${tablefieldname}"]`; + selector += ` [data-idx="${row_idx}"]`; + selector += ` .form-in-grid`; + + if (fieldtype === 'Text Editor') { + selector += ` [data-fieldname="${fieldname}"] .ql-editor[contenteditable=true]`; + } else if (fieldtype === 'Code') { + selector += ` [data-fieldname="${fieldname}"] .ace_text-input`; + } else { + selector += ` .form-control[data-fieldname="${fieldname}"]`; + } + + return cy.get(selector); +}); + Cypress.Commands.add('awesomebar', text => { - cy.get('#navbar-search').type(`${text}{downarrow}{enter}`, { delay: 100 }); + cy.get('#navbar-search').type(`${text}{downarrow}{enter}`, {delay: 100}); }); Cypress.Commands.add('new_form', doctype => { diff --git a/frappe/public/js/frappe/form/controls/base_control.js b/frappe/public/js/frappe/form/controls/base_control.js index 319aa067cc..d7f873bee0 100644 --- a/frappe/public/js/frappe/form/controls/base_control.js +++ b/frappe/public/js/frappe/form/controls/base_control.js @@ -40,23 +40,31 @@ frappe.ui.form.Control = Class.extend({ return this.df.get_status(this); } - if((!this.doctype && !this.docname) || this.df.parenttype === 'Web Form') { + if ((!this.doctype && !this.docname) || this.df.parenttype === 'Web Form' || this.df.is_web_form) { // like in case of a dialog box if (cint(this.df.hidden)) { // eslint-disable-next-line - if(explain) console.log("By Hidden: None"); + if (explain) console.log("By Hidden: None"); // eslint-disable-line no-console return "None"; } else if (cint(this.df.hidden_due_to_dependency)) { // eslint-disable-next-line - if(explain) console.log("By Hidden Dependency: None"); + if(explain) console.log("By Hidden Dependency: None"); // eslint-disable-line no-console return "None"; } else if (cint(this.df.read_only)) { // eslint-disable-next-line - if(explain) console.log("By Read Only: Read"); + if (explain) console.log("By Read Only: Read"); // eslint-disable-line no-console return "Read"; + } else if ((this.grid && + this.grid.display_status == 'Read') || + (this.layout && + this.layout.grid && + this.layout.grid.display_status == 'Read')) { + // parent grid is read + if (explain) console.log("By Parent Grid Read-only: Read"); // eslint-disable-line no-console + return "Read"; } return "Write"; @@ -65,13 +73,22 @@ frappe.ui.form.Control = Class.extend({ var status = frappe.perm.get_field_display_status(this.df, frappe.model.get_doc(this.doctype, this.docname), this.perm || (this.frm && this.frm.perm), explain); + // Match parent grid controls read only status + if (status === 'Write' && (this.grid || (this.layout && this.layout.grid))) { + var grid = this.grid || this.layout.grid; + if (grid.display_status == 'Read') { + status = 'Read'; + if (explain) console.log("By Parent Grid Read-only: Read"); // eslint-disable-line no-console + } + } + // hide if no value if (this.doctype && status==="Read" && !this.only_input && is_null(frappe.model.get_value(this.doctype, this.docname, this.df.fieldname)) && !in_list(["HTML", "Image", "Button"], this.df.fieldtype)) { // eslint-disable-next-line - if(explain) console.log("By Hide Read-only, null fields: None"); + if (explain) console.log("By Hide Read-only, null fields: None"); // eslint-disable-line no-console status = "None"; } diff --git a/frappe/public/js/frappe/form/controls/table.js b/frappe/public/js/frappe/form/controls/table.js index 14fad1c010..a87a4ad2a6 100644 --- a/frappe/public/js/frappe/form/controls/table.js +++ b/frappe/public/js/frappe/form/controls/table.js @@ -9,7 +9,8 @@ frappe.ui.form.ControlTable = frappe.ui.form.Control.extend({ frm: this.frm, df: this.df, perm: this.perm || (this.frm && this.frm.perm) || this.df.perm, - parent: this.wrapper + parent: this.wrapper, + control: this }); if(this.frm) { this.frm.grids[this.frm.grids.length] = this; diff --git a/frappe/public/js/frappe/form/grid.js b/frappe/public/js/frappe/form/grid.js index 9c916ccc4a..8ef5860d0d 100644 --- a/frappe/public/js/frappe/form/grid.js +++ b/frappe/public/js/frappe/form/grid.js @@ -264,6 +264,8 @@ export default class Grid { if (this.frm) { this.display_status = frappe.perm.get_field_display_status(this.df, this.frm.doc, this.perm); + } else if (this.df.is_web_form && this.control) { + this.display_status = this.control.get_status(); } else { // not in form this.display_status = 'Write'; diff --git a/frappe/public/js/frappe/form/grid_row_form.js b/frappe/public/js/frappe/form/grid_row_form.js index f93640936f..71c0c6e679 100644 --- a/frappe/public/js/frappe/form/grid_row_form.js +++ b/frappe/public/js/frappe/form/grid_row_form.js @@ -16,6 +16,9 @@ export default class GridRowForm { body: this.form_area, no_submit_on_enter: true, frm: this.row.frm, + grid: this.row.grid, + grid_row: this.row, + grid_row_form: this, }); this.layout.make(); diff --git a/frappe/public/js/frappe/form/layout.js b/frappe/public/js/frappe/form/layout.js index 2195568317..85daecc57a 100644 --- a/frappe/public/js/frappe/form/layout.js +++ b/frappe/public/js/frappe/form/layout.js @@ -1,7 +1,7 @@ import '../class'; frappe.ui.form.Layout = Class.extend({ - init: function(opts) { + init: function (opts) { this.views = {}; this.pages = []; this.sections = []; @@ -10,24 +10,24 @@ frappe.ui.form.Layout = Class.extend({ $.extend(this, opts); }, - make: function() { - if(!this.parent && this.body) { + make: function () { + if (!this.parent && this.body) { this.parent = this.body; } this.wrapper = $('
    ').appendTo(this.parent); this.message = $('').appendTo(this.wrapper); - if(!this.fields) { + if (!this.fields) { this.fields = this.get_doctype_fields(); } this.setup_tabbing(); this.render(); }, - show_empty_form_message: function() { - if(!(this.wrapper.find(".frappe-control:visible").length || this.wrapper.find(".section-head.collapsed").length)) { + show_empty_form_message: function () { + if (!(this.wrapper.find(".frappe-control:visible").length || this.wrapper.find(".section-head.collapsed").length)) { this.show_message(__("This form does not have any input")); } }, - get_doctype_fields: function() { + get_doctype_fields: function () { let fields = [ { parent: this.frm.doctype, @@ -36,7 +36,7 @@ frappe.ui.form.Layout = Class.extend({ reqd: 1, hidden: 1, label: __('Name'), - get_status: function(field) { + get_status: function (field) { if (field.frm && field.frm.is_new() && field.frm.meta.autoname && ['prompt', 'name'].includes(field.frm.meta.autoname.toLowerCase())) { @@ -49,14 +49,14 @@ frappe.ui.form.Layout = Class.extend({ fields = fields.concat(frappe.meta.sort_docfields(frappe.meta.docfield_map[this.doctype])); return fields; }, - show_message: function(html, color) { + show_message: function (html, color) { if (this.message_color) { // remove previous color this.message.removeClass(this.message_color); } this.message_color = (color && ['yellow', 'blue'].includes(color)) ? color : 'blue'; - if(html) { - if(html.substr(0, 1)!=='<') { + if (html) { + if (html.substr(0, 1) !== '<') { // wrap in a block html = '
    ' + html + '
    '; } @@ -66,7 +66,7 @@ frappe.ui.form.Layout = Class.extend({ this.message.empty().addClass('hidden'); } }, - render: function(new_fields) { + render: function (new_fields) { var me = this; var fields = new_fields || this.fields; @@ -80,8 +80,8 @@ frappe.ui.form.Layout = Class.extend({ if (this.no_opening_section()) { this.make_section(); } - $.each(fields, function(i, df) { - switch(df.fieldtype) { + $.each(fields, function (i, df) { + switch (df.fieldtype) { case "Fold": me.make_page(df); break; @@ -98,11 +98,11 @@ frappe.ui.form.Layout = Class.extend({ }, - no_opening_section: function() { - return (this.fields[0] && this.fields[0].fieldtype!="Section Break") || !this.fields.length; + no_opening_section: function () { + return (this.fields[0] && this.fields[0].fieldtype != "Section Break") || !this.fields.length; }, - setup_dashboard_section: function() { + setup_dashboard_section: function () { if (this.no_opening_section()) { this.fields.unshift({fieldtype: 'Section Break'}); } @@ -117,7 +117,7 @@ frappe.ui.form.Layout = Class.extend({ }); }, - replace_field: function(fieldname, df, render) { + replace_field: function (fieldname, df, render) { df.fieldname = fieldname; // change of fieldname is avoided if (this.fields_dict[fieldname] && this.fields_dict[fieldname].df) { const fieldobj = this.init_field(df, render); @@ -133,14 +133,14 @@ frappe.ui.form.Layout = Class.extend({ } }, - make_field: function(df, colspan, render) { + make_field: function (df, colspan, render) { !this.section && this.make_section(); !this.column && this.make_column(); const fieldobj = this.init_field(df, render); this.fields_list.push(fieldobj); this.fields_dict[df.fieldname] = fieldobj; - if(this.frm) { + if (this.frm) { fieldobj.perm = this.frm.perm; } @@ -149,31 +149,32 @@ frappe.ui.form.Layout = Class.extend({ fieldobj.section = this.section; }, - init_field: function(df, render = false) { + init_field: function (df, render = false) { const fieldobj = frappe.ui.form.make_control({ df: df, doctype: this.doctype, parent: this.column.wrapper.get(0), frm: this.frm, render_input: render, - doc: this.doc + doc: this.doc, + layout: this }); fieldobj.layout = this; return fieldobj; }, - make_page: function(df) { + make_page: function (df) { // eslint-disable-line no-unused-vars var me = this, head = $('').appendTo(this.wrapper); this.page = $('
    ').appendTo(this.wrapper); - this.fold_btn = head.find(".btn-fold").on("click", function() { + this.fold_btn = head.find(".btn-fold").on("click", function () { var page = $(this).parent().next(); - if(page.hasClass("hide")) { + if (page.hasClass("hide")) { $(this).removeClass("btn-fold").html(__("Hide details")); page.removeClass("hide"); frappe.utils.scroll_to($(this), true, 30); @@ -189,15 +190,15 @@ frappe.ui.form.Layout = Class.extend({ this.folded = true; }, - unfold: function() { + unfold: function () { this.fold_btn.trigger('click'); }, - make_section: function(df) { + make_section: function (df) { this.section = new frappe.ui.form.Section(this, df); // append to layout fields - if(df) { + if (df) { this.fields_dict[df.fieldname] = this.section; this.fields_list.push(this.section); } @@ -205,16 +206,16 @@ frappe.ui.form.Layout = Class.extend({ this.column = null; }, - make_column: function(df) { + make_column: function (df) { this.column = new frappe.ui.form.Column(this.section, df); - if(df && df.fieldname) { + if (df && df.fieldname) { this.fields_list.push(this.column); } }, - refresh: function(doc) { + refresh: function (doc) { var me = this; - if(doc) this.doc = doc; + if (doc) this.doc = doc; if (this.frm) { this.wrapper.find(".empty-form-alert").remove(); @@ -223,7 +224,7 @@ frappe.ui.form.Layout = Class.extend({ // NOTE this might seem redundant at first, but it needs to be executed when frm.refresh_fields is called me.attach_doc_and_docfields(true); - if(this.frm && this.frm.wrapper) { + if (this.frm && this.frm.wrapper) { $(this.frm.wrapper).trigger("refresh-fields"); } @@ -234,26 +235,26 @@ frappe.ui.form.Layout = Class.extend({ this.refresh_sections(); // collapse sections - if(this.frm) { + if (this.frm) { this.refresh_section_collapse(); } }, - refresh_sections: function() { + refresh_sections: function () { var cnt = 0; // hide invisible sections and set alternate background color - this.wrapper.find(".form-section:not(.hide-control)").each(function() { + this.wrapper.find(".form-section:not(.hide-control)").each(function () { var $this = $(this).removeClass("empty-section") .removeClass("visible-section") .removeClass("shaded-section"); - if(!$this.find(".frappe-control:not(.hide-control)").length + if (!$this.find(".frappe-control:not(.hide-control)").length && !$this.hasClass('form-dashboard')) { // nothing visible, hide the section $this.addClass("empty-section"); } else { $this.addClass("visible-section"); - if(cnt % 2) { + if (cnt % 2) { $this.addClass("shaded-section"); } cnt++; @@ -261,36 +262,36 @@ frappe.ui.form.Layout = Class.extend({ }); }, - refresh_fields: function(fields) { + refresh_fields: function (fields) { let fieldnames = fields.map((field) => { - if(field.fieldname) return field.fieldname; + if (field.fieldname) return field.fieldname; }); this.fields_list.map(fieldobj => { - if(fieldnames.includes(fieldobj.df.fieldname)) { + if (fieldnames.includes(fieldobj.df.fieldname)) { fieldobj.refresh(); - if(fieldobj.df["default"]) { + if (fieldobj.df["default"]) { fieldobj.set_input(fieldobj.df["default"]); } } }); }, - add_fields: function(fields) { + add_fields: function (fields) { this.render(fields); this.refresh_fields(fields); }, - refresh_section_collapse: function() { - if(!this.doc) return; + refresh_section_collapse: function () { + if (!this.doc) return; - for(var i=0; i=0;i--) { + for (var i = me.fields_list.length - 1; i >= 0; i--) { var f = me.fields_list[i]; f.guardian_has_value = true; if (f.df.depends_on) { @@ -473,12 +474,12 @@ frappe.ui.form.Layout = Class.extend({ // show / hide if (f.guardian_has_value) { - if(f.df.hidden_due_to_dependency) { + if (f.df.hidden_due_to_dependency) { f.df.hidden_due_to_dependency = false; f.refresh(); } } else { - if(!f.df.hidden_due_to_dependency) { + if (!f.df.hidden_due_to_dependency) { f.df.hidden_due_to_dependency = true; f.refresh(); } @@ -496,14 +497,14 @@ frappe.ui.form.Layout = Class.extend({ this.refresh_section_count(); }, - set_dependant_property: function(condition, fieldname, property) { + set_dependant_property: function (condition, fieldname, property) { let set_property = this.evaluate_depends_on_value(condition); let value = set_property ? 1 : 0; let form_obj; if (this.frm) { form_obj = this.frm; - } else if (this.is_dialog) { + } else if (this.is_dialog || this.doctype === 'Web Form') { form_obj = this; } if (form_obj) { @@ -514,7 +515,7 @@ frappe.ui.form.Layout = Class.extend({ } } }, - evaluate_depends_on_value: function(expression) { + evaluate_depends_on_value: function (expression) { var out = null; var doc = this.doc; @@ -528,27 +529,27 @@ frappe.ui.form.Layout = Class.extend({ var parent = this.frm ? this.frm.doc : this.doc || null; - if(typeof(expression) === 'boolean') { + if (typeof (expression) === 'boolean') { out = expression; - } else if(typeof(expression) === 'function') { + } else if (typeof (expression) === 'function') { out = expression(doc); - } else if(expression.substr(0,5)=='eval:') { + } else if (expression.substr(0, 5) == 'eval:') { try { out = eval(expression.substr(5)); - if(parent && parent.istable && expression.includes('is_submittable')) { + if (parent && parent.istable && expression.includes('is_submittable')) { out = true; } - } catch(e) { + } catch (e) { frappe.throw(__('Invalid "depends_on" expression')); } - } else if(expression.substr(0,3)=='fn:' && this.frm) { + } else if (expression.substr(0, 3) == 'fn:' && this.frm) { out = this.frm.script_manager.trigger(expression.substr(3), this.doctype, this.docname); } else { var value = doc[expression]; - if($.isArray(value)) { + if ($.isArray(value)) { out = !!value.length; } else { out = !!value; @@ -560,7 +561,7 @@ frappe.ui.form.Layout = Class.extend({ }); frappe.ui.form.Section = Class.extend({ - init: function(layout, df) { + init: function (layout, df) { var me = this; this.layout = layout; this.df = df || {}; @@ -580,8 +581,8 @@ frappe.ui.form.Section = Class.extend({ this.refresh(); }, - make: function() { - if(!this.layout.page) { + make: function () { + if (!this.layout.page) { this.layout.page = $('
    ').appendTo(this.layout.wrapper); } @@ -589,15 +590,15 @@ frappe.ui.form.Section = Class.extend({ .appendTo(this.layout.page); this.layout.sections.push(this); - if(this.df) { - if(this.df.label) { + if (this.df) { + if (this.df.label) { this.make_head(); } - if(this.df.description) { + if (this.df.description) { $('
    ' + __(this.df.description) + '
    ') .appendTo(this.wrapper); } - if(this.df.cssClass) { + if (this.df.cssClass) { this.wrapper.addClass(this.df.cssClass); } if (this.df.hide_border) { @@ -609,49 +610,49 @@ frappe.ui.form.Section = Class.extend({ this.body = $('
    ').appendTo(this.wrapper); }, - make_head: function() { + make_head: function () { var me = this; - if(!this.df.collapsible) { + if (!this.df.collapsible) { $('
    ' + __(this.df.label) + '
    ') .appendTo(this.wrapper); } else { this.head = $('').appendTo(this.wrapper); + + __(this.df.label) + '
    ').appendTo(this.wrapper); // show / hide based on status - this.collapse_link = this.head.on("click", function() { + this.collapse_link = this.head.on("click", function () { me.collapse(); }); this.indicator = this.head.find(".collapse-indicator"); } }, - refresh: function() { - if(!this.df) + refresh: function () { + if (!this.df) return; // hide if explictly hidden var hide = this.df.hidden || this.df.hidden_due_to_dependency; // hide if no perm - if(!hide && this.layout && this.layout.frm && !this.layout.frm.get_perm(this.df.permlevel || 0, "read")) { + if (!hide && this.layout && this.layout.frm && !this.layout.frm.get_perm(this.df.permlevel || 0, "read")) { hide = true; } this.wrapper.toggleClass("hide-control", !!hide); }, - collapse: function(hide) { + collapse: function (hide) { // unknown edge case if (!(this.head && this.body)) { return; } - if(hide===undefined) { + if (hide === undefined) { hide = !this.body.hasClass("hide"); } - if (this.df.fieldname==='_form_dashboard') { + if (this.df.fieldname === '_form_dashboard') { localStorage.setItem('collapseFormDashboard', hide ? 'yes' : 'no'); } @@ -662,7 +663,7 @@ frappe.ui.form.Section = Class.extend({ // refresh signature fields this.fields_list.forEach((f) => { - if (f.df.fieldtype=='Signature') { + if (f.df.fieldtype == 'Signature') { f.refresh(); } }); @@ -672,11 +673,11 @@ frappe.ui.form.Section = Class.extend({ return this.body.hasClass('hide'); }, - has_missing_mandatory: function() { + has_missing_mandatory: function () { var missing_mandatory = false; - for (var j=0, l=this.fields_list.length; j < l; j++) { + for (var j = 0, l = this.fields_list.length; j < l; j++) { var section_df = this.fields_list[j].df; - if (section_df.reqd && this.layout.doc[section_df.fieldname]==null) { + if (section_df.reqd && this.layout.doc[section_df.fieldname] == null) { missing_mandatory = true; break; } @@ -686,21 +687,21 @@ frappe.ui.form.Section = Class.extend({ }); frappe.ui.form.Column = Class.extend({ - init: function(section, df) { - if(!df) df = {}; + init: function (section, df) { + if (!df) df = {}; this.df = df; this.section = section; this.make(); this.resize_all_columns(); }, - make: function() { + make: function () { this.wrapper = $('
    \
    \
    \
    ').appendTo(this.section.body) .find("form") - .on("submit", function() { + .on("submit", function () { return false; }); @@ -709,7 +710,7 @@ frappe.ui.form.Column = Class.extend({ + '').appendTo(this.wrapper); } }, - resize_all_columns: function() { + resize_all_columns: function () { // distribute all columns equally var colspan = cint(12 / this.section.wrapper.find(".form-column").length); @@ -718,7 +719,7 @@ frappe.ui.form.Column = Class.extend({ .addClass("col-sm-" + colspan); }, - refresh: function() { + refresh: function () { this.section.refresh(); } }); diff --git a/frappe/public/js/frappe/web_form/webform_script.js b/frappe/public/js/frappe/web_form/webform_script.js index c3211de99f..6df526e7ac 100644 --- a/frappe/public/js/frappe/web_form/webform_script.js +++ b/frappe/public/js/frappe/web_form/webform_script.js @@ -85,6 +85,7 @@ frappe.ready(function() { function setup_fields(form_data) { form_data.web_form.web_form_fields.map(df => { + df.is_web_form = true; if (df.fieldtype === "Table") { df.get_data = () => { let data = []; @@ -99,14 +100,13 @@ frappe.ready(function() { if (field.fieldtype === "Link") { field.only_select = true; } + field.is_web_form = true; }); if (df.fieldtype === "Attach") { df.is_private = true; } - df.is_web_form = true; - delete df.parent; delete df.parentfield; delete df.parenttype; diff --git a/frappe/tests/ui_test_helpers.py b/frappe/tests/ui_test_helpers.py index ef572c6971..54a5a24acf 100644 --- a/frappe/tests/ui_test_helpers.py +++ b/frappe/tests/ui_test_helpers.py @@ -95,6 +95,24 @@ def create_doctype(name, fields): "name": name }).insert() +@frappe.whitelist() +def create_child_doctype(name, fields): + fields = frappe.parse_json(fields) + if frappe.db.exists('DocType', name): + return + frappe.get_doc({ + "doctype": "DocType", + "module": "Core", + "istable": 1, + "custom": 1, + "fields": fields, + "permissions": [{ + "role": "System Manager", + "read": 1 + }], + "name": name + }).insert() + @frappe.whitelist() def create_contact_records(): if frappe.db.get_all('Contact', {'first_name': 'Test Form Contact 1'}): From df72f80d25deefbb27dbaee58e123bca0c29bf43 Mon Sep 17 00:00:00 2001 From: Mathieu Brunot Date: Fri, 20 Nov 2020 00:25:43 +0100 Subject: [PATCH 117/273] Update frappe/public/js/frappe/list/list_sidebar.js Co-authored-by: Prssanna Desai --- frappe/public/js/frappe/list/list_sidebar.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/list/list_sidebar.js b/frappe/public/js/frappe/list/list_sidebar.js index 4d637602a3..0622e26dbd 100644 --- a/frappe/public/js/frappe/list/list_sidebar.js +++ b/frappe/public/js/frappe/list/list_sidebar.js @@ -94,7 +94,7 @@ frappe.views.ListSidebar = class ListSidebar { if (this.list_view.settings.get_coords_method || (this.list_view.meta.fields.find(i => i.fieldname === "latitude") && this.list_view.meta.fields.find(i => i.fieldname === "longitude")) || - (this.list_view.meta.fields.find(i => i.fieldname === 'location' && i.fieldtype == 'Geolocation'))) + (this.list_view.meta.fields.find(i => i.fieldname === 'location' && i.fieldtype == 'Geolocation'))) { this.sidebar.find('.list-link[data-view="Map"]').removeClass('hide'); show_list_link = true; } From a08692346994a0928550479b5dbf447cffe76d41 Mon Sep 17 00:00:00 2001 From: "mathieu.brunot" Date: Fri, 20 Nov 2020 00:37:19 +0100 Subject: [PATCH 118/273] chore: Remove useless prepare_data Signed-off-by: mathieu.brunot --- frappe/public/js/frappe/views/map/map_view.js | 7 ------- 1 file changed, 7 deletions(-) diff --git a/frappe/public/js/frappe/views/map/map_view.js b/frappe/public/js/frappe/views/map/map_view.js index 48e4ac8b3e..84e5b70ab6 100644 --- a/frappe/public/js/frappe/views/map/map_view.js +++ b/frappe/public/js/frappe/views/map/map_view.js @@ -20,13 +20,6 @@ frappe.views.MapView = class MapView extends frappe.views.ListView { this.get_coords(); } - prepare_data(data) { - super.prepare_data(data); - this.items = this.data.map(d => { - return d; - }); - } - render() { this.get_coords() .then(() => { From edbb26d73edb0873785502556c1634467cc9d4b1 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 20 Nov 2020 11:49:45 +0530 Subject: [PATCH 119/273] fix: display style removed from emails --- frappe/utils/html_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/utils/html_utils.py b/frappe/utils/html_utils.py index 302813645e..6fdd383eb9 100644 --- a/frappe/utils/html_utils.py +++ b/frappe/utils/html_utils.py @@ -34,7 +34,7 @@ def clean_email_html(html): 'margin', 'margin-top', 'margin-bottom', 'margin-left', 'margin-right', 'padding', 'padding-top', 'padding-bottom', 'padding-left', 'padding-right', 'font-size', 'font-weight', 'font-family', 'text-decoration', - 'line-height', 'text-align', 'vertical-align' + 'line-height', 'text-align', 'vertical-align', 'display' ], protocols=['cid', 'http', 'https', 'mailto', 'data'], strip=True, strip_comments=True) From 6bfe86d1272a7a9cc0585d17dc53e486654fec03 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Fri, 20 Nov 2020 17:40:20 +0100 Subject: [PATCH 120/273] feat: translate oauth confirmation dialog --- .../templates/includes/oauth_confirmation.html | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/frappe/templates/includes/oauth_confirmation.html b/frappe/templates/includes/oauth_confirmation.html index 73425af036..3fbbb75971 100644 --- a/frappe/templates/includes/oauth_confirmation.html +++ b/frappe/templates/includes/oauth_confirmation.html @@ -1,7 +1,7 @@ {% if not error %}
    -

    {{ client_id }} wants to access the following details from your account

    +

    {{ _("{} wants to access the following details from your account").format(client_id) }}

      @@ -11,10 +11,10 @@
    • - +
    • - +
    @@ -22,24 +22,24 @@ {% else %}
    -

    Authorization error for {{ client_id }}

    +

    {{ _("Authorization error for {}.").format(client_id) }}

    -

    An unexpected error occurred while authorizing {{ client_id }}.

    +

    {{ _("An unexpected error occurred while authorizing {}.").format(client_id) }}

    {{ error }}

    • - +
    From 5a52bc73effc615f2720303d7b0c7fe30621af8b Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Fri, 20 Nov 2020 17:40:56 +0100 Subject: [PATCH 121/273] fix: cookie value --- frappe/oauth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/oauth.py b/frappe/oauth.py index bf225ac118..09af5ad809 100644 --- a/frappe/oauth.py +++ b/frappe/oauth.py @@ -148,7 +148,7 @@ class OAuthWebRequestValidator(RequestValidator): print("Failed body authentication: Application %s does not exist".format(cid=request.client_id)) cookie_dict = get_cookie_dict_from_headers(request) - user_id = unquote(cookie_dict['user_id']) if 'user_id' in cookie_dict else "Guest" + user_id = unquote(cookie_dict.get('user_id').value) if 'user_id' in cookie_dict else "Guest" return frappe.session.user == user_id def authenticate_client_id(self, client_id, request, *args, **kwargs): From 9fb635828fe900d8e0eef7fb6ae6e4735586bc03 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Fri, 20 Nov 2020 17:44:35 +0100 Subject: [PATCH 122/273] refactor: oauth2 --- frappe/integrations/oauth2.py | 120 +++++++++++++++++----------------- 1 file changed, 60 insertions(+), 60 deletions(-) diff --git a/frappe/integrations/oauth2.py b/frappe/integrations/oauth2.py index c8dfc52c95..59d0278d79 100644 --- a/frappe/integrations/oauth2.py +++ b/frappe/integrations/oauth2.py @@ -1,41 +1,50 @@ from __future__ import unicode_literals -import frappe, json -from frappe.oauth import OAuthWebRequestValidator, WebApplicationServer + +import hashlib +import json +import jwt +from werkzeug.urls import url_fix from oauthlib.oauth2 import FatalClientError, OAuth2Error -from werkzeug import url_fix from six.moves.urllib.parse import quote, urlencode, urlparse -from frappe.integrations.doctype.oauth_provider_settings.oauth_provider_settings import get_oauth_settings + +import frappe from frappe import _ +from frappe.oauth import OAuthWebRequestValidator, WebApplicationServer +from frappe.integrations.doctype.oauth_provider_settings.oauth_provider_settings import get_oauth_settings def get_oauth_server(): if not getattr(frappe.local, 'oauth_server', None): oauth_validator = OAuthWebRequestValidator() - frappe.local.oauth_server = WebApplicationServer(oauth_validator) + frappe.local.oauth_server = WebApplicationServer(oauth_validator) return frappe.local.oauth_server -def get_urlparams_from_kwargs(param_kwargs): +def sanitize_kwargs(param_kwargs): arguments = param_kwargs - if arguments.get("data"): - arguments.pop("data") - if arguments.get("cmd"): - arguments.pop("cmd") + arguments.pop('data', None) + arguments.pop('cmd', None) - return urlencode(arguments) + return arguments @frappe.whitelist() def approve(*args, **kwargs): r = frappe.request - uri = url_fix(r.url.replace("+"," ")) - http_method = r.method - body = r.get_data() - headers = r.headers try: - scopes, frappe.flags.oauth_credentials = get_oauth_server().validate_authorization_request(uri, http_method, body, headers) + scopes, frappe.flags.oauth_credentials = get_oauth_server().validate_authorization_request( + r.url, + r.method, + r.get_data(), + r.headers + ) - headers, body, status = get_oauth_server().create_authorization_response(uri=frappe.flags.oauth_credentials['redirect_uri'], \ - body=body, headers=headers, scopes=scopes, credentials=frappe.flags.oauth_credentials) + headers, body, status = get_oauth_server().create_authorization_response( + uri=frappe.flags.oauth_credentials['redirect_uri'], + body=r.get_data(), + headers=r.headers, + scopes=scopes, + credentials=frappe.flags.oauth_credentials + ) uri = headers.get('Location', None) frappe.local.response["type"] = "redirect" @@ -47,34 +56,28 @@ def approve(*args, **kwargs): return e @frappe.whitelist(allow_guest=True) -def authorize(*args, **kwargs): - #Fetch provider URL from settings - oauth_settings = get_oauth_settings() - params = get_urlparams_from_kwargs(kwargs) - request_url = urlparse(frappe.request.url) - success_url = request_url.scheme + "://" + request_url.netloc + "/api/method/frappe.integrations.oauth2.approve?" + params +def authorize(**kwargs): + success_url = "/api/method/frappe.integrations.oauth2.approve?" + urlencode(sanitize_kwargs(kwargs)) failure_url = frappe.form_dict["redirect_uri"] + "?error=access_denied" - if frappe.session['user']=='Guest': + if frappe.session.user == 'Guest': #Force login, redirect to preauth again. frappe.local.response["type"] = "redirect" - frappe.local.response["location"] = "/login?redirect-to=/api/method/frappe.integrations.oauth2.authorize?" + quote(params.replace("+"," ")) - - elif frappe.session['user']!='Guest': + frappe.local.response["location"] = "/login?" + urlencode({'redirect-to': frappe.request.url}) + else: try: r = frappe.request - uri = url_fix(r.url) - http_method = r.method - body = r.get_data() - headers = r.headers - - scopes, frappe.flags.oauth_credentials = get_oauth_server().validate_authorization_request(uri, http_method, body, headers) + scopes, frappe.flags.oauth_credentials = get_oauth_server().validate_authorization_request( + r.url, + r.method, + r.get_data(), + r.headers + ) skip_auth = frappe.db.get_value("OAuth Client", frappe.flags.oauth_credentials['client_id'], "skip_authorization") unrevoked_tokens = frappe.get_all("OAuth Bearer Token", filters={"status":"Active"}) - if skip_auth or (oauth_settings["skip_authorization"] == "Auto" and len(unrevoked_tokens)): - + if skip_auth or (get_oauth_settings().skip_authorization == "Auto" and unrevoked_tokens): frappe.local.response["type"] = "redirect" frappe.local.response["location"] = success_url else: @@ -87,7 +90,6 @@ def authorize(*args, **kwargs): }) resp_html = frappe.render_template("templates/includes/oauth_confirmation.html", response_html_params) frappe.respond_as_web_page("Confirm Access", resp_html) - except FatalClientError as e: return e except OAuth2Error as e: @@ -95,20 +97,20 @@ def authorize(*args, **kwargs): @frappe.whitelist(allow_guest=True) def get_token(*args, **kwargs): - r = frappe.request - - uri = url_fix(r.url) - http_method = r.method - body = r.form - headers = r.headers - #Check whether frappe server URL is set frappe_server_url = frappe.db.get_value("Social Login Key", "frappe", "base_url") or None if not frappe_server_url: frappe.throw(_("Please set Base URL in Social Login Key for Frappe")) try: - headers, body, status = get_oauth_server().create_token_response(uri, http_method, body, headers, frappe.flags.oauth_credentials) + r = frappe.request + headers, body, status = get_oauth_server().create_token_response( + r.url, + r.method, + r.form, + r.headers, + frappe.flags.oauth_credentials + ) out = frappe._dict(json.loads(body)) if not out.error and "openid" in out.scope: token_user = frappe.db.get_value("OAuth Bearer Token", out.access_token, "user") @@ -116,7 +118,7 @@ def get_token(*args, **kwargs): client_secret = frappe.db.get_value("OAuth Client", token_client, "client_secret") if token_user in ["Guest", "Administrator"]: frappe.throw(_("Logged in as Guest or Administrator")) - import hashlib + id_token_header = { "typ":"jwt", "alg":"HS256" @@ -128,9 +130,10 @@ def get_token(*args, **kwargs): "iss": frappe_server_url, "at_hash": frappe.oauth.calculate_at_hash(out.access_token, hashlib.sha256) } - import jwt + id_token_encoded = jwt.encode(id_token, client_secret, algorithm='HS256', headers=id_token_header) - out.update({"id_token":str(id_token_encoded)}) + out.update({"id_token": str(id_token_encoded)}) + frappe.local.response = out except FatalClientError as e: @@ -140,12 +143,12 @@ def get_token(*args, **kwargs): @frappe.whitelist(allow_guest=True) def revoke_token(*args, **kwargs): r = frappe.request - uri = url_fix(r.url) - http_method = r.method - body = r.form - headers = r.headers - - headers, body, status = get_oauth_server().create_revocation_response(uri, headers=headers, body=body, http_method=http_method) + headers, body, status = get_oauth_server().create_revocation_response( + r.url, + headers=r.headers, + body=r.form, + http_method=r.method + ) frappe.local.response['http_status_code'] = status if status == 200: @@ -174,15 +177,12 @@ def openid_profile(*args, **kwargs): "email": name, "picture": picture }) - + frappe.local.response = user_profile def validate_url(url_string): try: result = urlparse(url_string) - if result.scheme and result.scheme in ["http", "https", "ftp", "ftps"]: - return True - else: - return False + return result.scheme and result.scheme in ["http", "https", "ftp", "ftps"] except: - return False \ No newline at end of file + return False From bea4f6e11b5058c7aa457141f2fd0aa59b27615b Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Fri, 20 Nov 2020 18:03:17 +0100 Subject: [PATCH 123/273] fix: remove unused import --- frappe/integrations/oauth2.py | 1 - 1 file changed, 1 deletion(-) diff --git a/frappe/integrations/oauth2.py b/frappe/integrations/oauth2.py index 59d0278d79..7570a50127 100644 --- a/frappe/integrations/oauth2.py +++ b/frappe/integrations/oauth2.py @@ -3,7 +3,6 @@ from __future__ import unicode_literals import hashlib import json import jwt -from werkzeug.urls import url_fix from oauthlib.oauth2 import FatalClientError, OAuth2Error from six.moves.urllib.parse import quote, urlencode, urlparse From e93a38f912d8fb10f4610173d218508b7a4459b7 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Fri, 20 Nov 2020 19:02:33 +0100 Subject: [PATCH 124/273] refactor: move encode_params from test to oauth2.py --- frappe/integrations/oauth2.py | 17 ++++++++++++++--- frappe/tests/test_oauth20.py | 11 +---------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/frappe/integrations/oauth2.py b/frappe/integrations/oauth2.py index 7570a50127..a750c8328c 100644 --- a/frappe/integrations/oauth2.py +++ b/frappe/integrations/oauth2.py @@ -2,9 +2,10 @@ from __future__ import unicode_literals import hashlib import json +from urllib.parse import quote, urlencode, urlparse + import jwt from oauthlib.oauth2 import FatalClientError, OAuth2Error -from six.moves.urllib.parse import quote, urlencode, urlparse import frappe from frappe import _ @@ -56,13 +57,13 @@ def approve(*args, **kwargs): @frappe.whitelist(allow_guest=True) def authorize(**kwargs): - success_url = "/api/method/frappe.integrations.oauth2.approve?" + urlencode(sanitize_kwargs(kwargs)) + success_url = "/api/method/frappe.integrations.oauth2.approve?" + encode_params(sanitize_kwargs(kwargs)) failure_url = frappe.form_dict["redirect_uri"] + "?error=access_denied" if frappe.session.user == 'Guest': #Force login, redirect to preauth again. frappe.local.response["type"] = "redirect" - frappe.local.response["location"] = "/login?" + urlencode({'redirect-to': frappe.request.url}) + frappe.local.response["location"] = "/login?" + encode_params({'redirect-to': frappe.request.url}) else: try: r = frappe.request @@ -185,3 +186,13 @@ def validate_url(url_string): return result.scheme and result.scheme in ["http", "https", "ftp", "ftps"] except: return False + +def encode_params(params): + """ + Encode a dict of params into a query string. + + Use `quote_via=urllib.parse.quote` so that whitespaces will be encoded as + `%20` instead of as `+`. This is needed because oauthlib cannot handle `+` + as a whitespace. + """ + return urlencode(params, quote_via=quote) diff --git a/frappe/tests/test_oauth20.py b/frappe/tests/test_oauth20.py index f4ecc8a68d..e2213145b7 100644 --- a/frappe/tests/test_oauth20.py +++ b/frappe/tests/test_oauth20.py @@ -6,6 +6,7 @@ import unittest, frappe, requests, time from frappe.test_runner import make_test_records from six.moves.urllib.parse import urlparse, parse_qs, urljoin from urllib.parse import urlencode, quote +from frappe.integrations.oauth2 import encode_params class TestOAuth20(unittest.TestCase): @@ -232,13 +233,3 @@ def login(session): def get_full_url(endpoint): """Turn '/endpoint' into 'http://127.0.0.1:8000/endpoint'.""" return urljoin(frappe.utils.get_url(), endpoint) - -def encode_params(params): - """ - Encode a dict of params into a query string. - - Use `quote_via=urllib.parse.quote` so that whitespaces will be encoded as - `%20` instead of as `+`. This is needed because oauthlib cannot handle `+` - as a whitespace. - """ - return urlencode(params, quote_via=quote) From 0312b9d67cd9ab45cf4f7e342e100dbb87ad1d26 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 17 Nov 2020 19:20:44 +0530 Subject: [PATCH 125/273] feat: check if auto_repeat field is already present --- .../doctype/customize_form/customize_form.py | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py index cf674082ab..67b4f05856 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -76,17 +76,20 @@ class CustomizeForm(Document): def create_auto_repeat_custom_field_if_requried(self, meta): if self.allow_auto_repeat: - if not frappe.db.exists('Custom Field', {'fieldname': 'auto_repeat', 'dt': self.doc_type}) and \ - not frappe.db.exists('DocField', {'fieldname': 'auto_repeat', 'parent': self.name}): - insert_after = self.fields[len(self.fields) - 1].fieldname - df = dict( - fieldname='auto_repeat', - label='Auto Repeat', - fieldtype='Link', - options='Auto Repeat', - insert_after=insert_after, - read_only=1, no_copy=1, print_hide=1) - create_custom_field(self.doc_type, df) + all_fields = [df.fieldname for df in meta.fields] + + if "auto_repeat" in all_fields: + return + + insert_after = self.fields[len(self.fields) - 1].fieldname + create_custom_field(self.doc_type, dict( + fieldname='auto_repeat', + label='Auto Repeat', + fieldtype='Link', + options='Auto Repeat', + insert_after=insert_after, + read_only=1, no_copy=1, print_hide=1 + )) def get_name_translation(self): From 019fca9ef773e006a57069b8137a1043593da174 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 23 Nov 2020 11:33:16 +0530 Subject: [PATCH 126/273] fix: typo in function name --- frappe/custom/doctype/customize_form/customize_form.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py index 67b4f05856..60ae65091d 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -39,7 +39,7 @@ class CustomizeForm(Document): translation = self.get_name_translation() self.label = translation.translated_text if translation else '' - self.create_auto_repeat_custom_field_if_requried(meta) + self.create_auto_repeat_custom_field_if_required(meta) # NOTE doc (self) is sent to clientside by run_method @@ -74,7 +74,7 @@ class CustomizeForm(Document): for d in meta.get(fieldname): self.append(fieldname, d) - def create_auto_repeat_custom_field_if_requried(self, meta): + def create_auto_repeat_custom_field_if_required(self, meta): if self.allow_auto_repeat: all_fields = [df.fieldname for df in meta.fields] From 44413e9ba6010a88b25114c67fb12275f0de803f Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 23 Nov 2020 11:34:49 +0530 Subject: [PATCH 127/273] chore: add docstrings --- frappe/custom/doctype/customize_form/customize_form.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py index 60ae65091d..82513783c7 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -75,6 +75,9 @@ class CustomizeForm(Document): self.append(fieldname, d) def create_auto_repeat_custom_field_if_required(self, meta): + ''' + Create auto repeat custom field if it's not already present + ''' if self.allow_auto_repeat: all_fields = [df.fieldname for df in meta.fields] From 456030760dba7b18753e42f34df383c81c421f0e Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 23 Nov 2020 19:11:48 +0530 Subject: [PATCH 128/273] chore: Allow custom freeze message in Open Mapped Doc --- frappe/public/js/frappe/model/create_new.js | 1 + 1 file changed, 1 insertion(+) diff --git a/frappe/public/js/frappe/model/create_new.js b/frappe/public/js/frappe/model/create_new.js index 7be7fc5baa..f7a5982b96 100644 --- a/frappe/public/js/frappe/model/create_new.js +++ b/frappe/public/js/frappe/model/create_new.js @@ -306,6 +306,7 @@ $.extend(frappe.model, { selected_children: opts.frm ? opts.frm.get_selected() : null }, freeze: true, + freeze_message: opts.freeze_message || '', callback: function(r) { if(!r.exc) { frappe.model.sync(r.message); From 0866526d11bbe7c9afa102cbaa3665b4423af0ca Mon Sep 17 00:00:00 2001 From: Anurag Mishra <32095923+Anurag810@users.noreply.github.com> Date: Tue, 24 Nov 2020 09:35:18 +0530 Subject: [PATCH 129/273] fix(Document Follow): Skip Email Account and Email Domain (#11973) --- frappe/desk/form/document_follow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/desk/form/document_follow.py b/frappe/desk/form/document_follow.py index 3aa3a4fa88..66164948f2 100644 --- a/frappe/desk/form/document_follow.py +++ b/frappe/desk/form/document_follow.py @@ -21,7 +21,7 @@ def follow_document(doctype, doc_name, user, force=False): avoided for some doctype follow only if track changes are set to 1 ''' - if (doctype in ("Communication", "ToDo", "Email Unsubscribe", "File", "Comment") + if (doctype in ("Communication", "ToDo", "Email Unsubscribe", "File", "Comment", "Email Account", "Email Domain") or doctype in log_types): return From f71a8e0afcffae02dee34fd20642ee439c6b6017 Mon Sep 17 00:00:00 2001 From: everyx Date: Tue, 24 Nov 2020 14:36:53 +0800 Subject: [PATCH 130/273] fix: `PRIMARY KEY must be NOT NULL` error when install with MySQL 5.7+ --- frappe/database/mariadb/framework_mariadb.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/database/mariadb/framework_mariadb.sql b/frappe/database/mariadb/framework_mariadb.sql index 15b0bed699..a52efd01e3 100644 --- a/frappe/database/mariadb/framework_mariadb.sql +++ b/frappe/database/mariadb/framework_mariadb.sql @@ -233,7 +233,7 @@ CREATE TABLE `tabDocType` ( DROP TABLE IF EXISTS `tabSeries`; CREATE TABLE `tabSeries` ( - `name` varchar(100) DEFAULT NULL, + `name` varchar(100), `current` int(10) NOT NULL DEFAULT 0, PRIMARY KEY(`name`) ) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci; From 200211e11ef40db91b6bd5fe83203792d779d4ce Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 24 Nov 2020 14:27:10 +0530 Subject: [PATCH 131/273] feat: use modified by or owner to send notification from --- frappe/email/doctype/notification/notification.py | 1 + 1 file changed, 1 insertion(+) diff --git a/frappe/email/doctype/notification/notification.py b/frappe/email/doctype/notification/notification.py index 75281d427e..2ea7a3785e 100644 --- a/frappe/email/doctype/notification/notification.py +++ b/frappe/email/doctype/notification/notification.py @@ -181,6 +181,7 @@ def get_context(context): 'document_type': doc.doctype, 'document_name': doc.name, 'subject': subject, + 'from_user': doc.modified_by or doc.owner, 'email_content': frappe.render_template(self.message, context), 'attached_file': attachments and json.dumps(attachments[0]) } From 856ff501e4399b4711de4b16f6e4e6afb712a7a7 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 24 Nov 2020 14:27:34 +0530 Subject: [PATCH 132/273] feat: show recipients section for System Notifications --- frappe/email/doctype/notification/notification.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/email/doctype/notification/notification.json b/frappe/email/doctype/notification/notification.json index 73a84e1d3e..c1c877efd4 100644 --- a/frappe/email/doctype/notification/notification.json +++ b/frappe/email/doctype/notification/notification.json @@ -207,7 +207,7 @@ "label": "Value To Be Set" }, { - "depends_on": "eval:in_list(['Email', 'SMS'], doc.channel)", + "depends_on": "eval:doc.channel !=\"Slack\"", "fieldname": "column_break_5", "fieldtype": "Section Break", "label": "Recipients" @@ -281,7 +281,7 @@ "icon": "fa fa-envelope", "index_web_pages_for_search": 1, "links": [], - "modified": "2020-10-28 11:04:54.955567", + "modified": "2020-11-24 14:25:43.245677", "modified_by": "Administrator", "module": "Email", "name": "Notification", From 0d6dafa3f589a224feafef1dedb5a6c09104a632 Mon Sep 17 00:00:00 2001 From: Raffael Meyer <14891507+barredterra@users.noreply.github.com> Date: Tue, 24 Nov 2020 11:36:23 +0100 Subject: [PATCH 133/273] fix: allow other github links in same PR --- .github/helper/documentation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/helper/documentation.py b/.github/helper/documentation.py index 3fc14ba61b..08d1d1aa9c 100644 --- a/.github/helper/documentation.py +++ b/.github/helper/documentation.py @@ -21,8 +21,8 @@ def docs_link_exists(body): if word.startswith('http') and uri_validator(word): parsed_url = urlparse(word) if parsed_url.netloc == "github.com": - _, org, repo, _type, ref = parsed_url.path.split('/') - if org == "frappe" and repo in docs_repos: + parts = parsed_url.path.split('/') + if len(parts) == 5 and parts[1] == "frappe" and parts[2] in docs_repos: return True From ff059156d8757bc89c945256116479bd6308db11 Mon Sep 17 00:00:00 2001 From: bhavesh95863 <34086262+bhavesh95863@users.noreply.github.com> Date: Tue, 24 Nov 2020 22:28:11 +0530 Subject: [PATCH 134/273] feat: translate kanboard board title --- frappe/public/js/frappe/views/kanban/kanban_view.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/views/kanban/kanban_view.js b/frappe/public/js/frappe/views/kanban/kanban_view.js index e4d0659965..48a45fddd6 100644 --- a/frappe/public/js/frappe/views/kanban/kanban_view.js +++ b/frappe/public/js/frappe/views/kanban/kanban_view.js @@ -30,7 +30,7 @@ frappe.views.KanbanView = class KanbanView extends frappe.views.ListView { return super.setup_defaults() .then(() => { this.board_name = frappe.get_route()[3]; - this.page_title = this.board_name; + this.page_title = __(this.board_name); this.card_meta = this.get_card_meta(); this.menu_items.push({ From c903286465c95e966a11b3a3e0b209f3f7997a20 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Tue, 24 Nov 2020 23:28:00 +0530 Subject: [PATCH 135/273] fix: Not able to save Domain Settings (#11984) --- frappe/core/doctype/domain_settings/domain_settings.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frappe/core/doctype/domain_settings/domain_settings.js b/frappe/core/doctype/domain_settings/domain_settings.js index 1428727993..7178cb4cd6 100644 --- a/frappe/core/doctype/domain_settings/domain_settings.js +++ b/frappe/core/doctype/domain_settings/domain_settings.js @@ -18,6 +18,9 @@ frappe.ui.form.on('Domain Settings', { checked: active_domains.includes(domain) }; }); + }, + on_change: () => { + frm.dirty(); } }, render_input: true From 1154cc931fafd454a98ca8c15ed21fbdd258757c Mon Sep 17 00:00:00 2001 From: Snyk bot Date: Wed, 25 Nov 2020 07:29:34 +0200 Subject: [PATCH 136/273] fix(Snyk): Security upgrade snyk from 1.398.1 to 1.425.4 (#11990) The following vulnerabilities are fixed with an upgrade: - https://snyk.io/vuln/SNYK-JS-Y18N-1021887 --- package.json | 2 +- yarn.lock | 349 +++++++++++++++++++++++++++++---------------------- 2 files changed, 202 insertions(+), 149 deletions(-) diff --git a/package.json b/package.json index c9eb9c0e56..91d204bec5 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "qz-tray": "^2.0.8", "redis": "^2.8.0", "showdown": "^1.9.1", - "snyk": "^1.398.1", + "snyk": "^1.425.4", "socket.io": "^2.3.0", "superagent": "^3.8.2", "touch": "^3.1.0", diff --git a/yarn.lock b/yarn.lock index 15a6321ae2..459f4139b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -93,10 +93,21 @@ source-map-support "^0.5.19" tslib "^1.13.0" -"@snyk/docker-registry-v2-client@^1.13.5": - version "1.13.5" - resolved "https://registry.yarnpkg.com/@snyk/docker-registry-v2-client/-/docker-registry-v2-client-1.13.5.tgz#8d862f0c53d4a9a25db09cd48b4cd44aa8e385c9" - integrity sha512-lgJiC071abCpFVLp47OnykU8MMrhdQe386Wt6QaDmjI0s2DQn/S58NfdLrPU7s6l4zoGT7UwRW9+7paozRgFTA== +"@snyk/dep-graph@^1.19.5": + version "1.20.0" + resolved "https://registry.yarnpkg.com/@snyk/dep-graph/-/dep-graph-1.20.0.tgz#258ae85f8a066dc63af4444cfca8b8d092b94bc0" + integrity sha512-/TOzXGh+JFgAu8pWdo1oLFKDNfFk99TnSQG2lbEu+vKLI2ZrGAk9oGO0geNogAN7Ib4EDQOEhgb7YwqwL7aA7w== + dependencies: + graphlib "^2.1.8" + lodash.isequal "^4.5.0" + object-hash "^2.0.3" + semver "^6.0.0" + tslib "^1.13.0" + +"@snyk/docker-registry-v2-client@1.13.9": + version "1.13.9" + resolved "https://registry.yarnpkg.com/@snyk/docker-registry-v2-client/-/docker-registry-v2-client-1.13.9.tgz#54c2e3071de58fc6fc12c5fef5eaeae174ecda12" + integrity sha512-DIFLEhr8m1GrAwsLGInJmpcQMacjuhf3jcbpQTR+LeMvZA9IuKq+B7kqw2O2FzMiHMZmUb5z+tV+BR7+IUHkFQ== dependencies: needle "^2.5.0" parse-link-header "^1.0.1" @@ -107,10 +118,10 @@ resolved "https://registry.yarnpkg.com/@snyk/gemfile/-/gemfile-1.2.0.tgz#919857944973cce74c650e5428aaf11bcd5c0457" integrity sha512-nI7ELxukf7pT4/VraL4iabtNNMz8mUo7EXlqCFld8O5z6mIMLX9llps24iPpaIZOwArkY3FWA+4t+ixyvtTSIA== -"@snyk/java-call-graph-builder@1.13.2": - version "1.13.2" - resolved "https://registry.yarnpkg.com/@snyk/java-call-graph-builder/-/java-call-graph-builder-1.13.2.tgz#6e4a9495d5c47bbab9bc69e066d4646473781b67" - integrity sha512-YN3a93ttscqFQRUeThrxa7i2SJkFPfYn0VpFqdPB6mIJz2fRVLxUkMtlCbG0aSEUvWiLnGVHN0IYxwWEzhq11w== +"@snyk/java-call-graph-builder@1.16.2": + version "1.16.2" + resolved "https://registry.yarnpkg.com/@snyk/java-call-graph-builder/-/java-call-graph-builder-1.16.2.tgz#a9f9a34107759cf2be847a114a759e347cef44e8" + integrity sha512-tJF+dY/wTfexwYuCgFB3RpWl4RGcf2H9RT9yurkTVi5wwKfvcNwZMUMwSlTDEFOqwmAsJ7e0uNVRlkPQHekCcQ== dependencies: ci-info "^2.0.0" debug "^4.1.1" @@ -119,11 +130,29 @@ jszip "^3.2.2" needle "^2.3.3" progress "^2.0.3" - snyk-config "^3.0.0" + snyk-config "^4.0.0-rc.2" source-map-support "^0.5.7" temp-dir "^2.0.0" tslib "^1.9.3" +"@snyk/java-call-graph-builder@1.16.5": + version "1.16.5" + resolved "https://registry.yarnpkg.com/@snyk/java-call-graph-builder/-/java-call-graph-builder-1.16.5.tgz#e57302cc6dc93f1adff7abe1e5eecff26d8a41f4" + integrity sha512-6H4hkq/qYljJoH1QnZsTRPMqp9Kt5AOEZYGJAeSHkhJdfUYSLtqwN4WsU6yVR3vWAaDQ8Lllp3m6EL7nstMPZA== + dependencies: + ci-info "^2.0.0" + debug "^4.1.1" + glob "^7.1.6" + graphlib "^2.1.8" + jszip "^3.2.2" + needle "^2.3.3" + progress "^2.0.3" + snyk-config "^4.0.0-rc.2" + source-map-support "^0.5.7" + temp-dir "^2.0.0" + tmp "^0.2.1" + tslib "^1.9.3" + "@snyk/rpm-parser@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@snyk/rpm-parser/-/rpm-parser-2.0.0.tgz#4ded7fa4b0a8efca7699359e4ca7a79bfbe38bc1" @@ -142,12 +171,12 @@ source-map-support "^0.5.7" tslib "^2.0.0" -"@snyk/snyk-docker-pull@^3.2.0": - version "3.2.0" - resolved "https://registry.yarnpkg.com/@snyk/snyk-docker-pull/-/snyk-docker-pull-3.2.0.tgz#07c47b8be2d899d51d720099a73a0d89effe5d99" - integrity sha512-uWKtjh29I/d0mfmfBN7w6RwwNBQxQVKrauF5ND/gqb0PVsKV22GIpkI+viWjI7KNKso6/B0tMmsv7TX2tsNcLQ== +"@snyk/snyk-docker-pull@3.2.3": + version "3.2.3" + resolved "https://registry.yarnpkg.com/@snyk/snyk-docker-pull/-/snyk-docker-pull-3.2.3.tgz#9743ea624098c7abd0f95c438c76067530494f4b" + integrity sha512-hiFiSmWGLc2tOI7FfgIhVdFzO2f69im8O6p3OV4xEZ/Ss1l58vwtqudItoswsk7wj/azRlgfBW8wGu2MjoudQg== dependencies: - "@snyk/docker-registry-v2-client" "^1.13.5" + "@snyk/docker-registry-v2-client" "1.13.9" child-process "^1.0.2" tar-stream "^2.1.2" tmp "^0.1.0" @@ -545,10 +574,10 @@ async-limiter@~1.0.0: resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.0.tgz#78faed8c3d074ab81f22b4e985d79e8738f720f8" integrity sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg== -async@^1.4.0: - version "1.5.2" - resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" - integrity sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo= +async@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.0.tgz#b3a2685c5ebb641d3de02d161002c60fc9f85720" + integrity sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw== asynckit@^0.4.0: version "0.4.0" @@ -757,6 +786,13 @@ braces@^2.3.1: split-string "^3.0.2" to-regex "^3.0.1" +braces@^3.0.1: + version "3.0.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" + integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== + dependencies: + fill-range "^7.0.1" + browserify-zlib@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.1.4.tgz#bb35f8a519f600e0fa6b8485241c979d0141fb2d" @@ -896,7 +932,7 @@ camelcase-keys@^2.0.0: camelcase "^2.0.0" map-obj "^1.0.0" -camelcase@^2.0.0, camelcase@^2.0.1: +camelcase@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f" integrity sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8= @@ -1021,15 +1057,6 @@ cli-width@^3.0.0: resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-3.0.0.tgz#a2f48437a2caa9a22436e794bf071ec9e61cedf6" integrity sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw== -cliui@^3.0.3: - version "3.2.0" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-3.2.0.tgz#120601537a916d29940f934da3b48d585a39213d" - integrity sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0= - dependencies: - string-width "^1.0.1" - strip-ansi "^3.0.1" - wrap-ansi "^2.0.0" - cliui@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5" @@ -1507,7 +1534,14 @@ debug@^3.1.0, debug@^3.2.5, debug@^3.2.6: dependencies: ms "^2.1.1" -decamelize@^1.1.1, decamelize@^1.1.2, decamelize@^1.2.0: +debug@^4.2.0: + version "4.3.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee" + integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ== + dependencies: + ms "2.1.2" + +decamelize@^1.1.2, decamelize@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= @@ -1751,6 +1785,13 @@ electron-to-chromium@^1.3.523: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.551.tgz#a94d243a4ca90705189bd4a5eca4e0f56b745a4f" integrity sha512-11qcm2xvf2kqeFO5EIejaBx5cKXsW1quAyv3VctCMYwofnyVZLs97y6LCekss3/ghQpr7PYkSO3uId5FmxZsdw== +elfy@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/elfy/-/elfy-1.0.0.tgz#7a1c86af7d41e0a568cbb4a3fa5b685648d9efcd" + integrity sha512-4Kp3AA94jC085IJox+qnvrZ3PudqTi4gQNvIoTZfJJ9IqkRuCoqP60vCVYlIg00c5aYusi5Wjh2bf0cHYt+6gQ== + dependencies: + endian-reader "^0.3.0" + email-validator@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/email-validator/-/email-validator-2.0.4.tgz#b8dfaa5d0dae28f1b03c95881d904d4e40bfe7ed" @@ -1783,6 +1824,11 @@ end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^1.4.1: dependencies: once "^1.4.0" +endian-reader@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/endian-reader/-/endian-reader-0.3.0.tgz#84eca436b80aed0d0639c47291338b932efe50a0" + integrity sha1-hOykNrgK7Q0GOcRykTOLky7+UKA= + engine.io-client@~3.4.0: version "3.4.0" resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.4.0.tgz#82a642b42862a9b3f7a188f41776b2deab643700" @@ -2177,6 +2223,13 @@ fill-range@^4.0.0: repeat-string "^1.6.1" to-regex-range "^2.1.0" +fill-range@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" + integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== + dependencies: + to-regex-range "^5.0.1" + finalhandler@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d" @@ -2678,6 +2731,13 @@ hosted-git-info@^3.0.4: dependencies: lru-cache "^6.0.0" +hosted-git-info@^3.0.7: + version "3.0.7" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-3.0.7.tgz#a30727385ea85acfcee94e0aad9e368c792e036c" + integrity sha512-fWqc0IcuXs+BmE9orLDyVykAG9GJtGLGuZAAqgcckPgv5xad4AcXGIv8galtQvlwutxSlaMcdw7BUtq2EIvqCQ== + dependencies: + lru-cache "^6.0.0" + hsl-regex@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/hsl-regex/-/hsl-regex-1.0.0.tgz#d49330c789ed819e276a4c0d272dffa30b18fe6e" @@ -2857,7 +2917,7 @@ inherits@2.0.3: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= -ini@^1.3.0, ini@^1.3.4, ini@^1.3.5, ini@~1.3.0: +ini@^1.3.4, ini@^1.3.5, ini@~1.3.0: version "1.3.5" resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== @@ -2881,11 +2941,6 @@ inquirer@^7.3.3: strip-ansi "^6.0.0" through "^2.3.6" -invert-kv@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" - integrity sha1-EEqOSqym09jNFXqO+L+rLXo//bY= - iota-array@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/iota-array/-/iota-array-1.0.0.tgz#81ef57fe5d05814cd58c2483632a99c30a0e8087" @@ -3122,6 +3177,11 @@ is-number@^3.0.0: dependencies: kind-of "^3.0.2" +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + is-obj@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" @@ -3447,13 +3507,6 @@ latest-version@^5.0.0: dependencies: package-json "^6.3.0" -lcid@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835" - integrity sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU= - dependencies: - invert-kv "^1.0.0" - less@^3.11.1: version "3.11.1" resolved "https://registry.yarnpkg.com/less/-/less-3.11.1.tgz#c6bf08e39e02404fe6b307a3dfffafdc55bd36e2" @@ -3750,6 +3803,14 @@ methods@^1.1.1, methods@~1.1.2: resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= +micromatch@4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.2.tgz#4fcb0999bf9fbc2fcbdd212f6d629b9a56c39259" + integrity sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q== + dependencies: + braces "^3.0.1" + picomatch "^2.0.5" + micromatch@^3.1.10: version "3.1.10" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" @@ -3896,7 +3957,7 @@ ms@2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== -ms@^2.1.1: +ms@2.1.2, ms@^2.1.1: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== @@ -3928,16 +3989,6 @@ nanomatch@^1.2.9: snapdragon "^0.8.1" to-regex "^3.0.1" -nconf@^0.10.0: - version "0.10.0" - resolved "https://registry.yarnpkg.com/nconf/-/nconf-0.10.0.tgz#da1285ee95d0a922ca6cee75adcf861f48205ad2" - integrity sha512-fKiXMQrpP7CYWJQzKkPPx9hPgmq+YLDyxcG9N8RpiE9FoCkCbzD0NyW0YhE3xn3Aupe7nnDeIx4PFzYehpHT9Q== - dependencies: - async "^1.4.0" - ini "^1.3.0" - secure-keys "^1.0.0" - yargs "^3.19.0" - ndarray-linear-interpolate@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/ndarray-linear-interpolate/-/ndarray-linear-interpolate-1.0.0.tgz#78bc92b85b9abc15b6e67ee65828f9e2137ae72b" @@ -4268,13 +4319,6 @@ os-homedir@^1.0.0, os-homedir@^1.0.1: resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M= -os-locale@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-1.4.0.tgz#20f9f17ae29ed345e8bde583b13d2009803c14d9" - integrity sha1-IPnxeuKe00XoveWDsT0gCYA8FNk= - dependencies: - lcid "^1.0.0" - os-name@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/os-name/-/os-name-3.1.0.tgz#dec19d966296e1cd62d701a5a66ee1ddeae70801" @@ -4508,6 +4552,11 @@ performance-now@^2.1.0: resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= +picomatch@^2.0.5: + version "2.2.2" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad" + integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg== + pify@^2.0.0: version "2.3.0" resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" @@ -5687,11 +5736,6 @@ scss-tokenizer@^0.2.3: js-base64 "^2.1.8" source-map "^0.4.2" -secure-keys@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/secure-keys/-/secure-keys-1.0.0.tgz#f0c82d98a3b139a8776a8808050b824431087fca" - integrity sha1-8MgtmKOxOah3aogIBQuCRDEIf8o= - semver-diff@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-3.1.1.tgz#05f77ce59f325e00e2706afd67bb506ddb1ca32b" @@ -5862,40 +5906,55 @@ snapdragon@^0.8.1: source-map-resolve "^0.5.0" use "^3.1.0" -snyk-config@3.1.1, snyk-config@^3.0.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/snyk-config/-/snyk-config-3.1.1.tgz#a511ef8bf769545f0564e09d382b5ea3aacb9c6a" - integrity sha512-wwrMIEDozfLJ8LmakCsCC1FQ0siIX5icCQPCbUKKgRbeVsZ27NjPJs37BpTXX4rcHkaWpe8TbH3yOtp23qmszg== +snyk-config@4.0.0-rc.2: + version "4.0.0-rc.2" + resolved "https://registry.yarnpkg.com/snyk-config/-/snyk-config-4.0.0-rc.2.tgz#c6c94afe733e9063df546cd71a7adf6957135594" + integrity sha512-HIXpMCRp5IdQDFH/CY6WqOUt5X5Ec55KC9dFVjlMLe/2zeqsImJn1vbjpE5uBoLYIdYi1SteTqtsJhyJZWRK8g== dependencies: + async "^3.2.0" debug "^4.1.1" lodash.merge "^4.6.2" - nconf "^0.10.0" + minimist "^1.2.5" -snyk-cpp-plugin@1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/snyk-cpp-plugin/-/snyk-cpp-plugin-1.5.0.tgz#2ec2068fdcf5e579eb7d9b9eed8bb984fd00a925" - integrity sha512-nBZ0cBmpT4RVJUFzYydQJOxwjcdXk7NtRJE1UIIOafQa2FcvIl3GBezfrCJ6pu61svOAf5r8Qi/likx6F15K1A== +snyk-config@^4.0.0-rc.2: + version "4.0.0" + resolved "https://registry.yarnpkg.com/snyk-config/-/snyk-config-4.0.0.tgz#21d459f19087991246cc07a7ffb4501dce6f4159" + integrity sha512-E6jNe0oUjjzVASWBOAc/mA23DhbzABDF9MI6UZvl0gylh2NSXSXw2/LjlqMNOKL2c1qkbSkzLOdIX5XACoLCAQ== + dependencies: + async "^3.2.0" + debug "^4.1.1" + lodash.merge "^4.6.2" + minimist "^1.2.5" + +snyk-cpp-plugin@2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/snyk-cpp-plugin/-/snyk-cpp-plugin-2.2.1.tgz#55891511a43a6448e5a7c836a94f66f70fa705eb" + integrity sha512-NFwVLMCqKTocY66gcim0ukF6e31VRDJqDapg5sy3vCHqlD1OCNUXSK/aI4VQEEndDrsnFmQepsL5KpEU0dDRIQ== dependencies: "@snyk/dep-graph" "^1.19.3" chalk "^4.1.0" debug "^4.1.1" + hosted-git-info "^3.0.7" tslib "^2.0.0" -snyk-docker-plugin@3.21.0: - version "3.21.0" - resolved "https://registry.yarnpkg.com/snyk-docker-plugin/-/snyk-docker-plugin-3.21.0.tgz#a92074c0411578c1a7b86852a06f1421770e985d" - integrity sha512-A7oJS3QGR7bwm1qeeczCb8PDfi8go1KM6VWph/drJHBQ7JxVKKLb3j4AzrMmIM96mGZFbmyNOL4pznwumaOM8g== +snyk-docker-plugin@4.12.0: + version "4.12.0" + resolved "https://registry.yarnpkg.com/snyk-docker-plugin/-/snyk-docker-plugin-4.12.0.tgz#137a159baf627debef6178cfb8b40941a81a7168" + integrity sha512-iN5GUTpMR4dx/hmjxh1GnJ9vrMpbOUhD8gsdWgFPZ5Qg+ImPQ2WBJBal/hyfkauM0TaKQEAgIwT6xZ1ovaIvWQ== dependencies: + "@snyk/dep-graph" "^1.19.4" "@snyk/rpm-parser" "^2.0.0" - "@snyk/snyk-docker-pull" "^3.2.0" + "@snyk/snyk-docker-pull" "3.2.3" + chalk "^2.4.2" debug "^4.1.1" docker-modem "2.1.3" dockerfile-ast "0.0.30" + elfy "^1.0.0" event-loop-spinner "^2.0.0" gunzip-maybe "^1.4.2" mkdirp "^1.0.4" semver "^6.1.0" - snyk-nodejs-lockfile-parser "1.28.1" + snyk-nodejs-lockfile-parser "1.30.1" tar-stream "^2.1.0" tmp "^0.2.1" tslib "^1" @@ -5921,13 +5980,14 @@ snyk-go-plugin@1.16.2: tmp "0.2.1" tslib "^1.10.0" -snyk-gradle-plugin@3.6.3: - version "3.6.3" - resolved "https://registry.yarnpkg.com/snyk-gradle-plugin/-/snyk-gradle-plugin-3.6.3.tgz#484059bcb98469b6a674bbcbdc995eafb5581041" - integrity sha512-j/eQSLSsK3DHmvVX2fNig4+ugYrKlCOV8Xvo6OYFkNzhMpdyNFiGWTS1uyP1HH75Gyc78MaLANMgjlSYePukzQ== +snyk-gradle-plugin@3.10.2: + version "3.10.2" + resolved "https://registry.yarnpkg.com/snyk-gradle-plugin/-/snyk-gradle-plugin-3.10.2.tgz#f3e104d42989e49b5c05818f005cae8c544c9803" + integrity sha512-gTFKL0BLUN54asUQ4OIoa4lATGn27VZwWDJGQ0VuqSaaoy8I5W16Cbn/KN95oIKa7tgwrmasPLd5uviFWzo/Qw== dependencies: "@snyk/cli-interface" "2.9.1" "@snyk/dep-graph" "^1.19.4" + "@snyk/java-call-graph-builder" "1.16.2" "@types/debug" "^4.1.4" chalk "^3.0.0" debug "^4.1.1" @@ -5960,22 +6020,23 @@ snyk-module@^2.0.2: debug "^3.1.0" hosted-git-info "^2.7.1" -snyk-mvn-plugin@2.19.4: - version "2.19.4" - resolved "https://registry.yarnpkg.com/snyk-mvn-plugin/-/snyk-mvn-plugin-2.19.4.tgz#4e29fa82b9ca409789d441939c766797d6a2360f" - integrity sha512-kYPUKOugnNd31PFqx1YHJTo90pospELYHME4AzBx8dkMDgs5ZPjAmQXSxegQ3AMUqfqcETMSTzlKHe6uHujI8A== +snyk-mvn-plugin@2.23.4: + version "2.23.4" + resolved "https://registry.yarnpkg.com/snyk-mvn-plugin/-/snyk-mvn-plugin-2.23.4.tgz#3f43601058aa51e8a0f9e272a7c186cad4b26950" + integrity sha512-1dWqvFu6eo2KsXFDqRF28JFwrdzpc0k+GwpIqv7vF2kHarsMxnLnT/akhjbKzs+xlRTNFvqdKhEQxjdq2nSD1Q== dependencies: "@snyk/cli-interface" "2.9.1" - "@snyk/java-call-graph-builder" "1.13.2" + "@snyk/java-call-graph-builder" "1.16.5" debug "^4.1.1" + glob "^7.1.6" needle "^2.5.0" tmp "^0.1.0" tslib "1.11.1" -snyk-nodejs-lockfile-parser@1.28.1: - version "1.28.1" - resolved "https://registry.yarnpkg.com/snyk-nodejs-lockfile-parser/-/snyk-nodejs-lockfile-parser-1.28.1.tgz#9eda1354bbca1fc881a4e63a1e1042f80c37bff2" - integrity sha512-0zbmtidYLI2ia/DQD4rZm2YKrhfHLvHlVBdF2cMAGPwhOoKW5ovG9eBO4wNQdvjxNi7b4VeUyAj8SfuhjDraDQ== +snyk-nodejs-lockfile-parser@1.30.1: + version "1.30.1" + resolved "https://registry.yarnpkg.com/snyk-nodejs-lockfile-parser/-/snyk-nodejs-lockfile-parser-1.30.1.tgz#5d54180ae818ddbe8c2b55329528c4d68e390235" + integrity sha512-QyhE4pmy7GI7fQrVmZ+qrQB8GGSbxN7OoYueS4BEP9nDxIyH4dJAz8dME5zOUeUxh3frcgBWoWgZoSzE4VOYpg== dependencies: "@yarnpkg/lockfile" "^1.1.0" event-loop-spinner "^2.0.0" @@ -5987,16 +6048,15 @@ snyk-nodejs-lockfile-parser@1.28.1: lodash.set "^4.3.2" lodash.topairs "^4.3.0" p-map "2.1.0" - snyk-config "^3.0.0" - source-map-support "^0.5.7" + snyk-config "^4.0.0-rc.2" tslib "^1.9.3" - uuid "^3.3.2" + uuid "^8.3.0" yaml "^1.9.2" -snyk-nuget-plugin@1.19.3: - version "1.19.3" - resolved "https://registry.yarnpkg.com/snyk-nuget-plugin/-/snyk-nuget-plugin-1.19.3.tgz#5b4d9a5a61a543810c98bd4e67b9f6b1d95e3c3a" - integrity sha512-KwKoMumwcXVz/DQH80ifXfX7CTnm29bmHJ2fczjCGohxLGb4EKBGQtA3t7K98O7lTISQGgXDxnWIaM9ZXkxPdw== +snyk-nuget-plugin@1.19.4: + version "1.19.4" + resolved "https://registry.yarnpkg.com/snyk-nuget-plugin/-/snyk-nuget-plugin-1.19.4.tgz#cd1163a29f8002d54a965eab9e256345c97d4174" + integrity sha512-6BvLJc7gpNdfPJSnvpmTL4BrbaOVbXh/9q1FNMs5OVp8NbnZ3l97iM+bpQXWTJHOa3BJBZz7iEg+3suH4AWoWw== dependencies: debug "^4.1.1" dotnet-deps-parser "5.0.0" @@ -6022,6 +6082,17 @@ snyk-php-plugin@1.9.2: "@snyk/composer-lockfile-parser" "^1.4.1" tslib "1.11.1" +snyk-poetry-lockfile-parser@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/snyk-poetry-lockfile-parser/-/snyk-poetry-lockfile-parser-1.1.1.tgz#3f062953802916f6ae1767ec13dd1892fff0541e" + integrity sha512-G3LX27V2KUsKObwVN4vDDjrYr5BERad9pXHAf+SST5+vZsdPUUZjd1ZUIrHgCv7IQhwq+7mZrtqedY5x7+LIGA== + dependencies: + "@snyk/cli-interface" "^2.9.2" + "@snyk/dep-graph" "^1.19.5" + debug "^4.2.0" + toml "^3.0.0" + tslib "^2.0.0" + snyk-policy@1.14.1: version "1.14.1" resolved "https://registry.yarnpkg.com/snyk-policy/-/snyk-policy-1.14.1.tgz#4e48ea993573aca18e8d883b8c62171b9d35a3e0" @@ -6037,12 +6108,13 @@ snyk-policy@1.14.1: snyk-try-require "^1.3.1" then-fs "^2.0.0" -snyk-python-plugin@1.17.1: - version "1.17.1" - resolved "https://registry.yarnpkg.com/snyk-python-plugin/-/snyk-python-plugin-1.17.1.tgz#303ec2885ef748634d89f22f3099ef1febdc3325" - integrity sha512-KKklat9Hfbj4hw2y63LRhgmziYzmyRt+cSuzN5KDmBSAGYck0EAoPDtNpJXjrIs1kPNz28EXnE6NDnadXnOjiQ== +snyk-python-plugin@1.19.1: + version "1.19.1" + resolved "https://registry.yarnpkg.com/snyk-python-plugin/-/snyk-python-plugin-1.19.1.tgz#91febcd260094a9d900bc54bf200aa0c2632613a" + integrity sha512-JoOUHnA76L3pekCblSuE9jQ9CuA5jt+GqXpsLQbEIZ0FQQTBa+0F7vfolg3Q7+s1it4ZdtgSbSWrlxCngIJt8g== dependencies: "@snyk/cli-interface" "^2.0.3" + snyk-poetry-lockfile-parser "^1.1.1" tmp "0.0.33" snyk-resolve-deps@4.4.0: @@ -6104,10 +6176,10 @@ snyk-try-require@1.3.1, snyk-try-require@^1.1.1, snyk-try-require@^1.3.1: lru-cache "^4.0.0" then-fs "^2.0.0" -snyk@^1.398.1: - version "1.398.1" - resolved "https://registry.yarnpkg.com/snyk/-/snyk-1.398.1.tgz#19aec8dfffa60e7412e6309117e96b2cfa960355" - integrity sha512-jH24ztdJY8DQlqkd1z8n/JutdOqHtTPccCynM2hfOedW20yAp9c108LFjXvqBEk/EH3YyNmWzyLkkHOySeDkwQ== +snyk@^1.425.4: + version "1.431.1" + resolved "https://registry.yarnpkg.com/snyk/-/snyk-1.431.1.tgz#1e360dae1b63d83f74fe90979f7b9a0fb1607aa7" + integrity sha512-OW48lG89ffLsSZPHwsjfdqQcu3XG6aRQOkwASPCgTAGcVcnXzS9XHB89h0gLsDzk0fZRskEVgYpvXdh4RFjNqA== dependencies: "@snyk/cli-interface" "2.9.2" "@snyk/dep-graph" "1.19.4" @@ -6120,28 +6192,28 @@ snyk@^1.398.1: configstore "^5.0.1" debug "^4.1.1" diff "^4.0.1" - glob "^7.1.3" graphlib "^2.1.8" inquirer "^7.3.3" lodash "^4.17.20" + micromatch "4.0.2" needle "2.5.0" open "^7.0.3" os-name "^3.0.0" proxy-agent "^3.1.1" proxy-from-env "^1.0.0" semver "^6.0.0" - snyk-config "3.1.1" - snyk-cpp-plugin "1.5.0" - snyk-docker-plugin "3.21.0" + snyk-config "4.0.0-rc.2" + snyk-cpp-plugin "2.2.1" + snyk-docker-plugin "4.12.0" snyk-go-plugin "1.16.2" - snyk-gradle-plugin "3.6.3" + snyk-gradle-plugin "3.10.2" snyk-module "3.1.0" - snyk-mvn-plugin "2.19.4" - snyk-nodejs-lockfile-parser "1.28.1" - snyk-nuget-plugin "1.19.3" + snyk-mvn-plugin "2.23.4" + snyk-nodejs-lockfile-parser "1.30.1" + snyk-nuget-plugin "1.19.4" snyk-php-plugin "1.9.2" snyk-policy "1.14.1" - snyk-python-plugin "1.17.1" + snyk-python-plugin "1.19.1" snyk-resolve "1.0.1" snyk-resolve-deps "4.4.0" snyk-sbt-plugin "2.11.0" @@ -6760,6 +6832,13 @@ to-regex-range@^2.1.0: is-number "^3.0.0" repeat-string "^1.6.1" +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + to-regex@^3.0.1, to-regex@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce" @@ -7023,6 +7102,11 @@ uuid@^8.2.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.0.tgz#ab738085ca22dc9a8c92725e459b1d507df5d6ea" integrity sha512-fX6Z5o4m6XsXBdli9g7DtWgAx+osMsRRZFKma1mIUsLCz6vRvv+pz5VNbyu9UEDzpMWulZfvpgb/cmDXVulYFQ== +uuid@^8.3.0: + version "8.3.1" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.1.tgz#2ba2e6ca000da60fce5a196954ab241131e05a31" + integrity sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg== + validate-npm-package-license@^3.0.1: version "3.0.4" resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" @@ -7147,11 +7231,6 @@ widest-line@^3.1.0: dependencies: string-width "^4.0.0" -window-size@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.4.tgz#f8e1aa1ee5a53ec5bf151ffa09742a6ad7697876" - integrity sha1-+OGqHuWlPsW/FR/6CXQqatdpeHY= - windows-release@^3.1.0: version "3.2.0" resolved "https://registry.yarnpkg.com/windows-release/-/windows-release-3.2.0.tgz#8122dad5afc303d833422380680a79cdfa91785f" @@ -7164,14 +7243,6 @@ word-wrap@~1.2.3: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== -wrap-ansi@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85" - integrity sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU= - dependencies: - string-width "^1.0.1" - strip-ansi "^3.0.1" - wrap-ansi@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09" @@ -7241,11 +7312,6 @@ xtend@~4.0.1: resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== -y18n@^3.2.0: - version "3.2.1" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41" - integrity sha1-bRX7qITAhnnA136I53WegR4H+kE= - y18n@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" @@ -7320,19 +7386,6 @@ yargs@^14.2: y18n "^4.0.0" yargs-parser "^15.0.0" -yargs@^3.19.0: - version "3.32.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-3.32.0.tgz#03088e9ebf9e756b69751611d2a5ef591482c995" - integrity sha1-AwiOnr+edWtpdRYR0qXvWRSCyZU= - dependencies: - camelcase "^2.0.1" - cliui "^3.0.3" - decamelize "^1.1.1" - os-locale "^1.4.0" - string-width "^1.0.1" - window-size "^0.1.4" - y18n "^3.2.0" - yeast@0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419" From 0c1dc810fba8a2d8b025663698a4fbb6cc82f432 Mon Sep 17 00:00:00 2001 From: Mohammad Hasnain Date: Wed, 25 Nov 2020 12:10:30 +0530 Subject: [PATCH 137/273] fix: add semicolons to unicode end --- frappe/public/js/frappe/utils/common.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frappe/public/js/frappe/utils/common.js b/frappe/public/js/frappe/utils/common.js index 9ff4ade761..0a145b098b 100644 --- a/frappe/public/js/frappe/utils/common.js +++ b/frappe/public/js/frappe/utils/common.js @@ -234,11 +234,11 @@ frappe.utils.xss_sanitise = function (string, options) { strategies: ['html', 'js'] // use all strategies. } const HTML_ESCAPE_MAP = { - '<': '<', - '>': '>', - '"': '"', - "'": ''', - '/': '/' + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/' }; const REGEX_SCRIPT = /)<[^<]*)*<\/script>/gi; // used in jQuery 1.7.2 src/ajax.js Line 14 options = Object.assign({ }, DEFAULT_OPTIONS, options); // don't deep copy, immutable beauty. From f4c7c7aeedb3976847505b0987d7f8af2850cd0a Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 25 Nov 2020 15:07:19 +0530 Subject: [PATCH 138/273] fix: set self.port instead of self.smtp_port --- frappe/email/smtp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/email/smtp.py b/frappe/email/smtp.py index f53b835757..391ce06c74 100644 --- a/frappe/email/smtp.py +++ b/frappe/email/smtp.py @@ -210,7 +210,7 @@ class SMTPServer: try: if self.use_ssl: if not self.port: - self.smtp_port = 465 + self.port = 465 self._sess = smtplib.SMTP_SSL((self.server or "").encode('utf-8'), cint(self.port) or None) From b3a8ecad632bf3f12bd588447fdb4acc97833c55 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 25 Nov 2020 17:34:44 +0530 Subject: [PATCH 139/273] refactor: don't encode server string --- frappe/email/smtp.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frappe/email/smtp.py b/frappe/email/smtp.py index 391ce06c74..9ba81fa146 100644 --- a/frappe/email/smtp.py +++ b/frappe/email/smtp.py @@ -212,8 +212,7 @@ class SMTPServer: if not self.port: self.port = 465 - self._sess = smtplib.SMTP_SSL((self.server or "").encode('utf-8'), - cint(self.port) or None) + self._sess = smtplib.SMTP_SSL((self.server or ""), cint(self.port)) else: if self.use_tls and not self.port: self.port = 587 From 08c8e517b6d484c5ad9aa1848ace855fec4c4c15 Mon Sep 17 00:00:00 2001 From: Rohan Bansal Date: Wed, 25 Nov 2020 18:08:18 +0530 Subject: [PATCH 140/273] fix: git check flow --- frappe/utils/change_log.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/frappe/utils/change_log.py b/frappe/utils/change_log.py index 75421c43ea..148d03ae39 100644 --- a/frappe/utils/change_log.py +++ b/frappe/utils/change_log.py @@ -185,29 +185,38 @@ def parse_latest_non_beta_release(response): return None -def check_release_on_github(app): - from subprocess import CalledProcessError +def check_release_on_github(app: str): + """ + Check the latest release for a given Frappe application hosted on Github. + + Args: + app (str): The name of the Frappe application. + + Returns: + tuple(Version, str): The Version object of the latest release and the + organization name, if the application exists, otherwise None. + """ try: # Check if repo remote is on github - remote_url = subprocess.check_output("cd ../apps/{} && git ls-remote --get-url".format(app), shell=True).decode() - except CalledProcessError: - # Passing this since some apps may not have git initializaed in them + remote_url = subprocess.check_output("cd ../apps/{} && git ls-remote --get-url".format(app), shell=True) + except subprocess.CalledProcessError: + # Passing this since some apps may not have git initialized in them return if isinstance(remote_url, bytes): remote_url = remote_url.decode() - if "github.com" not in remote_url: + if "github" not in remote_url: + return + + if is_git_url(remote_url): return # Get latest version from github if 'https' not in remote_url: return - if is_git_url(remote_url): - return - org_name = remote_url.split('/')[3] r = requests.get('https://api.github.com/repos/{}/{}/releases'.format(org_name, app)) if r.ok: From 6d9a56e43cb2e80e74e781c93151b023e8256fd5 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 25 Nov 2020 18:51:50 +0530 Subject: [PATCH 141/273] feat: add tests --- frappe/email/test_smtp.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 frappe/email/test_smtp.py diff --git a/frappe/email/test_smtp.py b/frappe/email/test_smtp.py new file mode 100644 index 0000000000..869d708430 --- /dev/null +++ b/frappe/email/test_smtp.py @@ -0,0 +1,25 @@ +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# License: The MIT License + +import unittest +from frappe.email.smtp import SMTPServer + +class TestSMTP(unittest.TestCase): + def test_smtp_ssl_session(self): + for port in [None, 0, 465, "465"]: + make_server(port, 1, 0) + + def test_smtp_tls_session(self): + for port in [None, 0, 587, "587"]: + make_server(port, 0, 1) + + +def make_server(port, ssl, tls): + server = SMTPServer( + server = "smtp.gmail.com", + port = port, + use_ssl = ssl, + use_tls = tls + ) + + server.sess \ No newline at end of file From 90875ef21c3c3e4b32667970ff6bf1816ca938de Mon Sep 17 00:00:00 2001 From: Mathieu Brunot Date: Thu, 26 Nov 2020 02:26:57 +0100 Subject: [PATCH 142/273] Update frappe/geo/utils.py Co-authored-by: Prssanna Desai --- frappe/geo/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frappe/geo/utils.py b/frappe/geo/utils.py index 77e48acb76..d94a13ea41 100644 --- a/frappe/geo/utils.py +++ b/frappe/geo/utils.py @@ -40,6 +40,8 @@ def merge_location_features_in_one(coords): geojson_dict = [] for element in coords: geojson_loc = frappe.parse_json(element['location']) + if not geojson_loc: + continue for coord in geojson_loc['features']: coord['properties']['name'] = element['name'] geojson_dict.append(coord.copy()) From 56b775a3deafd8d15b341c14abcf9d7c79c2bbfa Mon Sep 17 00:00:00 2001 From: Mathieu Brunot Date: Thu, 26 Nov 2020 02:54:41 +0100 Subject: [PATCH 143/273] Update frappe/public/js/frappe/views/map/map_view.js Co-authored-by: Prssanna Desai --- frappe/public/js/frappe/views/map/map_view.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frappe/public/js/frappe/views/map/map_view.js b/frappe/public/js/frappe/views/map/map_view.js index 84e5b70ab6..2c068277ad 100644 --- a/frappe/public/js/frappe/views/map/map_view.js +++ b/frappe/public/js/frappe/views/map/map_view.js @@ -46,11 +46,13 @@ frappe.views.MapView = class MapView extends frappe.views.ListView { }).addTo(this.map); L.control.scale().addTo(this.map); +if (this.coords.features && this.coords.features.length) { this.coords.features.forEach( coords => L.geoJSON(coords).bindPopup(coords.properties.name).addTo(this.map) ); let lastCoords = this.coords.features[0].geometry.coordinates.reverse(); this.map.panTo(lastCoords, 8); +} } get_coords() { From a459ce40ed05efbf682ec8c5df00e819b315c3ec Mon Sep 17 00:00:00 2001 From: Snyk bot Date: Thu, 26 Nov 2020 05:41:01 +0200 Subject: [PATCH 144/273] fix(snyk): Security upgrade highlight.js from 9.18.1 to 9.18.2 (#11999) The following vulnerabilities are fixed with an upgrade: - https://snyk.io/vuln/SNYK-JS-HIGHLIGHTJS-1045326 --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 91d204bec5..d1a94d0e35 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "frappe-datatable": "^1.15.3", "frappe-gantt": "^0.5.0", "fuse.js": "^3.4.6", - "highlight.js": "^9.18.1", + "highlight.js": "^9.18.2", "js-sha256": "^0.9.0", "jsbarcode": "^3.9.0", "moment": "^2.20.1", diff --git a/yarn.lock b/yarn.lock index 459f4139b9..b30ca7de0c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2702,10 +2702,10 @@ hex-color-regex@^1.1.0: resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ== -highlight.js@^9.18.1: - version "9.18.1" - resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.18.1.tgz#ed21aa001fe6252bb10a3d76d47573c6539fe13c" - integrity sha512-OrVKYz70LHsnCgmbXctv/bfuvntIKDz177h0Co37DQ5jamGZLVmoCVMtjMtNZY3X9DrCcKfklHPNeA0uPZhSJg== +highlight.js@^9.18.2: + version "9.18.5" + resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.18.5.tgz#d18a359867f378c138d6819edfc2a8acd5f29825" + integrity sha512-a5bFyofd/BHCX52/8i8uJkjr9DYwXIPnM/plwI6W7ezItLGqzt7X2G2nXuYSfsIJdkwwj/g9DG1LkcGJI/dDoA== homedir-polyfill@^1.0.1: version "1.0.3" From ce05cc15ed4a4816d145aaed664bd905a5688c4e Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Thu, 26 Nov 2020 09:26:58 +0530 Subject: [PATCH 145/273] fix: text editor field type changes in grid row form (bp #11974) (#11978) Co-authored-by: Shivam Mishra Co-authored-by: Suraj Shetty <13928957+surajshetty3416@users.noreply.github.com> --- frappe/public/js/frappe/form/grid_row.js | 1 + 1 file changed, 1 insertion(+) diff --git a/frappe/public/js/frappe/form/grid_row.js b/frappe/public/js/frappe/form/grid_row.js index 827fbfdee6..ec9cee9c39 100644 --- a/frappe/public/js/frappe/form/grid_row.js +++ b/frappe/public/js/frappe/form/grid_row.js @@ -373,6 +373,7 @@ export default class GridRow { // no text editor in grid if (df.fieldtype=='Text Editor') { + df = Object.assign({}, df); df.fieldtype = 'Text'; } From 926d7e78fd75dcfc5713de7c684900d54d84a843 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 26 Nov 2020 15:33:55 +0530 Subject: [PATCH 146/273] fix: clear localstorage if quota exceeds --- frappe/public/js/frappe/model/model.js | 33 +++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/frappe/public/js/frappe/model/model.js b/frappe/public/js/frappe/model/model.js index 308d9bd5f8..1d302215dd 100644 --- a/frappe/public/js/frappe/model/model.js +++ b/frappe/public/js/frappe/model/model.js @@ -103,6 +103,31 @@ $.extend(frappe.model, { return docfield[0]; }, + get_from_localstorage: function(doctype) { + if (localStorage["_doctype:" + doctype]) { + return JSON.parse(localStorage["_doctype:" + doctype]); + } + }, + + set_in_localstorage: function(doctype, docs) { + try { + localStorage["_doctype:" + doctype] = JSON.stringify(docs); + } catch(e) { + // if quota is exceeded, clear local storage and set item + console.warn("localStorage quota exceeded, clearing doctype cache") + frappe.model.clear_local_storage(); + localStorage["_doctype:" + doctype] = JSON.stringify(docs); + } + }, + + clear_local_storage: function() { + for(var key in localStorage) { + if (key.startsWith("_doctype:")) { + localStorage.removeItem(key); + } + } + }, + with_doctype: function(doctype, callback, async) { if(locals.DocType[doctype]) { callback && callback(); @@ -110,13 +135,15 @@ $.extend(frappe.model, { let cached_timestamp = null; let cached_doc = null; - if(localStorage["_doctype:" + doctype]) { - let cached_docs = JSON.parse(localStorage["_doctype:" + doctype]); + let cached_docs = frappe.model.get_from_localstorage(doctype) + + if (cached_docs) { cached_doc = cached_docs.filter(doc => doc.name === doctype)[0]; if(cached_doc) { cached_timestamp = cached_doc.modified; } } + return frappe.call({ method:'frappe.desk.form.load.getdoctype', type: "GET", @@ -134,7 +161,7 @@ $.extend(frappe.model, { if(r.message=="use_cache") { frappe.model.sync(cached_doc); } else { - localStorage["_doctype:" + doctype] = JSON.stringify(r.docs); + frappe.model.set_in_localstorage(doctype, r.docs) } frappe.model.init_doctype(doctype); From 8192db7382a02fd4334c227ad15339c10f7bede5 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 26 Nov 2020 16:22:40 +0530 Subject: [PATCH 147/273] chore: remove stray console --- frappe/public/js/frappe/desk.js | 1 - 1 file changed, 1 deletion(-) diff --git a/frappe/public/js/frappe/desk.js b/frappe/public/js/frappe/desk.js index c8ed29fb76..5fa7a9dbcb 100644 --- a/frappe/public/js/frappe/desk.js +++ b/frappe/public/js/frappe/desk.js @@ -148,7 +148,6 @@ frappe.Application = Class.extend({ user: frappe.session.user }, callback: function(r) { - console.log(r); if(r.message.show_alert){ frappe.show_alert({ indicator: 'red', From 0d4f116b3ef07c83190db428b2868ab9c819f493 Mon Sep 17 00:00:00 2001 From: Karthikeyan Singaravelan Date: Thu, 26 Nov 2020 13:15:09 +0000 Subject: [PATCH 148/273] fix: Import ABC from collections.abc for Python 3.9 compatibility. --- frappe/chat/util/util.py | 4 ++-- frappe/utils/response.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frappe/chat/util/util.py b/frappe/chat/util/util.py index 5aa80a85ae..1c3de3bbf5 100644 --- a/frappe/chat/util/util.py +++ b/frappe/chat/util/util.py @@ -7,7 +7,7 @@ import requests import six # imports - standard imports -from collections import Sequence, MutableSequence, Mapping, MutableMapping +from collections.abc import Sequence, MutableSequence, Mapping, MutableMapping if six.PY2: from urlparse import urlparse # PY2 else: @@ -113,4 +113,4 @@ def get_emojis(): emojis = resp.json() redis.hset('frappe_emojis', 'emojis', emojis) - return dictify(emojis) \ No newline at end of file + return dictify(emojis) diff --git a/frappe/utils/response.py b/frappe/utils/response.py index 20b5ea5678..c35ebc751e 100644 --- a/frappe/utils/response.py +++ b/frappe/utils/response.py @@ -123,7 +123,7 @@ def make_logs(response = None): def json_handler(obj): """serialize non-serializable data for json""" # serialize date - import collections + import collections.abc if isinstance(obj, (datetime.date, datetime.timedelta, datetime.datetime)): return text_type(obj) @@ -138,7 +138,7 @@ def json_handler(obj): doc = obj.as_dict(no_nulls=True) return doc - elif isinstance(obj, collections.Iterable): + elif isinstance(obj, collections.abc.Iterable): return list(obj) elif type(obj)==type or isinstance(obj, Exception): From 3a146580a8a1cafdb74c2af8e5e9522017a0069c Mon Sep 17 00:00:00 2001 From: Karthikeyan Singaravelan Date: Thu, 26 Nov 2020 13:30:07 +0000 Subject: [PATCH 149/273] fix: Use html.unescape for Python 3.9 compatibility. --- frappe/utils/global_search.py | 6 ++---- frappe/utils/html_utils.py | 5 ++--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/frappe/utils/global_search.py b/frappe/utils/global_search.py index e945039d0d..f605c3bf66 100644 --- a/frappe/utils/global_search.py +++ b/frappe/utils/global_search.py @@ -10,6 +10,7 @@ import json import os from bs4 import BeautifulSoup from frappe.utils import cint, strip_html_tags +from frappe.utils.html_utils import unescape_html from frappe.model.base_document import get_controller from six import text_type @@ -345,11 +346,8 @@ def get_formatted_value(value, field): :return: """ - from six.moves.html_parser import HTMLParser - if getattr(field, 'fieldtype', None) in ["Text", "Text Editor"]: - h = HTMLParser() - value = h.unescape(frappe.safe_decode(value)) + value = unescape_html(frappe.safe_decode(value)) value = (re.subn(r'<[\s]*(script|style).*?(?s)', '', text_type(value))[0]) value = ' '.join(value.split()) return field.label + " : " + strip_html_tags(text_type(value)) diff --git a/frappe/utils/html_utils.py b/frappe/utils/html_utils.py index 6fdd383eb9..bccdbd9441 100644 --- a/frappe/utils/html_utils.py +++ b/frappe/utils/html_utils.py @@ -106,9 +106,8 @@ def get_icon_html(icon, small=False): return "".format(icon=icon) def unescape_html(value): - from six.moves.html_parser import HTMLParser - h = HTMLParser() - return h.unescape(value) + from html import unescape + return unescape(value) # adapted from https://raw.githubusercontent.com/html5lib/html5lib-python/4aa79f113e7486c7ec5d15a6e1777bfe546d3259/html5lib/sanitizer.py acceptable_elements = [ From 8d3894e8a537540899c0412e2a48724014b6c304 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 27 Nov 2020 11:01:49 +0530 Subject: [PATCH 150/273] fix: Validate Python syntax on saves --- frappe/core/doctype/server_script/server_script.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frappe/core/doctype/server_script/server_script.py b/frappe/core/doctype/server_script/server_script.py index 839b784651..ded397d5e3 100644 --- a/frappe/core/doctype/server_script/server_script.py +++ b/frappe/core/doctype/server_script/server_script.py @@ -4,6 +4,8 @@ from __future__ import unicode_literals +import ast + import frappe from frappe.model.document import Document from frappe.utils.safe_exec import safe_exec @@ -11,9 +13,9 @@ from frappe import _ class ServerScript(Document): - @staticmethod - def validate(): + def validate(self): frappe.only_for('Script Manager', True) + ast.parse(self.script) @staticmethod def on_update(): From 5babacac3eac0e4dd2c7734a779b59641b96c94b Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 27 Nov 2020 11:02:37 +0530 Subject: [PATCH 151/273] fix: Show function not available in namespace instead of nothing Prior to this, frappe._dict was being used to inject functions to the server script namespaces. This meant unimplemented methods returned None and we'd get a NoneType not callable error --- frappe/utils/safe_exec.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/frappe/utils/safe_exec.py b/frappe/utils/safe_exec.py index fee6b404ac..50893330be 100644 --- a/frappe/utils/safe_exec.py +++ b/frappe/utils/safe_exec.py @@ -13,7 +13,17 @@ from frappe.www.printview import get_visible_columns import frappe.exceptions import frappe.integrations.utils -class ServerScriptNotEnabled(frappe.PermissionError): pass +class ServerScriptNotEnabled(frappe.PermissionError): + pass + +class NamespaceDict(frappe._dict): + """Raise AttributeError if function not found in namespace""" + def __getattr__(self, key): + ret = self.get(key) + if (not ret and key.startswith("__")) or (key not in self): + raise AttributeError(f"module has no attribute '{key}'") + return ret + def safe_exec(script, _globals=None, _locals=None): # script reports must be enabled via site_config.json @@ -46,13 +56,13 @@ def get_safe_globals(): user = getattr(frappe.local, "session", None) and frappe.local.session.user or "Guest" - out = frappe._dict( + out = NamespaceDict( # make available limited methods of frappe json=json, dict=dict, log=frappe.log, _dict=frappe._dict, - frappe=frappe._dict( + frappe=NamespaceDict( flags=frappe._dict(), format=frappe.format_value, format_value=frappe.format_value, @@ -112,7 +122,7 @@ def get_safe_globals(): out.get_visible_columns = get_visible_columns out.frappe.date_format = date_format out.frappe.time_format = time_format - out.frappe.db = frappe._dict( + out.frappe.db = NamespaceDict( get_list = frappe.get_list, get_all = frappe.get_all, get_value = frappe.db.get_value, From da0fa439bc4e96400cbf0b5dd0406d6951693712 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 27 Nov 2020 11:40:53 +0530 Subject: [PATCH 152/273] style: Optimize imports, fixed flake8 issues --- frappe/chat/util/util.py | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/frappe/chat/util/util.py b/frappe/chat/util/util.py index 1c3de3bbf5..82df6dd127 100644 --- a/frappe/chat/util/util.py +++ b/frappe/chat/util/util.py @@ -1,27 +1,21 @@ from __future__ import unicode_literals +# imports - standard imports +import json +from collections.abc import MutableMapping, MutableSequence, Sequence + # imports - third-party imports import requests - -# imports - compatibility imports -import six - -# imports - standard imports -from collections.abc import Sequence, MutableSequence, Mapping, MutableMapping -if six.PY2: - from urlparse import urlparse # PY2 -else: - from urllib.parse import urlparse # PY3 -import json +from urllib.parse import urlparse # imports - module imports -from frappe.model.document import Document -from frappe.exceptions import DuplicateEntryError -from frappe import _dict import frappe +from frappe.exceptions import DuplicateEntryError +from frappe.model.document import Document session = frappe.session + def get_user_doc(user = None): if isinstance(user, Document): return user @@ -38,12 +32,12 @@ def squashify(what): return what def safe_json_loads(*args): - results = [ ] + results = [] for arg in args: try: arg = json.loads(arg) - except Exception as e: + except Exception: pass results.append(arg) @@ -81,7 +75,7 @@ def dictify(arg): for i, a in enumerate(arg): arg[i] = dictify(a) elif isinstance(arg, MutableMapping): - arg = _dict(arg) + arg = frappe._dict(arg) return arg From 58ed57e8f53bf24c438ae7e91909ccdf9f0bad02 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 27 Nov 2020 15:01:46 +0530 Subject: [PATCH 153/273] fix: Add postgreSQL support for rename_doc queries --- frappe/core/doctype/doctype/doctype.py | 5 ++++- frappe/model/rename_doc.py | 24 ++++++++++++------------ 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 8a9c130fbe..f06f2017ae 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -392,7 +392,10 @@ class DocType(Document): frappe.db.sql("""update tabSingles set value=%s where doctype=%s and field='name' and value = %s""", (new, new, old)) else: - frappe.db.sql("rename table `tab%s` to `tab%s`" % (old, new)) + frappe.db.multisql({ + "mariadb": f"RENAME TABLE `tab{old}` TO `tab{new}`", + "postgres": f"ALTER TABLE `tab{old}` RENAME TO `tab{new}`" + }) def rename_files_and_folders(self, old, new): # move files diff --git a/frappe/model/rename_doc.py b/frappe/model/rename_doc.py index 789a7f51cf..33f6fefb7d 100644 --- a/frappe/model/rename_doc.py +++ b/frappe/model/rename_doc.py @@ -315,8 +315,7 @@ def get_link_fields(doctype): def update_options_for_fieldtype(fieldtype, old, new): if frappe.conf.developer_mode: - for name in frappe.db.sql_list("""select parent from - tabDocField where options=%s""", old): + for name in frappe.get_all("DocField", filters={"options": old}, pluck="parent"): doctype = frappe.get_doc("DocType", name) save = False for f in doctype.fields: @@ -422,20 +421,21 @@ def update_parenttype_values(old, new): child_doctypes += custom_child_doctypes fields = [d['fieldname'] for d in child_doctypes] - property_setter_child_doctypes = frappe.db.sql("""\ - select value as options from `tabProperty Setter` - where doc_type=%s and property='options' and - field_name in ("%s")""" % ('%s', '", "'.join(fields)), - (new,)) + property_setter_child_doctypes = frappe.get_all( + "Property Setter", + filters={ + "doc_type": new, + "property": "options", + "field_name": ("in", fields) + }, + pluck="value" + ) + child_doctypes = list(d['options'] for d in child_doctypes) child_doctypes += property_setter_child_doctypes - child_doctypes = (d['options'] for d in child_doctypes) for doctype in child_doctypes: - frappe.db.sql("""\ - update `tab%s` set parenttype=%s - where parenttype=%s""" % (doctype, '%s', '%s'), - (new, old)) + frappe.db.sql(f"update `tab{doctype}` set parenttype=%s where parenttype=%s", (new, old)) def rename_dynamic_links(doctype, old, new): for df in get_dynamic_link_map().get(doctype, []): From 104bc1b16708af1417a5488b779ac931db4827e5 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 27 Nov 2020 15:09:23 +0530 Subject: [PATCH 154/273] chore: Remove dead code --- frappe/model/rename_doc.py | 60 -------------------------------------- 1 file changed, 60 deletions(-) diff --git a/frappe/model/rename_doc.py b/frappe/model/rename_doc.py index 33f6fefb7d..15d634dade 100644 --- a/frappe/model/rename_doc.py +++ b/frappe/model/rename_doc.py @@ -42,7 +42,6 @@ def rename_doc(doctype, old, new, force=False, merge=False, ignore_permissions=F force = cint(force) merge = cint(merge) - meta = frappe.get_meta(doctype) # call before_rename @@ -489,62 +488,3 @@ def bulk_rename(doctype, rows=None, via_console = False): if not via_console: return rename_log - -def update_linked_doctypes(doctype, docname, linked_to, value, ignore_doctypes=None): - """ - linked_doctype_info_list = list formed by get_fetch_fields() function - docname = Master DocType's name in which modification are made - value = Value for the field thats set in other DocType's by fetching from Master DocType - """ - linked_doctype_info_list = get_fetch_fields(doctype, linked_to, ignore_doctypes) - - for d in linked_doctype_info_list: - frappe.db.sql(""" - update - `tab{doctype}` - set - {linked_to_fieldname} = "{value}" - where - {master_fieldname} = {docname} - and {linked_to_fieldname} != "{value}" - """.format( - doctype = d['doctype'], - linked_to_fieldname = d['linked_to_fieldname'], - value = value, - master_fieldname = d['master_fieldname'], - docname = frappe.db.escape(docname) - )) - -def get_fetch_fields(doctype, linked_to, ignore_doctypes=None): - """ - doctype = Master DocType in which the changes are being made - linked_to = DocType name of the field thats being updated in Master - - This function fetches list of all DocType where both doctype and linked_to is found - as link fields. - Forms a list of dict in the form - - [{doctype: , master_fieldname: , linked_to_fieldname: ] - where - doctype = DocType where changes need to be made - master_fieldname = Fieldname where options = doctype - linked_to_fieldname = Fieldname where options = linked_to - """ - - master_list = get_link_fields(doctype) - linked_to_list = get_link_fields(linked_to) - out = [] - - from itertools import product - product_list = product(master_list, linked_to_list) - - for d in product_list: - linked_doctype_info = frappe._dict() - if d[0]['parent'] == d[1]['parent'] \ - and (not ignore_doctypes or d[0]['parent'] not in ignore_doctypes) \ - and not d[1]['issingle']: - linked_doctype_info['doctype'] = d[0]['parent'] - linked_doctype_info['master_fieldname'] = d[0]['fieldname'] - linked_doctype_info['linked_to_fieldname'] = d[1]['fieldname'] - out.append(linked_doctype_info) - - return out From 7bba0b7da933950accbf654325bf668fd1bcf4ab Mon Sep 17 00:00:00 2001 From: gavin Date: Fri, 27 Nov 2020 15:56:28 +0530 Subject: [PATCH 155/273] chore(GitHub): Add issue template config --- .github/ISSUE_TEMPLATE/config.yml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..26bb7ab280 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Community Forum + url: https://discuss.erpnext.com/ + about: For general QnA, discussions and community help. From 8a6bdf546652d46d13377e016c7fdf6c2173d2e3 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 27 Nov 2020 16:49:49 +0530 Subject: [PATCH 156/273] chore: Move rename doctype method to dedicated TestCase Co-authored-by: Marica --- frappe/tests/test_document.py | 76 --------------------------------- frappe/tests/test_rename_doc.py | 36 ++++++++++++++++ 2 files changed, 36 insertions(+), 76 deletions(-) diff --git a/frappe/tests/test_document.py b/frappe/tests/test_document.py index 4f595c9419..2be92be1f5 100644 --- a/frappe/tests/test_document.py +++ b/frappe/tests/test_document.py @@ -249,82 +249,6 @@ class TestDocument(unittest.TestCase): self.assertEqual(cint(old_current) - 1, new_current) - def test_rename_doc(self): - from random import choice, sample - - available_documents = [] - doctype = "ToDo" - - # data generation: 4 todo documents - for num in range(1, 5): - doc = frappe.get_doc({ - "doctype": doctype, - "date": add_to_date(now(), days=num), - "description": "this is todo #{}".format(num) - }).insert() - available_documents.append(doc.name) - - # test 1: document renaming - old_name = choice(available_documents) - new_name = old_name + '.new' - self.assertEqual(new_name, frappe.rename_doc(doctype, old_name, new_name, force=True)) - available_documents.remove(old_name) - available_documents.append(new_name) - - # test 2: merge documents - first_todo, second_todo = sample(available_documents, 2) - - second_todo_doc = frappe.get_doc(doctype, second_todo) - second_todo_doc.priority = "High" - second_todo_doc.save() - - merged_todo = frappe.rename_doc(doctype, first_todo, second_todo, merge=True, force=True) - merged_todo_doc = frappe.get_doc(doctype, merged_todo) - available_documents.remove(first_todo) - - with self.assertRaises(DoesNotExistError): - frappe.get_doc(doctype, first_todo) - - self.assertEqual(merged_todo_doc.priority, second_todo_doc.priority) - - for docname in available_documents: - frappe.delete_doc(doctype, docname) - - def test_rename_doctype(self): - from frappe.core.doctype.doctype.test_doctype import new_doctype - - fields =[{ - "label": "Linked To", - "fieldname": "linked_to_doctype", - "fieldtype": "Link", - "options": "DocType", - "unique": 0 - }] - if not frappe.db.exists("DocType", "Rename This"): - new_doctype("Rename This", unique=0, fields=fields).insert() - - to_rename_record = frappe.get_doc({ - "doctype": "Rename This", - "linked_to_doctype": "Rename This" - }) - to_rename_record.insert() - - # Rename doctype - self.assertEqual("Renamed Doc", frappe.rename_doc("DocType", "Rename This", "Renamed Doc", force=True)) - - # Test if Doctype value has changed in Link field - renamed_doctype_record = frappe.get_doc("Renamed Doc", to_rename_record.name) - self.assertEqual(renamed_doctype_record.linked_to_doctype, "Renamed Doc") - - # Test if there are conflicts between a record and a DocType - # having the same name - old_name = to_rename_record.name - new_name = "ToDo" - self.assertEqual(new_name, frappe.rename_doc("Renamed Doc", old_name, new_name, force=True)) - - frappe.delete_doc_if_exists("Renamed Doc", "ToDo") - frappe.delete_doc_if_exists("DocType", "Renamed Doc") - def test_non_negative_check(self): frappe.delete_doc_if_exists("Currency", "Frappe Coin", 1) diff --git a/frappe/tests/test_rename_doc.py b/frappe/tests/test_rename_doc.py index 4db877e586..fcb82e2755 100644 --- a/frappe/tests/test_rename_doc.py +++ b/frappe/tests/test_rename_doc.py @@ -98,3 +98,39 @@ class TestRenameDoc(unittest.TestCase): self.assertTrue(frappe.db.exists("DocType", self.doctype.new)) self.assertFalse(frappe.db.exists("DocType", self.doctype.old)) self.assertFalse(os.path.exists(old_doctype_path)) + + def test_rename_doctype(self): + """Rename DocType via frappe.rename_doc""" + from frappe.core.doctype.doctype.test_doctype import new_doctype + + fields =[{ + "label": "Linked To", + "fieldname": "linked_to_doctype", + "fieldtype": "Link", + "options": "DocType", + "unique": 0 + }] + if not frappe.db.exists("DocType", "Rename This"): + new_doctype("Rename This", unique=0, fields=fields).insert() + + to_rename_record = frappe.get_doc({ + "doctype": "Rename This", + "linked_to_doctype": "Rename This" + }) + to_rename_record.insert() + + # Rename doctype + self.assertEqual("Renamed Doc", frappe.rename_doc("DocType", "Rename This", "Renamed Doc", force=True)) + + # Test if Doctype value has changed in Link field + renamed_doctype_record = frappe.get_doc("Renamed Doc", to_rename_record.name) + self.assertEqual(renamed_doctype_record.linked_to_doctype, "Renamed Doc") + + # Test if there are conflicts between a record and a DocType + # having the same name + old_name = to_rename_record.name + new_name = "ToDo" + self.assertEqual(new_name, frappe.rename_doc("Renamed Doc", old_name, new_name, force=True)) + + frappe.delete_doc_if_exists("Renamed Doc", "ToDo") + frappe.delete_doc_if_exists("DocType", "Renamed Doc") From 9a84a7eb45c529d882f6b541db0916232468d783 Mon Sep 17 00:00:00 2001 From: Rohan Bansal Date: Fri, 27 Nov 2020 17:27:44 +0530 Subject: [PATCH 157/273] feat: use giturlparse to parse Git URLs --- frappe/utils/change_log.py | 32 ++++++++++++++++++++------------ frappe/utils/data.py | 6 ------ requirements.txt | 1 + 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/frappe/utils/change_log.py b/frappe/utils/change_log.py index 148d03ae39..9607c89784 100644 --- a/frappe/utils/change_log.py +++ b/frappe/utils/change_log.py @@ -5,13 +5,14 @@ import json import os import subprocess # nosec -import frappe import requests -from frappe import _, safe_decode -from frappe.utils import cstr, is_git_url from semantic_version import Version from six.moves import range +import frappe +from frappe import _, safe_decode +from frappe.utils import cstr + def get_change_log(user=None): if not user: user = frappe.session.user @@ -193,10 +194,13 @@ def check_release_on_github(app: str): app (str): The name of the Frappe application. Returns: - tuple(Version, str): The Version object of the latest release and the + tuple(Version, str): The semantic version object of the latest release and the organization name, if the application exists, otherwise None. """ + from giturlparse import parse + from giturlparse.parser import ParserError + try: # Check if repo remote is on github remote_url = subprocess.check_output("cd ../apps/{} && git ls-remote --get-url".format(app), shell=True) @@ -207,22 +211,26 @@ def check_release_on_github(app: str): if isinstance(remote_url, bytes): remote_url = remote_url.decode() - if "github" not in remote_url: + try: + parsed_url = parse(remote_url) + except ParserError: + # Invalid URL return - if is_git_url(remote_url): + # Get latest version from Github + if parsed_url.protocol == "http": + return + if parsed_url.resource != "github.com": return - # Get latest version from github - if 'https' not in remote_url: - return + owner = parsed_url.owner + repo = parsed_url.name - org_name = remote_url.split('/')[3] - r = requests.get('https://api.github.com/repos/{}/{}/releases'.format(org_name, app)) + r = requests.get('https://api.github.com/repos/{}/{}/releases'.format(owner, repo)) if r.ok: latest_non_beta_release = parse_latest_non_beta_release(r.json()) if latest_non_beta_release: - return Version(latest_non_beta_release), org_name + return Version(latest_non_beta_release), owner def add_message_to_redis(update_json): diff --git a/frappe/utils/data.py b/frappe/utils/data.py index cb61355a29..34659e1cac 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -1397,9 +1397,3 @@ def validate_json_string(string): json.loads(string) except (TypeError, ValueError): raise frappe.ValidationError - - -def is_git_url(url): - # modified to allow without the tailing .git from https://github.com/jonschlinkert/is-git-url.git - pattern = r"(?:git|ssh|https?|git@[-\w.]+):(\/\/)?(.*?)(\.git)?(\/?|\#[-\d\w._]+?)$" - return bool(re.match(pattern, url)) diff --git a/requirements.txt b/requirements.txt index de9e675a67..59c4a9dbf8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,6 +15,7 @@ Faker==2.0.4 future==0.18.2 gitdb2==2.0.6;python_version<'3.4' GitPython==2.1.15 +git-url-parse==1.2.2 google-api-python-client==1.9.3 google-auth-httplib2==0.0.3 google-auth-oauthlib==0.4.1 From 147a3def0dcab7173d000b91d9d998a72a8b87de Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 27 Nov 2020 18:36:23 +0530 Subject: [PATCH 158/273] fix: Use get_value to find value of linked_to_doctype instead Tests ran successfully when run locally for mariaDB and postgres --- frappe/tests/test_rename_doc.py | 42 ++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/frappe/tests/test_rename_doc.py b/frappe/tests/test_rename_doc.py index fcb82e2755..fb0776a485 100644 --- a/frappe/tests/test_rename_doc.py +++ b/frappe/tests/test_rename_doc.py @@ -103,34 +103,42 @@ class TestRenameDoc(unittest.TestCase): """Rename DocType via frappe.rename_doc""" from frappe.core.doctype.doctype.test_doctype import new_doctype - fields =[{ - "label": "Linked To", - "fieldname": "linked_to_doctype", - "fieldtype": "Link", - "options": "DocType", - "unique": 0 - }] if not frappe.db.exists("DocType", "Rename This"): - new_doctype("Rename This", unique=0, fields=fields).insert() + new_doctype( + "Rename This", + fields=[ + { + "label": "Linked To", + "fieldname": "linked_to_doctype", + "fieldtype": "Link", + "options": "DocType", + "unique": 0, + } + ], + ).insert() - to_rename_record = frappe.get_doc({ - "doctype": "Rename This", - "linked_to_doctype": "Rename This" - }) - to_rename_record.insert() + to_rename_record = frappe.get_doc( + {"doctype": "Rename This", "linked_to_doctype": "Rename This"} + ).insert() # Rename doctype - self.assertEqual("Renamed Doc", frappe.rename_doc("DocType", "Rename This", "Renamed Doc", force=True)) + self.assertEqual( + "Renamed Doc", frappe.rename_doc("DocType", "Rename This", "Renamed Doc", force=True) + ) # Test if Doctype value has changed in Link field - renamed_doctype_record = frappe.get_doc("Renamed Doc", to_rename_record.name) - self.assertEqual(renamed_doctype_record.linked_to_doctype, "Renamed Doc") + linked_to_doctype = frappe.db.get_value( + "Renamed Doc", to_rename_record.name, "linked_to_doctype" + ) + self.assertEqual(linked_to_doctype, "Renamed Doc") # Test if there are conflicts between a record and a DocType # having the same name old_name = to_rename_record.name new_name = "ToDo" - self.assertEqual(new_name, frappe.rename_doc("Renamed Doc", old_name, new_name, force=True)) + self.assertEqual( + new_name, frappe.rename_doc("Renamed Doc", old_name, new_name, force=True) + ) frappe.delete_doc_if_exists("Renamed Doc", "ToDo") frappe.delete_doc_if_exists("DocType", "Renamed Doc") From 729ee6ea9ea1138043a501632891cfd03fc68396 Mon Sep 17 00:00:00 2001 From: pateljannat Date: Fri, 27 Nov 2020 19:03:23 +0530 Subject: [PATCH 159/273] fix: changing percent field precision to global default --- frappe/public/js/frappe/form/formatters.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/form/formatters.js b/frappe/public/js/frappe/form/formatters.js index 3f422d0a9b..c456739add 100644 --- a/frappe/public/js/frappe/form/formatters.js +++ b/frappe/public/js/frappe/form/formatters.js @@ -50,7 +50,7 @@ frappe.form.formatters = { return frappe.form.formatters._right(value==null ? "" : cint(value), options) }, Percent: function(value, docfield, options) { - return frappe.form.formatters._right(flt(value, 2) + "%", options) + return frappe.form.formatters._right(flt(value, frappe.defaults.get_default("float_precision")) + "%", options) }, Rating: function(value) { return ` From 8aeb1716642a1e275ddbc83c77501a24529dd1c5 Mon Sep 17 00:00:00 2001 From: pateljannat Date: Fri, 27 Nov 2020 19:58:41 +0530 Subject: [PATCH 160/273] fix: sider issue fixed --- frappe/public/js/frappe/form/formatters.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/form/formatters.js b/frappe/public/js/frappe/form/formatters.js index c456739add..7f5c3bf472 100644 --- a/frappe/public/js/frappe/form/formatters.js +++ b/frappe/public/js/frappe/form/formatters.js @@ -50,7 +50,7 @@ frappe.form.formatters = { return frappe.form.formatters._right(value==null ? "" : cint(value), options) }, Percent: function(value, docfield, options) { - return frappe.form.formatters._right(flt(value, frappe.defaults.get_default("float_precision")) + "%", options) + return frappe.form.formatters._right(flt(value, frappe.defaults.get_default("float_precision")) + "%", options); }, Rating: function(value) { return ` From e3b09e2a2aa8d88760ea91cc51375ca485ef34ce Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 27 Nov 2020 20:15:08 +0530 Subject: [PATCH 161/273] fix: Add rename_doc utils for external API usages * The previously deleted code was being used in ERPNext's Customer module. * This will be moved into frappe.model.utils.rename_doc for the time and completely removed in time. --- frappe/model/rename_doc.py | 30 +++++++++++++++++ frappe/model/utils/rename_doc.py | 58 ++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 frappe/model/utils/rename_doc.py diff --git a/frappe/model/rename_doc.py b/frappe/model/rename_doc.py index 15d634dade..a188705fcf 100644 --- a/frappe/model/rename_doc.py +++ b/frappe/model/rename_doc.py @@ -2,6 +2,7 @@ # MIT License. See license.txt from __future__ import unicode_literals, print_function +from click.termui import secho import frappe from frappe import _, bold from frappe.utils import cint @@ -488,3 +489,32 @@ def bulk_rename(doctype, rows=None, via_console = False): if not via_console: return rename_log + +def update_linked_doctypes(doctype, docname, linked_to, value, ignore_doctypes=None): + from frappe.model.utils.rename_doc import update_linked_doctypes + show_deprecation_warning("update_linked_doctypes") + + return update_linked_doctypes( + doctype=doctype, + docname=docname, + linked_to=linked_to, + value=value, + ignore_doctypes=ignore_doctypes, + ) + + +def get_fetch_fields(doctype, linked_to, ignore_doctypes=None): + from frappe.model.utils.rename_doc import get_fetch_fields + show_deprecation_warning("get_fetch_fields") + + return get_fetch_fields( + doctype=doctype, linked_to=linked_to, ignore_doctypes=ignore_doctypes + ) + +def show_deprecation_warning(funct): + from click import secho + message = ( + f"Function frappe.model.rename_doc.{funct} has been deprecated and " + "moved to the frappe.model.utils.rename_doc" + ) + secho(message, fg="yellow") diff --git a/frappe/model/utils/rename_doc.py b/frappe/model/utils/rename_doc.py new file mode 100644 index 0000000000..bf71d36a42 --- /dev/null +++ b/frappe/model/utils/rename_doc.py @@ -0,0 +1,58 @@ +from itertools import product + +import frappe +from frappe.model.rename_doc import get_link_fields + + +def update_linked_doctypes(doctype, docname, linked_to, value, ignore_doctypes=None): + """ + linked_doctype_info_list = list formed by get_fetch_fields() function + docname = Master DocType's name in which modification are made + value = Value for the field thats set in other DocType's by fetching from Master DocType + """ + linked_doctype_info_list = get_fetch_fields(doctype, linked_to, ignore_doctypes) + + for d in linked_doctype_info_list: + frappe.db.set_value( + d.doctype, + { + d.master_fieldname : docname, + d.linked_to_fieldname : ("!=", value), + }, + d.linked_to_fieldname, + value, + ) + + +def get_fetch_fields(doctype, linked_to, ignore_doctypes=None): + """ + doctype = Master DocType in which the changes are being made + linked_to = DocType name of the field thats being updated in Master + This function fetches list of all DocType where both doctype and linked_to is found + as link fields. + Forms a list of dict in the form - + [{doctype: , master_fieldname: , linked_to_fieldname: ] + where + doctype = DocType where changes need to be made + master_fieldname = Fieldname where options = doctype + linked_to_fieldname = Fieldname where options = linked_to + """ + + out = [] + master_list = get_link_fields(doctype) + linked_to_list = get_link_fields(linked_to) + product_list = product(master_list, linked_to_list) + + for d in product_list: + linked_doctype_info = frappe._dict() + if ( + d[0]["parent"] == d[1]["parent"] + and (not ignore_doctypes or d[0]["parent"] not in ignore_doctypes) + and not d[1]["issingle"] + ): + linked_doctype_info.doctype = d[0]["parent"] + linked_doctype_info.master_fieldname = d[0]["fieldname"] + linked_doctype_info.linked_to_fieldname = d[1]["fieldname"] + out.append(linked_doctype_info) + + return out From a0cb5930198ea1df0c8c6d1aaefd5d557e02b973 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 27 Nov 2020 20:33:10 +0530 Subject: [PATCH 162/273] style: Sort and remove unused imports --- frappe/model/rename_doc.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frappe/model/rename_doc.py b/frappe/model/rename_doc.py index a188705fcf..35fbf94dc6 100644 --- a/frappe/model/rename_doc.py +++ b/frappe/model/rename_doc.py @@ -1,15 +1,15 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # MIT License. See license.txt -from __future__ import unicode_literals, print_function -from click.termui import secho +from __future__ import print_function, unicode_literals + import frappe from frappe import _, bold -from frappe.utils import cint -from frappe.model.naming import validate_name from frappe.model.dynamic_links import get_dynamic_link_map -from frappe.utils.password import rename_password +from frappe.model.naming import validate_name from frappe.model.utils.user_settings import sync_user_settings, update_user_settings_data +from frappe.utils import cint +from frappe.utils.password import rename_password @frappe.whitelist() From dd8e2114ab5ef913418ea747a15e757350588ec7 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sat, 28 Nov 2020 18:53:59 +0530 Subject: [PATCH 163/273] fix: Trim long names in website navbar by adding ellipsis (bp #11730) (#12009) Co-authored-by: Suraj Shetty <13928957+surajshetty3416@users.noreply.github.com> --- frappe/public/scss/website.scss | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/frappe/public/scss/website.scss b/frappe/public/scss/website.scss index e1b7d0a827..5291834aab 100644 --- a/frappe/public/scss/website.scss +++ b/frappe/public/scss/website.scss @@ -244,9 +244,18 @@ h5.modal-title { white-space: nowrap; text-overflow: ellipsis; } + .about-section { padding-top: 1rem; } + .about-footer { padding-top: 1rem; -} \ No newline at end of file +} + +.logged-in > .nav-link { + max-width: 200px; + @extend .ellipsis; + max-width: 100%; + vertical-align: middle; +} From be6724c7c8288589d616b73a641a2440ef580f26 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 30 Nov 2020 11:15:18 +0530 Subject: [PATCH 164/273] fix: check if print settings has landscape --- frappe/public/js/frappe/microtemplate.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/microtemplate.js b/frappe/public/js/frappe/microtemplate.js index d233a47893..7b45db952e 100644 --- a/frappe/public/js/frappe/microtemplate.js +++ b/frappe/public/js/frappe/microtemplate.js @@ -89,11 +89,19 @@ frappe.render_template = function(name, data) { } frappe.render_grid = function(opts) { // build context - if(opts.grid) { + if (opts.grid) { opts.columns = opts.grid.getColumns(); opts.data = opts.grid.getData().getItems(); } + if ( + opts.print_settings && + opts.print_settings.orientation && + opts.print_settings.orientation.toLowerCase() === "landscape" + ) { + opts.landscape = true; + } + // show landscape view if columns more than 10 if (opts.landscape == null) { if(opts.columns && opts.columns.length > 10) { From bd2e3530cdf6491d1ab43fd1c4326e7d9275b1ab Mon Sep 17 00:00:00 2001 From: Abhishek Balam Date: Mon, 30 Nov 2020 11:59:16 +0530 Subject: [PATCH 165/273] fix: strip exif data from image files before uploading --- frappe/core/doctype/file/file.py | 12 +++++++++--- frappe/utils/image.py | 25 ++++++++++++++++++++++++- requirements.txt | 3 ++- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index 473d810a9f..1642e857c5 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -30,7 +30,7 @@ import frappe from frappe import _, conf from frappe.model.document import Document from frappe.utils import call_hook_method, cint, cstr, encode, get_files_path, get_hook_method, random_string, strip - +from frappe.utils.image import strip_exif_data class MaxFileSizeReachedError(frappe.ValidationError): pass @@ -456,6 +456,7 @@ class File(Document): def save_file(self, content=None, decode=False, ignore_existing_file_check=False): file_exists = False self.content = content + if decode: if isinstance(content, text_type): self.content = content.encode("utf-8") @@ -466,10 +467,15 @@ class File(Document): if not self.is_private: self.is_private = 0 + + self.content_type = mimetypes.guess_type(self.file_name)[0] + + if self.content_type and "image" in self.content_type: + self.content = strip_exif_data(self.content, self.content_type) + self.file_size = self.check_max_file_size() self.content_hash = get_content_hash(self.content) - self.content_type = mimetypes.guess_type(self.file_name)[0] - + duplicate_file = None # check if a file exists with the same content hash and is also in the same folder (public or private) diff --git a/frappe/utils/image.py b/frappe/utils/image.py index 1eada5acca..3d3d98a28c 100644 --- a/frappe/utils/image.py +++ b/frappe/utils/image.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals, print_function import os def resize_images(path, maxdim=700): - import Image + from PIL import Image size = (maxdim, maxdim) for basepath, folders, files in os.walk(path): for fname in files: @@ -17,3 +17,26 @@ def resize_images(path, maxdim=700): im.save(os.path.join(basepath, fname)) print("resized {0}".format(os.path.join(basepath, fname))) + +def strip_exif_data(content, content_type): + """ Strips exif from image files which support it. + + Works by creating a new Image object which ignores exif by + default and then extracts the binary data back into content. + + Returns: stripped image content + """ + + from PIL import Image + import io + + original_image = Image.open(io.BytesIO(content)) + output = io.BytesIO() + + new_image = Image.new(original_image.mode, original_image.size) + new_image.putdata(list(original_image.getdata())) + new_image.save(output, format=content_type.split('/')[-1].upper()) + + content = output.getvalue() + + return content \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index de9e675a67..92ea0e5572 100644 --- a/requirements.txt +++ b/requirements.txt @@ -73,4 +73,5 @@ pycryptodome==3.9.8 paytmchecksum==1.7.0 wrapt==1.10.11 razorpay==1.2.0 -rsa>=4.1 # not directly required, pinned by Snyk to avoid a vulnerability \ No newline at end of file +rsa>=4.1 # not directly required, pinned by Snyk to avoid a vulnerability +pillow==8.0.1 \ No newline at end of file From 29941ef46a396dcbc0d79762988107e05b98fd2d Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 30 Nov 2020 12:03:53 +0530 Subject: [PATCH 166/273] fix: Use ORM instead of raw SQL Co-authored-by: Marica --- frappe/model/rename_doc.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/frappe/model/rename_doc.py b/frappe/model/rename_doc.py index a7714a0ae7..697978ff98 100644 --- a/frappe/model/rename_doc.py +++ b/frappe/model/rename_doc.py @@ -247,6 +247,7 @@ def update_link_field_values(link_fields, old, new, doctype): pass else: parent = field['parent'] + docfield = field["fieldname"] # Handles the case where one of the link fields belongs to # the DocType being renamed. @@ -258,11 +259,8 @@ def update_link_field_values(link_fields, old, new, doctype): if parent == new and doctype == "DocType": parent = old - frappe.db.sql(""" - update `tab{table_name}` set `{fieldname}`=%s - where `{fieldname}`=%s""".format( - table_name=parent, - fieldname=field['fieldname']), (new, old)) + frappe.db.set_value(parent, {docfield: old}, docfield, new) + # update cached link_fields as per new if doctype=='DocType' and field['parent'] == old: field['parent'] = new From d276a2d8e0b826f18b13ae6183b56c787a2ab242 Mon Sep 17 00:00:00 2001 From: Abhishek Balam Date: Mon, 30 Nov 2020 12:26:09 +0530 Subject: [PATCH 167/273] fix: remove extra pillow entry in requirements --- requirements.txt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 92ea0e5572..de9e675a67 100644 --- a/requirements.txt +++ b/requirements.txt @@ -73,5 +73,4 @@ pycryptodome==3.9.8 paytmchecksum==1.7.0 wrapt==1.10.11 razorpay==1.2.0 -rsa>=4.1 # not directly required, pinned by Snyk to avoid a vulnerability -pillow==8.0.1 \ No newline at end of file +rsa>=4.1 # not directly required, pinned by Snyk to avoid a vulnerability \ No newline at end of file From 51928cccb0ff573ac314255cd29522c82a388c9f Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 30 Nov 2020 12:38:38 +0530 Subject: [PATCH 168/273] chore: bump frappe-charts to 1.5.4 --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index b30ca7de0c..26797675c6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2300,9 +2300,9 @@ fragment-cache@^0.2.1: map-cache "^0.2.2" frappe-charts@^1.5.1: - version "1.5.3" - resolved "https://registry.yarnpkg.com/frappe-charts/-/frappe-charts-1.5.3.tgz#0dcb86ea774fa7a3e1b79221e958d29701dfff04" - integrity sha512-VS5XVxek41ea8mVzetyFF3avNefiwGDcDSDJuHrZyJXgbqiTSXLoqlPFoMqTzuzRm1g+o6TXs+A7wLtVp3Vt0g== + version "1.5.4" + resolved "https://registry.yarnpkg.com/frappe-charts/-/frappe-charts-1.5.4.tgz#5870f77ac6ffc8ea4dab32adda1d4e5e4fbda64b" + integrity sha512-hBr7cRLmsCC5VBj/HwKOCgdwyXnkeAO5CAvOd5H4IYFbk84VD9jOjx9fSaqAE0MygVVbY1nCN+5nb08WThW4Xw== frappe-datatable@^1.15.3: version "1.15.3" From 64e80d7aa3025290e61d15795bf9d53b0c8c7bd0 Mon Sep 17 00:00:00 2001 From: Leela vadlamudi Date: Mon, 30 Nov 2020 14:12:43 +0530 Subject: [PATCH 169/273] refactor: Remove telephony related code (#12017) --- frappe/public/js/frappe/form/controls/data.js | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/frappe/public/js/frappe/form/controls/data.js b/frappe/public/js/frappe/form/controls/data.js index 4db2553bd1..401de2ed5d 100644 --- a/frappe/public/js/frappe/form/controls/data.js +++ b/frappe/public/js/frappe/form/controls/data.js @@ -22,27 +22,9 @@ frappe.ui.form.ControlData = frappe.ui.form.ControlInput.extend({ this.has_input = true; this.bind_change_event(); this.setup_autoname_check(); - if (this.df.options == 'Phone') { - this.setup_phone(); - } // somehow this event does not bubble up to document // after v7, if you can debug, remove this }, - setup_phone() { - if (frappe.phone_call.handler) { - this.$wrapper.find('.control-input') - .append(` - - - - - `) - .find('.phone-btn') - .click(() => { - frappe.phone_call.handler(this.get_value(), this.frm); - }); - } - }, setup_autoname_check: function() { if (!this.df.parent) return; this.meta = frappe.get_meta(this.df.parent); From 9808c868d70ce334dadda790ca55b3819d557ca7 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 30 Nov 2020 14:14:19 +0530 Subject: [PATCH 170/273] feat: allow html in email templates --- .../email_template/email_template.json | 22 +++++++++++++-- .../doctype/email_template/email_template.py | 27 ++++++++++++++++--- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/frappe/email/doctype/email_template/email_template.json b/frappe/email/doctype/email_template/email_template.json index 0d0922f16f..dc73acacc1 100644 --- a/frappe/email/doctype/email_template/email_template.json +++ b/frappe/email/doctype/email_template/email_template.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_import": 1, "allow_rename": 1, "autoname": "Prompt", @@ -8,6 +9,8 @@ "engine": "InnoDB", "field_order": [ "subject", + "use_html", + "response_html", "response", "owner", "section_break_4", @@ -22,11 +25,12 @@ "reqd": 1 }, { + "depends_on": "eval:!doc.use_html", "fieldname": "response", "fieldtype": "Text Editor", "in_list_view": 1, "label": "Response", - "reqd": 1 + "mandatory_depends_on": "eval:!doc.use_html" }, { "default": "user", @@ -45,10 +49,24 @@ "fieldtype": "HTML", "label": "Email Reply Help", "options": "

    Email Reply Example

    \n\n
    Order Overdue\n\nTransaction {{ name }} has exceeded Due Date. Please take necessary action.\n\nDetails\n\n- Customer: {{ customer }}\n- Amount: {{ grand_total }}\n
    \n\n

    How to get fieldnames

    \n\n

    The fieldnames you can use in your email template are the fields in the document from which you are sending the email. You can find out the fields of any documents via Setup > Customize Form View and selecting the document type (e.g. Sales Invoice)

    \n\n

    Templating

    \n\n

    Templates are compiled using the Jinja Templating Language. To learn more about Jinja, read this documentation.

    \n" + }, + { + "default": "0", + "fieldname": "use_html", + "fieldtype": "Check", + "label": "Use HTML" + }, + { + "depends_on": "eval:doc.use_html", + "fieldname": "response_html", + "fieldtype": "Code", + "label": "Response ", + "options": "HTML" } ], "icon": "fa fa-comment", - "modified": "2019-10-30 14:15:00.956347", + "links": [], + "modified": "2020-11-30 14:12:50.321633", "modified_by": "Administrator", "module": "Email", "name": "Email Template", diff --git a/frappe/email/doctype/email_template/email_template.py b/frappe/email/doctype/email_template/email_template.py index 2743032331..6708e9dd3f 100644 --- a/frappe/email/doctype/email_template/email_template.py +++ b/frappe/email/doctype/email_template/email_template.py @@ -9,7 +9,29 @@ from six import string_types class EmailTemplate(Document): def validate(self): - validate_template(self.response) + if self.use_html: + validate_template(self.response_html) + else: + validate_template(self.response) + + def get_formatted_subject(self, doc): + return frappe.render_template(self.subject, doc) + + def get_formatted_response(self, doc): + if self.use_html: + return frappe.render_template(self.response_html, doc) + + return frappe.render_template(self.response, doc) + + def get_formatted_email(self, doc): + if isinstance(doc, string_types): + doc = json.loads(doc) + + return { + "subject" : self.get_formatted_subject(doc), + "message" : self.get_formatted_response(doc) + } + @frappe.whitelist() def get_email_template(template_name, doc): @@ -18,5 +40,4 @@ def get_email_template(template_name, doc): doc = json.loads(doc) email_template = frappe.get_doc("Email Template", template_name) - return {"subject" : frappe.render_template(email_template.subject, doc), - "message" : frappe.render_template(email_template.response, doc)} \ No newline at end of file + return email_template.get_formatted_email(doc) \ No newline at end of file From 76b3fd811ec84ea86085e603d3dd6423f20c489c Mon Sep 17 00:00:00 2001 From: Abhishek Balam Date: Mon, 30 Nov 2020 14:56:49 +0530 Subject: [PATCH 171/273] fix: added tests, cleaned up code --- frappe/tests/data/exif_sample_image.jpg | Bin 0 -> 161713 bytes frappe/tests/test_image.py | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 frappe/tests/data/exif_sample_image.jpg create mode 100644 frappe/tests/test_image.py diff --git a/frappe/tests/data/exif_sample_image.jpg b/frappe/tests/data/exif_sample_image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4a2c1552b991add9a356a460415626ad700f401b GIT binary patch literal 161713 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 literal 0 HcmV?d00001 diff --git a/frappe/tests/test_image.py b/frappe/tests/test_image.py new file mode 100644 index 0000000000..4da83ace8b --- /dev/null +++ b/frappe/tests/test_image.py @@ -0,0 +1,19 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt +from __future__ import unicode_literals + +import frappe, unittest +from PIL import Image +from frappe.utils.image import strip_exif_data +import io + +class TestImage(unittest.TestCase): + def test_strip_exif_data(self): + original_image = Image.open("../apps/frappe/frappe/tests/data/exif_sample_image.jpg") + original_image_content = io.open("../apps/frappe/frappe/tests/data/exif_sample_image.jpg", mode='rb').read() + + new_image_content = strip_exif_data(original_image_content) + new_image = Image.open(io.BytesIO(new_image_content)) + + self.assertEqual(new_image._getexif(), None) + self.assertNotEqual(original_image._getexif(), new_image._getexif()) \ No newline at end of file From 3a074756c1162579a2ae1cd92342248162deb208 Mon Sep 17 00:00:00 2001 From: Abhishek Balam Date: Mon, 30 Nov 2020 14:58:01 +0530 Subject: [PATCH 172/273] fix: removed content_type param --- frappe/core/doctype/file/file.py | 4 ++-- frappe/utils/image.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index 1642e857c5..8b1d8195dc 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -470,8 +470,8 @@ class File(Document): self.content_type = mimetypes.guess_type(self.file_name)[0] - if self.content_type and "image" in self.content_type: - self.content = strip_exif_data(self.content, self.content_type) + if self.content_type == "image": + self.content = strip_exif_data(self.content) self.file_size = self.check_max_file_size() self.content_hash = get_content_hash(self.content) diff --git a/frappe/utils/image.py b/frappe/utils/image.py index 3d3d98a28c..6cb5e9fc92 100644 --- a/frappe/utils/image.py +++ b/frappe/utils/image.py @@ -18,7 +18,7 @@ def resize_images(path, maxdim=700): print("resized {0}".format(os.path.join(basepath, fname))) -def strip_exif_data(content, content_type): +def strip_exif_data(content): """ Strips exif from image files which support it. Works by creating a new Image object which ignores exif by @@ -35,7 +35,7 @@ def strip_exif_data(content, content_type): new_image = Image.new(original_image.mode, original_image.size) new_image.putdata(list(original_image.getdata())) - new_image.save(output, format=content_type.split('/')[-1].upper()) + new_image.save(output, format='JPEG') # Since this is a temporary image, the extension does not matter content = output.getvalue() From f7444b7b440e770a34ff23d64c9c5c41ddc405d2 Mon Sep 17 00:00:00 2001 From: Abhishek Balam Date: Mon, 30 Nov 2020 15:01:07 +0530 Subject: [PATCH 173/273] fix: removed unused import --- frappe/tests/test_image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/tests/test_image.py b/frappe/tests/test_image.py index 4da83ace8b..89cbc3225a 100644 --- a/frappe/tests/test_image.py +++ b/frappe/tests/test_image.py @@ -2,7 +2,7 @@ # MIT License. See license.txt from __future__ import unicode_literals -import frappe, unittest +import unittest from PIL import Image from frappe.utils.image import strip_exif_data import io From f8fd59b6eb26b03c324fe06f0ed10409ae5ac0a4 Mon Sep 17 00:00:00 2001 From: Rohan Bansal Date: Mon, 30 Nov 2020 15:15:19 +0530 Subject: [PATCH 174/273] fix: remove HTTP filter --- frappe/utils/change_log.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frappe/utils/change_log.py b/frappe/utils/change_log.py index 9607c89784..7cea1554b4 100644 --- a/frappe/utils/change_log.py +++ b/frappe/utils/change_log.py @@ -217,15 +217,13 @@ def check_release_on_github(app: str): # Invalid URL return - # Get latest version from Github - if parsed_url.protocol == "http": - return if parsed_url.resource != "github.com": return owner = parsed_url.owner repo = parsed_url.name + # Get latest version from Github r = requests.get('https://api.github.com/repos/{}/{}/releases'.format(owner, repo)) if r.ok: latest_non_beta_release = parse_latest_non_beta_release(r.json()) From 4810d07a8a4823a31ceef66b0896a991123ece6e Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 30 Nov 2020 15:23:12 +0530 Subject: [PATCH 175/273] test: Add tests for throwing AttributeError if method not found --- .../doctype/server_script/test_server_script.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/frappe/core/doctype/server_script/test_server_script.py b/frappe/core/doctype/server_script/test_server_script.py index 3356e584af..256cea57d7 100644 --- a/frappe/core/doctype/server_script/test_server_script.py +++ b/frappe/core/doctype/server_script/test_server_script.py @@ -45,6 +45,15 @@ frappe.response['message'] = 'hello' allow_guest = 1, script = ''' frappe.flags = 'hello' +''' + ), + dict( + name='test_invalid_namespace_method', + script_type = 'DocType Event', + doctype_event = 'Before Insert', + reference_doctype = 'Note', + script = ''' +frappe.method_that_doesnt_exist("do some magic") ''' ) ] @@ -85,3 +94,8 @@ class TestServerScript(unittest.TestCase): def test_api_return(self): self.assertEqual(frappe.get_doc('Server Script', 'test_return_value').execute_method(), 'hello') + + def test_attribute_error(self): + """Raise AttributeError if method not found in Namespace""" + note = frappe.get_doc({"doctype": "Note", "title": "Test Note: Server Script"}) + self.assertRaises(AttributeError, note.insert) From d5d0bc8ea9983438366c059edea0be19a26bf359 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 30 Nov 2020 15:23:42 +0530 Subject: [PATCH 176/273] fix: Return dummy function to avoid NoneType not callable --- frappe/utils/safe_exec.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frappe/utils/safe_exec.py b/frappe/utils/safe_exec.py index 50893330be..2aacf5eda8 100644 --- a/frappe/utils/safe_exec.py +++ b/frappe/utils/safe_exec.py @@ -21,7 +21,9 @@ class NamespaceDict(frappe._dict): def __getattr__(self, key): ret = self.get(key) if (not ret and key.startswith("__")) or (key not in self): - raise AttributeError(f"module has no attribute '{key}'") + def default_function(*args, **kwargs): + raise AttributeError(f"module has no attribute '{key}'") + return default_function return ret From 3e1f6c8103fd705c67968d5b1c9f46867af0ecd5 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 30 Nov 2020 15:37:29 +0530 Subject: [PATCH 177/273] style: Use f-string instead of format --- frappe/utils/change_log.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/utils/change_log.py b/frappe/utils/change_log.py index 7cea1554b4..33801af722 100644 --- a/frappe/utils/change_log.py +++ b/frappe/utils/change_log.py @@ -223,8 +223,8 @@ def check_release_on_github(app: str): owner = parsed_url.owner repo = parsed_url.name - # Get latest version from Github - r = requests.get('https://api.github.com/repos/{}/{}/releases'.format(owner, repo)) + # Get latest version from GitHub + r = requests.get(f"https://api.github.com/repos/{owner}/{repo}/releases") if r.ok: latest_non_beta_release = parse_latest_non_beta_release(r.json()) if latest_non_beta_release: From 3296097df606df2998f01dde927541836504d6bc Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 30 Nov 2020 15:46:29 +0530 Subject: [PATCH 178/273] feat: show absolute value in print format --- frappe/model/base_document.py | 6 +++--- frappe/printing/doctype/print_format/print_format.json | 9 ++++++++- frappe/templates/print_formats/standard_macros.html | 5 ++--- frappe/www/printview.py | 1 + 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 0a219b4253..5d86b3bac8 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -802,12 +802,12 @@ class BaseDocument(object): if translated: val = _(val) - if absolute_value and isinstance(val, (int, float)): - val = abs(self.get(fieldname)) - if not doc: doc = getattr(self, "parent_doc", None) or self + if (absolute_value or doc.get('absolute_value')) and isinstance(val, (int, float)): + val = abs(self.get(fieldname)) + return format_value(val, df=df, doc=doc, currency=currency) def is_print_hide(self, fieldname, df=None, for_print=True): diff --git a/frappe/printing/doctype/print_format/print_format.json b/frappe/printing/doctype/print_format/print_format.json index 63448ccc39..3867ce4502 100644 --- a/frappe/printing/doctype/print_format/print_format.json +++ b/frappe/printing/doctype/print_format/print_format.json @@ -22,6 +22,7 @@ "align_labels_right", "show_section_headings", "line_breaks", + "absolute_value", "column_break_11", "font", "css_section", @@ -196,13 +197,19 @@ "fieldtype": "Check", "hidden": 1, "label": "Print Format Builder" + }, + { + "default": "0", + "fieldname": "absolute_value", + "fieldtype": "Check", + "label": "Show absolute values" } ], "icon": "fa fa-print", "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2020-10-27 18:27:58.307070", + "modified": "2020-11-30 15:26:35.605213", "modified_by": "Administrator", "module": "Printing", "name": "Print Format", diff --git a/frappe/templates/print_formats/standard_macros.html b/frappe/templates/print_formats/standard_macros.html index 3681a87f53..168547798b 100644 --- a/frappe/templates/print_formats/standard_macros.html +++ b/frappe/templates/print_formats/standard_macros.html @@ -136,10 +136,9 @@ data-fieldname="{{ df.fieldname }}" data-fieldtype="{{ df.fieldtype }}" {%- if df.print_width %} style="width: {{ get_width(df) }};"{% endif %}> {% elif df.fieldtype=="HTML" %} {{ frappe.render_template(df.options, {"doc":doc}) }} - {% elif df.fieldtype=="Currency" %} - {{ doc.get_formatted(df.fieldname, doc, translated=df.translatable) }} {% else %} - {{ doc.get_formatted(df.fieldname, parent_doc or doc, translated=df.translatable) }} + {%- set parent = parent_doc or doc -%} + {{ doc.get_formatted(df.fieldname, parent, translated=df.translatable, absolute_value=parent.absolute_value) }} {% endif %} {%- endmacro %} diff --git a/frappe/www/printview.py b/frappe/www/printview.py index 545e5d581d..71316dc48c 100644 --- a/frappe/www/printview.py +++ b/frappe/www/printview.py @@ -100,6 +100,7 @@ def get_rendered_template(doc, name=None, print_format=None, meta=None, doc.print_section_headings = print_format.show_section_headings doc.print_line_breaks = print_format.line_breaks doc.align_labels_right = print_format.align_labels_right + doc.absolute_value = print_format.absolute_value def get_template_from_string(): return jenv.from_string(get_print_format(doc.doctype, From 7d4992549d69bc5485dad51cf75fa60f6143fcc5 Mon Sep 17 00:00:00 2001 From: Abhishek Balam Date: Mon, 30 Nov 2020 17:19:35 +0530 Subject: [PATCH 179/273] feat: added option in system settings to remove exif tags, default 1 --- frappe/core/doctype/file/file.py | 5 +++-- frappe/core/doctype/system_settings/system_settings.json | 9 ++++++++- frappe/utils/image.py | 4 ++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index 8b1d8195dc..34ff1f5599 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -470,8 +470,9 @@ class File(Document): self.content_type = mimetypes.guess_type(self.file_name)[0] - if self.content_type == "image": - self.content = strip_exif_data(self.content) + if self.content_type and "image" in self.content_type and \ + frappe.get_system_settings("remove_exif_tags"): + self.content = strip_exif_data(self.content, self.content_type) self.file_size = self.check_max_file_size() self.content_hash = get_content_hash(self.content) diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index 17f97b3e1a..987c079b77 100644 --- a/frappe/core/doctype/system_settings/system_settings.json +++ b/frappe/core/doctype/system_settings/system_settings.json @@ -37,6 +37,7 @@ "allow_login_using_mobile_number", "allow_login_using_user_name", "allow_error_traceback", + "remove_exif_tags", "password_settings", "logout_on_password_reset", "force_user_to_reset_password", @@ -460,12 +461,18 @@ "fieldname": "prepared_report_section", "fieldtype": "Section Break", "label": "Prepared Report" + }, + { + "default": "1", + "fieldname": "remove_exif_tags", + "fieldtype": "Check", + "label": "Remove EXIF tags from uploaded images" } ], "icon": "fa fa-cog", "issingle": 1, "links": [], - "modified": "2020-08-12 14:35:45.214327", + "modified": "2020-11-30 17:04:06.785282", "modified_by": "Administrator", "module": "Core", "name": "System Settings", diff --git a/frappe/utils/image.py b/frappe/utils/image.py index 6cb5e9fc92..4643429691 100644 --- a/frappe/utils/image.py +++ b/frappe/utils/image.py @@ -18,7 +18,7 @@ def resize_images(path, maxdim=700): print("resized {0}".format(os.path.join(basepath, fname))) -def strip_exif_data(content): +def strip_exif_data(content, content_type): """ Strips exif from image files which support it. Works by creating a new Image object which ignores exif by @@ -35,7 +35,7 @@ def strip_exif_data(content): new_image = Image.new(original_image.mode, original_image.size) new_image.putdata(list(original_image.getdata())) - new_image.save(output, format='JPEG') # Since this is a temporary image, the extension does not matter + new_image.save(output, format=content_type.split('/')[1]) content = output.getvalue() From 8f8489d9b39a8a5a649b93c0e3f5665d44275494 Mon Sep 17 00:00:00 2001 From: Abhishek Balam Date: Mon, 30 Nov 2020 17:43:28 +0530 Subject: [PATCH 180/273] fix: test fix --- frappe/tests/test_image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/tests/test_image.py b/frappe/tests/test_image.py index 89cbc3225a..b8ed0792b6 100644 --- a/frappe/tests/test_image.py +++ b/frappe/tests/test_image.py @@ -12,7 +12,7 @@ class TestImage(unittest.TestCase): original_image = Image.open("../apps/frappe/frappe/tests/data/exif_sample_image.jpg") original_image_content = io.open("../apps/frappe/frappe/tests/data/exif_sample_image.jpg", mode='rb').read() - new_image_content = strip_exif_data(original_image_content) + new_image_content = strip_exif_data(original_image_content, "image/jpeg") new_image = Image.open(io.BytesIO(new_image_content)) self.assertEqual(new_image._getexif(), None) From e306f5601c0a841b5bda80c4ee9bd394d12c9eff Mon Sep 17 00:00:00 2001 From: Abhishek Balam Date: Mon, 30 Nov 2020 18:58:53 +0530 Subject: [PATCH 181/273] fix: made requested changes --- frappe/core/doctype/file/file.py | 9 ++++++--- .../system_settings/system_settings.json | 8 ++++---- frappe/tests/test_image.py | 19 ------------------- frappe/tests/test_utils.py | 15 +++++++++++++++ frappe/utils/image.py | 5 +++-- 5 files changed, 28 insertions(+), 28 deletions(-) delete mode 100644 frappe/tests/test_image.py diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index 34ff1f5599..8614740d26 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -470,11 +470,14 @@ class File(Document): self.content_type = mimetypes.guess_type(self.file_name)[0] - if self.content_type and "image" in self.content_type and \ - frappe.get_system_settings("remove_exif_tags"): + self.file_size = self.check_max_file_size() + + if ( + self.content_type and "image" in self.content_type + and frappe.get_system_settings("strip_exif_metadata_from_uploaded_images") + ): self.content = strip_exif_data(self.content, self.content_type) - self.file_size = self.check_max_file_size() self.content_hash = get_content_hash(self.content) duplicate_file = None diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index 987c079b77..79fb84923a 100644 --- a/frappe/core/doctype/system_settings/system_settings.json +++ b/frappe/core/doctype/system_settings/system_settings.json @@ -37,7 +37,7 @@ "allow_login_using_mobile_number", "allow_login_using_user_name", "allow_error_traceback", - "remove_exif_tags", + "strip_exif_metadata_from_uploaded_images", "password_settings", "logout_on_password_reset", "force_user_to_reset_password", @@ -464,15 +464,15 @@ }, { "default": "1", - "fieldname": "remove_exif_tags", + "fieldname": "strip_exif_metadata_from_uploaded_images", "fieldtype": "Check", - "label": "Remove EXIF tags from uploaded images" + "label": "Strip EXIF tags from uploaded images" } ], "icon": "fa fa-cog", "issingle": 1, "links": [], - "modified": "2020-11-30 17:04:06.785282", + "modified": "2020-11-30 18:52:22.161391", "modified_by": "Administrator", "module": "Core", "name": "System Settings", diff --git a/frappe/tests/test_image.py b/frappe/tests/test_image.py deleted file mode 100644 index b8ed0792b6..0000000000 --- a/frappe/tests/test_image.py +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# MIT License. See license.txt -from __future__ import unicode_literals - -import unittest -from PIL import Image -from frappe.utils.image import strip_exif_data -import io - -class TestImage(unittest.TestCase): - def test_strip_exif_data(self): - original_image = Image.open("../apps/frappe/frappe/tests/data/exif_sample_image.jpg") - original_image_content = io.open("../apps/frappe/frappe/tests/data/exif_sample_image.jpg", mode='rb').read() - - new_image_content = strip_exif_data(original_image_content, "image/jpeg") - new_image = Image.open(io.BytesIO(new_image_content)) - - self.assertEqual(new_image._getexif(), None) - self.assertNotEqual(original_image._getexif(), new_image._getexif()) \ No newline at end of file diff --git a/frappe/tests/test_utils.py b/frappe/tests/test_utils.py index 8cdfe3e1a9..ebba60b8e8 100644 --- a/frappe/tests/test_utils.py +++ b/frappe/tests/test_utils.py @@ -7,6 +7,10 @@ import unittest from frappe.utils import evaluate_filters, money_in_words, scrub_urls, get_url from frappe.utils import ceil, floor +from PIL import Image +from frappe.utils.image import strip_exif_data +import io + class TestFilters(unittest.TestCase): def test_simple_dict(self): self.assertTrue(evaluate_filters({'doctype': 'User', 'status': 'Open'}, {'status': 'Open'})) @@ -122,3 +126,14 @@ class TestHTMLUtils(unittest.TestCase): clean = clean_email_html(sample) self.assertTrue('

    Hello

    ' in clean) self.assertTrue('text' in clean) + +class TestImage(unittest.TestCase): + def test_strip_exif_data(self): + original_image = Image.open("../apps/frappe/frappe/tests/data/exif_sample_image.jpg") + original_image_content = io.open("../apps/frappe/frappe/tests/data/exif_sample_image.jpg", mode='rb').read() + + new_image_content = strip_exif_data(original_image_content, "image/jpeg") + new_image = Image.open(io.BytesIO(new_image_content)) + + self.assertEqual(new_image._getexif(), None) + self.assertNotEqual(original_image._getexif(), new_image._getexif()) \ No newline at end of file diff --git a/frappe/utils/image.py b/frappe/utils/image.py index 4643429691..60595464a1 100644 --- a/frappe/utils/image.py +++ b/frappe/utils/image.py @@ -19,12 +19,13 @@ def resize_images(path, maxdim=700): print("resized {0}".format(os.path.join(basepath, fname))) def strip_exif_data(content, content_type): - """ Strips exif from image files which support it. + """ Strips EXIF from image files which support it. Works by creating a new Image object which ignores exif by default and then extracts the binary data back into content. - Returns: stripped image content + Returns: + Bytes: Stripped image content """ from PIL import Image From e760c39c68d9d9d9c2561fc5989f2a218b849821 Mon Sep 17 00:00:00 2001 From: Leela vadlamudi Date: Tue, 1 Dec 2020 16:34:19 +0530 Subject: [PATCH 182/273] chore: Add .editorconfig to enforce consistent styling (#12021) Github respects editorconfig settings. Adding indent_size as 4 in settings makes github indent code to 4 columns. --- .editorconfig | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..24f122a8d4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +# Root editor config file +root = true + +# Common settings +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 + +# python, js indentation settings +[{*.py,*.js}] +indent_style = tab +indent_size = 4 From fa80d26f4c2a29ccbdef70058d600114afb42aa9 Mon Sep 17 00:00:00 2001 From: Rushabh Mehta Date: Wed, 2 Dec 2020 09:46:48 +0530 Subject: [PATCH 183/273] fix(minor): update requirements.txt --- requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 59c4a9dbf8..3cc92264a2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -35,7 +35,7 @@ oauthlib==3.1.0 openpyxl==2.6.4 passlib==1.7.3 pdfkit==0.6.1 -Pillow==7.1.0 +Pillow>=8.0.0 premailer==3.6.1 psycopg2-binary==2.8.4 pyasn1==0.4.8 @@ -74,4 +74,4 @@ pycryptodome==3.9.8 paytmchecksum==1.7.0 wrapt==1.10.11 razorpay==1.2.0 -rsa>=4.1 # not directly required, pinned by Snyk to avoid a vulnerability \ No newline at end of file +rsa>=4.1 # not directly required, pinned by Snyk to avoid a vulnerability From aadbdfbd84482a37ab8503a5cc5a868019138346 Mon Sep 17 00:00:00 2001 From: Anuja Pawar <60467153+Anuja-pawar@users.noreply.github.com> Date: Wed, 2 Dec 2020 17:51:38 +0530 Subject: [PATCH 184/273] refactor(System Settings): Remove redundant code (#12027) Co-authored-by: Anuja --- .../system_settings/system_settings.js | 63 +++++++++---------- 1 file changed, 31 insertions(+), 32 deletions(-) diff --git a/frappe/core/doctype/system_settings/system_settings.js b/frappe/core/doctype/system_settings/system_settings.js index b6514dea9f..c0c9074cbc 100644 --- a/frappe/core/doctype/system_settings/system_settings.js +++ b/frappe/core/doctype/system_settings/system_settings.js @@ -1,37 +1,36 @@ -frappe.ui.form.on("System Settings", "refresh", function(frm) { - frappe.call({ - method: "frappe.core.doctype.system_settings.system_settings.load", - callback: function(data) { - frappe.all_timezones = data.message.timezones; - frm.set_df_property("time_zone", "options", frappe.all_timezones); +frappe.ui.form.on("System Settings", { + refresh: function(frm) { + frappe.call({ + method: "frappe.core.doctype.system_settings.system_settings.load", + callback: function(data) { + frappe.all_timezones = data.message.timezones; + frm.set_df_property("time_zone", "options", frappe.all_timezones); - $.each(data.message.defaults, function(key, val) { - frm.set_value(key, val); - frappe.sys_defaults[key] = val; - }) + $.each(data.message.defaults, function(key, val) { + frm.set_value(key, val); + frappe.sys_defaults[key] = val; + }); + } + }); + }, + enable_password_policy: function(frm) { + if (frm.doc.enable_password_policy == 0) { + frm.set_value("minimum_password_score", ""); + } else { + frm.set_value("minimum_password_score", "2"); } - }); -}); - -frappe.ui.form.on("System Settings", "enable_password_policy", function(frm) { - if(frm.doc.enable_password_policy == 0){ - frm.set_value("minimum_password_score", ""); - } else { - frm.set_value("minimum_password_score", "2"); - } -}); - -frappe.ui.form.on("System Settings", "enable_two_factor_auth", function(frm) { - if(frm.doc.enable_two_factor_auth == 0){ - frm.set_value("bypass_2fa_for_retricted_ip_users", 0); - frm.set_value("bypass_restrict_ip_check_if_2fa_enabled", 0); - } -}); - -frappe.ui.form.on("System Settings", "enable_prepared_report_auto_deletion", function(frm) { - if (frm.doc.enable_prepared_report_auto_deletion) { - if (!frm.doc.prepared_report_expiry_period) { - frm.set_value('prepared_report_expiry_period', 7); + }, + enable_two_factor_auth: function(frm) { + if (frm.doc.enable_two_factor_auth == 0) { + frm.set_value("bypass_2fa_for_retricted_ip_users", 0); + frm.set_value("bypass_restrict_ip_check_if_2fa_enabled", 0); + } + }, + enable_prepared_report_auto_deletion: function(frm) { + if (frm.doc.enable_prepared_report_auto_deletion) { + if (!frm.doc.prepared_report_expiry_period) { + frm.set_value('prepared_report_expiry_period', 7); + } } } }); From a19932835a8e8ab66042e19c8ae52f87a19af946 Mon Sep 17 00:00:00 2001 From: Anupam Date: Thu, 3 Dec 2020 12:30:10 +0530 Subject: [PATCH 185/273] feat: provision to open child table in customize form --- frappe/custom/doctype/customize_form/customize_form.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frappe/custom/doctype/customize_form/customize_form.js b/frappe/custom/doctype/customize_form/customize_form.js index 2d220b864c..17343573ed 100644 --- a/frappe/custom/doctype/customize_form/customize_form.js +++ b/frappe/custom/doctype/customize_form/customize_form.js @@ -81,6 +81,11 @@ frappe.ui.form.on("Customize Form", { } else { f._sortable = false; } + if (f.fieldtype == "Table") { + frm.add_custom_button(f.options, function() { + frm.set_value('doc_type', f.options); + }, __('Customize Child Table')); + } }); frm.fields_dict.fields.grid.refresh(); }, From 6c48c2d14aa81d9ed2dc0545f45cda6672e41cef Mon Sep 17 00:00:00 2001 From: Mohammad Hasnain Date: Thu, 3 Dec 2020 14:10:47 +0530 Subject: [PATCH 186/273] fix(charts): escape nan in each y axis --- frappe/public/js/frappe/views/reports/report_utils.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/views/reports/report_utils.js b/frappe/public/js/frappe/views/reports/report_utils.js index 158dbd653b..ebb08bd24b 100644 --- a/frappe/public/js/frappe/views/reports/report_utils.js +++ b/frappe/public/js/frappe/views/reports/report_utils.js @@ -15,7 +15,9 @@ frappe.report_utils = { if (raw_data.add_total_row) { labels = labels.slice(0, -1); - datasets[0].values = datasets[0].values.slice(0, -1); + datasets.forEach(dataset => { + dataset.values = dataset.values.slice(0, -1); + }); } return { From ee3fa3e4e0ebf414a9a7e777a509f6a7535be0a0 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Thu, 3 Dec 2020 22:39:38 +0530 Subject: [PATCH 187/273] fix: Remove unreferenced variable base_path --- frappe/commands/site.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/frappe/commands/site.py b/frappe/commands/site.py index bc65aa178c..35f5b13582 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -100,12 +100,10 @@ def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_pas # Extract public and/or private files to the restored site, if user has given the path if with_public_files: - with_public_files = os.path.join(base_path, with_public_files) public = extract_files(site, with_public_files, 'public') os.remove(public) if with_private_files: - with_private_files = os.path.join(base_path, with_private_files) private = extract_files(site, with_private_files, 'private') os.remove(private) From 9ac14fb5abf6930234e4da41217dd74718c858da Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Thu, 3 Dec 2020 23:26:34 +0530 Subject: [PATCH 188/273] feat: Permission Query script type Add dynamic conditions in where clause of get_list query --- .../doctype/server_script/server_script.js | 41 ++++++++++++++----- .../doctype/server_script/server_script.json | 6 +-- .../doctype/server_script/server_script.py | 6 +++ .../server_script/server_script_utils.py | 11 ++++- .../server_script/test_server_script.py | 12 ++++++ frappe/model/db_query.py | 13 +++++- 6 files changed, 71 insertions(+), 18 deletions(-) diff --git a/frappe/core/doctype/server_script/server_script.js b/frappe/core/doctype/server_script/server_script.js index 78ef2d0509..a317d69166 100644 --- a/frappe/core/doctype/server_script/server_script.js +++ b/frappe/core/doctype/server_script/server_script.js @@ -48,29 +48,33 @@ frappe.ui.form.on('Server Script', { setup_help(frm) { frm.get_field('help_html').html(` -

    Examples

    DocType Event

    -
    
    +

    Add logic for standard doctype events like Before Insert, After Submit, etc.

    +
    +	
     # set property
     if "test" in doc.description:
    -    doc.status = 'Closed'
    +	doc.status = 'Closed'
     
     
     # validate
     if "validate" in doc.description:
    -    raise frappe.ValidationError
    +	raise frappe.ValidationError
     
     # auto create another document
    -if doc.allocted_to:
    -    frappe.get_doc(dict(
    -        doctype = 'ToDo'
    -        owner = doc.allocated_to,
    -        description = doc.subject
    -    )).insert()
    -
    +if doc.allocated_to: + frappe.get_doc(dict( + doctype = 'ToDo' + owner = doc.allocated_to, + description = doc.subject + )).insert() +
    +
    +

    API Call

    +

    Respond to /api/method/<method-name> calls, just like whitelisted methods

    
     # respond to API
     
    @@ -79,6 +83,21 @@ if frappe.form_dict.message == "ping":
     else:
     	frappe.response['message'] = "ok"
     
    + +
    + +

    Permission Query

    +

    Add conditions to the where clause of list queries.

    +
    
    +# generate dynamic conditions and set it in the conditions variable
    +tenant_id = frappe.db.get_value(...)
    +conditions = 'tenant_id = {}'.format(tenant_id)
    +
    +# resulting select query
    +select name from \`tabPerson\`
    +where tenant_id = 2
    +order by creation desc
    +
    `); } diff --git a/frappe/core/doctype/server_script/server_script.json b/frappe/core/doctype/server_script/server_script.json index 420f96ec2f..94a48f196c 100644 --- a/frappe/core/doctype/server_script/server_script.json +++ b/frappe/core/doctype/server_script/server_script.json @@ -24,7 +24,7 @@ "fieldtype": "Select", "in_list_view": 1, "label": "Script Type", - "options": "DocType Event\nScheduler Event\nAPI", + "options": "DocType Event\nScheduler Event\nPermission Query\nAPI", "reqd": 1 }, { @@ -35,7 +35,7 @@ "reqd": 1 }, { - "depends_on": "eval:doc.script_type==='DocType Event'", + "depends_on": "eval:['DocType Event', 'Permission Query'].includes(doc.script_type)", "fieldname": "reference_doctype", "fieldtype": "Link", "in_list_view": 1, @@ -88,7 +88,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2020-11-11 12:39:41.391052", + "modified": "2020-12-03 22:42:02.708148", "modified_by": "Administrator", "module": "Core", "name": "Server Script", diff --git a/frappe/core/doctype/server_script/server_script.py b/frappe/core/doctype/server_script/server_script.py index 839b784651..b1bf79dc52 100644 --- a/frappe/core/doctype/server_script/server_script.py +++ b/frappe/core/doctype/server_script/server_script.py @@ -41,6 +41,12 @@ class ServerScript(Document): # wrong report type! raise frappe.DoesNotExistError + def get_permission_query_conditions(self, user): + locals = {"user": user, "conditions": ""} + safe_exec(self.script, None, locals) + if locals["conditions"]: + return locals["conditions"] + @frappe.whitelist() def setup_scheduler_events(script_name, frequency): method = frappe.scrub('{0}-{1}'.format(script_name, frequency)) diff --git a/frappe/core/doctype/server_script/server_script_utils.py b/frappe/core/doctype/server_script/server_script_utils.py index e03504f30b..4dc4f12b34 100644 --- a/frappe/core/doctype/server_script/server_script_utils.py +++ b/frappe/core/doctype/server_script/server_script_utils.py @@ -50,6 +50,9 @@ def get_server_script_map(): # }, # '_api': { # '[path]': '[server script]' + # }, + # 'permission_query': { + # 'DocType': '[server script]' # } # } if frappe.flags.in_patch and not frappe.db.table_exists('Server Script'): @@ -57,16 +60,20 @@ def get_server_script_map(): script_map = frappe.cache().get_value('server_script_map') if script_map is None: - script_map = {} + script_map = { + 'permission_query': {} + } enabled_server_scripts = frappe.get_all('Server Script', fields=('name', 'reference_doctype', 'doctype_event','api_method', 'script_type'), filters={'disabled': 0}) for script in enabled_server_scripts: if script.script_type == 'DocType Event': script_map.setdefault(script.reference_doctype, {}).setdefault(script.doctype_event, []).append(script.name) + elif script.script_type == 'Permission Query': + script_map['permission_query'][script.reference_doctype] = script.name else: script_map.setdefault('_api', {})[script.api_method] = script.name frappe.cache().set_value('server_script_map', script_map) - return script_map \ No newline at end of file + return script_map diff --git a/frappe/core/doctype/server_script/test_server_script.py b/frappe/core/doctype/server_script/test_server_script.py index 3356e584af..565436d9f2 100644 --- a/frappe/core/doctype/server_script/test_server_script.py +++ b/frappe/core/doctype/server_script/test_server_script.py @@ -45,6 +45,14 @@ frappe.response['message'] = 'hello' allow_guest = 1, script = ''' frappe.flags = 'hello' +''' + ), + dict( + name='test_permission_query', + script_type = 'Permission Query', + reference_doctype = 'ToDo', + script = ''' +conditions = '1 = 1' ''' ) ] @@ -85,3 +93,7 @@ class TestServerScript(unittest.TestCase): def test_api_return(self): self.assertEqual(frappe.get_doc('Server Script', 'test_return_value').execute_method(), 'hello') + + def test_permission_query(self): + self.assertTrue('where (1 = 1)' in frappe.db.get_list('ToDo', return_query=1)) + self.assertTrue(isinstance(frappe.db.get_list('ToDo'), list)) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index ace9b04cec..b936251b50 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -18,6 +18,7 @@ from frappe.client import check_parent_permission from frappe.model.utils.user_settings import get_user_settings, update_user_settings from frappe.utils import flt, cint, get_time, make_filter_tuple, get_filter, add_to_date, cstr, get_timespan_date_range from frappe.model.meta import get_table_columns +from frappe.core.doctype.server_script.server_script_utils import get_server_script_map class DatabaseQuery(object): def __init__(self, doctype, user=None): @@ -683,15 +684,23 @@ class DatabaseQuery(object): self.match_filters.append(match_filters) def get_permission_query_conditions(self): + conditions = [] condition_methods = frappe.get_hooks("permission_query_conditions", {}).get(self.doctype, []) if condition_methods: - conditions = [] for method in condition_methods: c = frappe.call(frappe.get_attr(method), self.user) if c: conditions.append(c) - return " and ".join(conditions) if conditions else None + permision_script_name = get_server_script_map().get("permission_query").get(self.doctype) + if permision_script_name: + script = frappe.get_doc("Server Script", permision_script_name) + condition = script.get_permission_query_conditions(self.user) + if condition: + conditions.append(condition) + + return " and ".join(conditions) if conditions else "" + def run_custom_query(self, query): if '%(key)s' in query: From e338f2f7b6cb5e446dacd9a7527d12e1c4806405 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Thu, 3 Dec 2020 23:56:26 +0530 Subject: [PATCH 189/273] fix(newsletter): Render template for HTML content type --- frappe/email/doctype/newsletter/newsletter.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frappe/email/doctype/newsletter/newsletter.py b/frappe/email/doctype/newsletter/newsletter.py index a4d60706eb..2791ebb75b 100755 --- a/frappe/email/doctype/newsletter/newsletter.py +++ b/frappe/email/doctype/newsletter/newsletter.py @@ -85,11 +85,11 @@ class Newsletter(WebsiteGenerator): self.db_set("scheduled_to_send", len(self.recipients)) def get_message(self): - + if self.content_type == "HTML": + return frappe.render_template(self.message_html, {"doc": self.as_dict()}) return { 'Rich Text': self.message, - 'Markdown': markdown(self.message_md), - 'HTML': self.message_html + 'Markdown': markdown(self.message_md) }[self.content_type or 'Rich Text'] def get_recipients(self): From 69befda08efa8d42f6c9ea2bb0fd84936d124f99 Mon Sep 17 00:00:00 2001 From: prssanna Date: Fri, 4 Dec 2020 16:12:22 +0530 Subject: [PATCH 190/273] fix: label for _assign field --- frappe/model/meta.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frappe/model/meta.py b/frappe/model/meta.py index 8c17a5b19b..c740d495c1 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -209,7 +209,8 @@ class Meta(Document): 'owner': _('Created By'), 'modified_by': _('Modified By'), 'creation': _('Created On'), - 'modified': _('Last Modified On') + 'modified': _('Last Modified On'), + '_assign': _('Assigned To') }.get(fieldname) or _('No Label') return label From 9d060f96533679b7f9fa187e63ae548a73f4258f Mon Sep 17 00:00:00 2001 From: Abhishek Balam Date: Fri, 4 Dec 2020 17:01:55 +0530 Subject: [PATCH 191/273] fix: cstr import added --- frappe/utils/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py index 9640bcd394..d76e6c951e 100644 --- a/frappe/utils/__init__.py +++ b/frappe/utils/__init__.py @@ -10,7 +10,7 @@ import functools from .html_utils import sanitize_html import frappe from frappe.utils.identicon import Identicon -from email.utils import parseaddr, formataddr +from email.utils import parseaddr, formataddr, cstr from email.header import decode_header, make_header # utility functions like cint, int, flt, etc. from frappe.utils.data import * From d229e363b39d26a0ff11ee4efb2ed2693da6c158 Mon Sep 17 00:00:00 2001 From: Abhishek Balam Date: Fri, 4 Dec 2020 17:10:42 +0530 Subject: [PATCH 192/273] fix: revert cstr import added --- frappe/utils/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py index d76e6c951e..9640bcd394 100644 --- a/frappe/utils/__init__.py +++ b/frappe/utils/__init__.py @@ -10,7 +10,7 @@ import functools from .html_utils import sanitize_html import frappe from frappe.utils.identicon import Identicon -from email.utils import parseaddr, formataddr, cstr +from email.utils import parseaddr, formataddr from email.header import decode_header, make_header # utility functions like cint, int, flt, etc. from frappe.utils.data import * From 1b609af8e5de0b0cc75fdd9d60e8fe9748371180 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 4 Dec 2020 18:15:17 +0530 Subject: [PATCH 193/273] fix: Handle paths relative to bench root and sites folder --- frappe/commands/site.py | 4 ++-- frappe/installer.py | 20 +++++++------------- frappe/utils/__init__.py | 24 ++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/frappe/commands/site.py b/frappe/commands/site.py index 35f5b13582..4a631be3ac 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -100,11 +100,11 @@ def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_pas # Extract public and/or private files to the restored site, if user has given the path if with_public_files: - public = extract_files(site, with_public_files, 'public') + public = extract_files(site, with_public_files) os.remove(public) if with_private_files: - private = extract_files(site, with_private_files, 'private') + private = extract_files(site, with_private_files) os.remove(private) # Removing temporarily created file diff --git a/frappe/installer.py b/frappe/installer.py index 1245a08cb7..a11c8dfbfa 100755 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -440,20 +440,11 @@ def extract_sql_from_archive(sql_file_path): Returns: str: Path of the decompressed SQL file """ + from frappe.utils import get_bench_relative_path + sql_file_path = get_bench_relative_path(sql_file_path) # Extract the gzip file if user has passed *.sql.gz file instead of *.sql file - if not os.path.exists(sql_file_path): - base_path = '..' - sql_file_path = os.path.join(base_path, sql_file_path) - if not os.path.exists(sql_file_path): - print('Invalid path {0}'.format(sql_file_path[3:])) - sys.exit(1) - elif sql_file_path.startswith(os.sep): - base_path = os.sep - else: - base_path = '.' - if sql_file_path.endswith('sql.gz'): - decompressed_file_name = extract_sql_gzip(os.path.abspath(sql_file_path)) + decompressed_file_name = extract_sql_gzip(sql_file_path) else: decompressed_file_name = sql_file_path @@ -475,9 +466,12 @@ def extract_sql_gzip(sql_gz_path): return decompressed_file -def extract_files(site_name, file_path, folder_name): +def extract_files(site_name, file_path): import shutil import subprocess + from frappe.utils import get_bench_relative_path + + file_path = get_bench_relative_path(file_path) # Need to do frappe.init to maintain the site locals frappe.init(site=site_name) diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py index c209ee13c9..cc5b42acb7 100644 --- a/frappe/utils/__init__.py +++ b/frappe/utils/__init__.py @@ -729,3 +729,27 @@ def get_build_version(): # .build can sometimes not exist # this is not a major problem so send fallback return frappe.utils.random_string(8) + +def get_bench_relative_path(file_path): + """Fixes paths relative to the bench root directory if exists and returns the absolute path + + Args: + file_path (str, Path): Path of a file that exists on the file system + + Returns: + str: Absolute path of the file_path + """ + if not os.path.exists(file_path): + base_path = '..' + elif file_path.startswith(os.sep): + base_path = os.sep + else: + base_path = '.' + + file_path = os.path.join(base_path, file_path) + + if not os.path.exists(file_path): + print('Invalid path {0}'.format(file_path[3:])) + sys.exit(1) + + return os.path.abspath(file_path) From beec0b48fbac14766d005d73c874b4b874420d91 Mon Sep 17 00:00:00 2001 From: prssanna Date: Fri, 4 Dec 2020 18:31:16 +0530 Subject: [PATCH 194/273] fix: use frappe.utils.shorten_number --- frappe/public/js/frappe/widgets/number_card_widget.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/widgets/number_card_widget.js b/frappe/public/js/frappe/widgets/number_card_widget.js index 20503ed4f1..8855de435b 100644 --- a/frappe/public/js/frappe/widgets/number_card_widget.js +++ b/frappe/public/js/frappe/widgets/number_card_widget.js @@ -269,7 +269,7 @@ export default class NumberCardWidget extends Widget { result: this.number }).then(res => { if (res !== undefined) { - this.percentage_stat = shorten_number(res); + this.percentage_stat = frappe.utils.shorten_number(res); } }); } From b1cab9a9c1788ee654fe6d08d49a71c67eb6e163 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Sat, 5 Dec 2020 19:26:52 +0530 Subject: [PATCH 195/273] fix: Query report filter --- frappe/core/doctype/report_filter/report_filter.json | 4 ++-- frappe/public/js/frappe/views/reports/query_report.js | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/frappe/core/doctype/report_filter/report_filter.json b/frappe/core/doctype/report_filter/report_filter.json index 9d277db11d..964294b96e 100644 --- a/frappe/core/doctype/report_filter/report_filter.json +++ b/frappe/core/doctype/report_filter/report_filter.json @@ -44,7 +44,7 @@ }, { "fieldname": "options", - "fieldtype": "Data", + "fieldtype": "Small Text", "label": "Options" }, { @@ -58,7 +58,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-08-17 16:15:46.937267", + "modified": "2020-12-05 19:20:00.503097", "modified_by": "Administrator", "module": "Core", "name": "Report Filter", diff --git a/frappe/public/js/frappe/views/reports/query_report.js b/frappe/public/js/frappe/views/reports/query_report.js index 60abb187ae..eccfa9c089 100644 --- a/frappe/public/js/frappe/views/reports/query_report.js +++ b/frappe/public/js/frappe/views/reports/query_report.js @@ -1112,7 +1112,9 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { } get_filter_values(raise) { - const mandatory = this.filters.filter(f => f.df.reqd); + + // check for mandatory property for filters added via UI + const mandatory = this.filters.filter(f => (f.df.reqd || f.df.mandatory)); const missing_mandatory = mandatory.filter(f => !f.get_value()); if (raise && missing_mandatory.length > 0) { let message = __('Please set filters'); From 9dec6e84bd3f6043a10003b6e5bff17f07a383c0 Mon Sep 17 00:00:00 2001 From: snyk-bot Date: Sun, 6 Dec 2020 23:07:09 +0000 Subject: [PATCH 196/273] fix: package.json & yarn.lock to reduce vulnerabilities The following vulnerabilities are fixed with an upgrade: - https://snyk.io/vuln/SNYK-JS-HIGHLIGHTJS-1048676 --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index d1a94d0e35..2ef6ceab2d 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "frappe-datatable": "^1.15.3", "frappe-gantt": "^0.5.0", "fuse.js": "^3.4.6", - "highlight.js": "^9.18.2", + "highlight.js": "^10.4.1", "js-sha256": "^0.9.0", "jsbarcode": "^3.9.0", "moment": "^2.20.1", diff --git a/yarn.lock b/yarn.lock index 26797675c6..5cbea3f938 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2702,10 +2702,10 @@ hex-color-regex@^1.1.0: resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ== -highlight.js@^9.18.2: - version "9.18.5" - resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.18.5.tgz#d18a359867f378c138d6819edfc2a8acd5f29825" - integrity sha512-a5bFyofd/BHCX52/8i8uJkjr9DYwXIPnM/plwI6W7ezItLGqzt7X2G2nXuYSfsIJdkwwj/g9DG1LkcGJI/dDoA== +highlight.js@^10.4.1: + version "10.4.1" + resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.4.1.tgz#d48fbcf4a9971c4361b3f95f302747afe19dbad0" + integrity sha512-yR5lWvNz7c85OhVAEAeFhVCc/GV4C30Fjzc/rCP0aCWzc1UUOPUk55dK/qdwTZHBvMZo+eZ2jpk62ndX/xMFlg== homedir-polyfill@^1.0.1: version "1.0.3" From c5677b52d0af3dd31812625db8b22a2fab501983 Mon Sep 17 00:00:00 2001 From: prssanna Date: Mon, 7 Dec 2020 14:42:54 +0530 Subject: [PATCH 197/273] refactor: shorten_number function - pass min_length and max_no_of_decimals --- frappe/public/js/frappe/utils/utils.js | 41 +++++++++++++++---- .../js/frappe/widgets/number_card_widget.js | 2 +- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js index 4bf9c5bbd8..f8f25293b3 100644 --- a/frappe/public/js/frappe/utils/utils.js +++ b/frappe/public/js/frappe/utils/utils.js @@ -979,17 +979,42 @@ Object.assign(frappe.utils, { return route; }, - shorten_number: function (number, country) { - country = (country == 'India') ? country : ''; + shorten_number: function (number, country, min_length=4, max_no_of_decimals=2) { + /* returns the number as an abbreviated string + * PARAMS + * number - number to be shortened + * country - country that determines the numnber system to be used + * min_length - length below which the number will not be shortened + * max_no_of_decimals - max number of decimals of the shortened number + */ + + // return number if total digits is lesser than min_length + const len = String(number).match(/\d/g).length; + if (len < min_length) return number.toString(); + const number_system = this.get_number_system(country); let x = Math.abs(Math.round(number)); for (const map of number_system) { - const condition = map.condition ? map.condition(x) : x >= map.divisor; - if (condition) { - return (number/map.divisor).toFixed(2) + ' ' + map.symbol; + if (x >= map.divisor) { + let result = number/map.divisor; + const no_of_decimals = this.get_number_of_decimals(result); + /* + If no_of_decimals is greater than max_no_of_decimals, + round the number to max_no_of_decimals + */ + result = no_of_decimals > max_no_of_decimals + ? result.toFixed(max_no_of_decimals) + : result; + return result + ' ' + map.symbol; } } - return number.toFixed(); + + return number.toFixed(max_no_of_decimals); + }, + + get_number_of_decimals: function (number) { + if (Math.floor(number) === number) return 0; + return number.toString().split(".")[1].length || 0; }, get_number_system: function (country) { @@ -1019,9 +1044,11 @@ Object.assign(frappe.utils, { { divisor: 1.0e+3, symbol: 'K', - condition: (num) => num.toFixed().length > 5 }] }; + + if (!Object.keys(number_system_map).includes(country)) country = ''; + return number_system_map[country]; }, }); diff --git a/frappe/public/js/frappe/widgets/number_card_widget.js b/frappe/public/js/frappe/widgets/number_card_widget.js index 8855de435b..c41f9bc6e7 100644 --- a/frappe/public/js/frappe/widgets/number_card_widget.js +++ b/frappe/public/js/frappe/widgets/number_card_widget.js @@ -204,7 +204,7 @@ export default class NumberCardWidget extends Widget { get_formatted_number(df) { const default_country = frappe.sys_defaults.country; - const shortened_number = frappe.utils.shorten_number(this.number, default_country); + const shortened_number = frappe.utils.shorten_number(this.number, default_country, 5); let number_parts = shortened_number.split(' '); const symbol = number_parts[1] || ''; From fc97e080c67ddc241af9548fb08d9672fb3c0470 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Mon, 7 Dec 2020 16:32:19 +0100 Subject: [PATCH 198/273] feat: remove useless functionality --- .../s3_backup_settings.json | 32 ++-------- .../s3_backup_settings/s3_backup_settings.py | 58 +++++-------------- 2 files changed, 19 insertions(+), 71 deletions(-) diff --git a/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.json b/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.json index 123bb21e88..2ca1723cb2 100755 --- a/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.json +++ b/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.json @@ -18,12 +18,9 @@ "bucket", "endpoint_url", "column_break_13", - "region", "backup_details_section", "frequency", - "backup_files", - "column_break_18", - "backup_limit" + "backup_files" ], "fields": [ { @@ -42,7 +39,7 @@ }, { "default": "1", - "description": "Note: By default emails for failed backups are sent.", + "description": "By default, emails are only sent for failed backups.", "fieldname": "send_email_for_successful_backup", "fieldtype": "Check", "label": "Send Email for Successful Backup" @@ -73,14 +70,7 @@ "reqd": 1 }, { - "default": "us-east-1", - "description": "See https://docs.aws.amazon.com/general/latest/gr/s3.html for details.", - "fieldname": "region", - "fieldtype": "Select", - "label": "Region", - "options": "us-east-1\nus-east-2\nus-west-1\nus-west-2\naf-south-1\nap-east-1\nap-south-1\nap-southeast-1\nap-southeast-2\nap-northeast-1\nap-northeast-2\nap-northeast-3\nca-central-1\ncn-north-1\ncn-northwest-1\neu-central-1\neu-west-1\neu-west-2\neu-west-3\neu-south-1\neu-north-1\nme-south-1\nsa-east-1" - }, - { + "default": "https://s3.amazonaws.com", "fieldname": "endpoint_url", "fieldtype": "Data", "label": "Endpoint URL" @@ -92,14 +82,6 @@ "mandatory_depends_on": "enabled", "reqd": 1 }, - { - "description": "Set to 0 for no limit on the number of backups taken", - "fieldname": "backup_limit", - "fieldtype": "Int", - "label": "Backup Limit", - "mandatory_depends_on": "enabled", - "reqd": 1 - }, { "depends_on": "enabled", "fieldname": "api_access_section", @@ -142,16 +124,12 @@ "fieldname": "backup_files", "fieldtype": "Check", "label": "Backup Files" - }, - { - "fieldname": "column_break_18", - "fieldtype": "Column Break" } ], "hide_toolbar": 1, "issingle": 1, "links": [], - "modified": "2020-07-27 17:27:21.400000", + "modified": "2020-12-07 15:30:55.047689", "modified_by": "Administrator", "module": "Integrations", "name": "S3 Backup Settings", @@ -172,4 +150,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} +} \ No newline at end of file diff --git a/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.py b/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.py index 7c90d37f82..308d34c5c2 100755 --- a/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.py +++ b/frappe/integrations/doctype/s3_backup_settings/s3_backup_settings.py @@ -24,6 +24,7 @@ class S3BackupSettings(Document): if not self.endpoint_url: self.endpoint_url = 'https://s3.amazonaws.com' + conn = boto3.client( 's3', aws_access_key_id=self.access_key_id, @@ -31,25 +32,21 @@ class S3BackupSettings(Document): endpoint_url=self.endpoint_url ) - bucket_lower = str(self.bucket) - - try: - conn.list_buckets() - - except ClientError: - frappe.throw(_("Invalid Access Key ID or Secret Access Key.")) - try: # Head_bucket returns a 200 OK if the bucket exists and have access to it. - conn.head_bucket(Bucket=bucket_lower) + # Requires ListBucket permission + conn.head_bucket(Bucket=self.bucket) except ClientError as e: error_code = e.response['Error']['Code'] + bucket_name = frappe.bold(self.bucket) if error_code == '403': - frappe.throw(_("Do not have permission to access {0} bucket.").format(bucket_lower)) - else: # '400'-Bad request or '404'-Not Found return - # try to create bucket - conn.create_bucket(Bucket=bucket_lower, CreateBucketConfiguration={ - 'LocationConstraint': self.region}) + msg = _("Do not have permission to access bucket {0}.").format(bucket_name) + elif error_code == '404': + msg = _("Bucket {0} not found.").format(bucket_name) + else: + msg = e.args[0] + + frappe.throw(msg) @frappe.whitelist() @@ -70,11 +67,13 @@ def take_backups_weekly(): def take_backups_monthly(): take_backups_if("Monthly") + def take_backups_if(freq): if cint(frappe.db.get_value("S3 Backup Settings", None, "enabled")): if frappe.db.get_value("S3 Backup Settings", None, "frequency") == freq: take_backups_s3() + @frappe.whitelist() def take_backups_s3(retry_count=0): try: @@ -146,42 +145,13 @@ def backup_to_s3(): if files_filename: upload_file_to_s3(files_filename, folder, conn, bucket) - delete_old_backups(doc.backup_limit, bucket) - def upload_file_to_s3(filename, folder, conn, bucket): destpath = os.path.join(folder, os.path.basename(filename)) try: print("Uploading file:", filename) - conn.upload_file(filename, bucket, destpath) + conn.upload_file(filename, bucket, destpath) # Requires PutObject permission except Exception as e: frappe.log_error() print("Error uploading: %s" % (e)) - - -def delete_old_backups(limit, bucket): - all_backups = [] - doc = frappe.get_single("S3 Backup Settings") - backup_limit = int(limit) - - s3 = boto3.resource( - 's3', - aws_access_key_id=doc.access_key_id, - aws_secret_access_key=doc.get_password('secret_access_key'), - endpoint_url=doc.endpoint_url or 'https://s3.amazonaws.com' - ) - - bucket = s3.Bucket(bucket) - objects = bucket.meta.client.list_objects_v2(Bucket=bucket.name, Delimiter='/') - if objects: - for obj in objects.get('CommonPrefixes'): - all_backups.append(obj.get('Prefix')) - - oldest_backup = sorted(all_backups)[0] if all_backups else '' - - if len(all_backups) > backup_limit: - print("Deleting Backup: {0}".format(oldest_backup)) - for obj in bucket.objects.filter(Prefix=oldest_backup): - # delete all keys that are inside the oldest_backup - s3.Object(bucket.name, obj.key).delete() From 3a11ef3365ed4838f1bd82929af5d4f5589110e0 Mon Sep 17 00:00:00 2001 From: prssanna Date: Tue, 8 Dec 2020 12:46:16 +0530 Subject: [PATCH 199/273] fix: filter dashboards, dashboard charts, number cards by modules --- frappe/desk/doctype/dashboard/dashboard.py | 19 ++++++++++++ .../dashboard_chart/dashboard_chart.py | 30 ++++++++++++------- .../desk/doctype/number_card/number_card.py | 13 ++++++-- frappe/hooks.py | 1 + 4 files changed, 50 insertions(+), 13 deletions(-) diff --git a/frappe/desk/doctype/dashboard/dashboard.py b/frappe/desk/doctype/dashboard/dashboard.py index b12bcfe27d..1c04b6a2fe 100644 --- a/frappe/desk/doctype/dashboard/dashboard.py +++ b/frappe/desk/doctype/dashboard/dashboard.py @@ -5,6 +5,7 @@ from __future__ import unicode_literals from frappe.model.document import Document from frappe.modules.export_file import export_to_files +from frappe.config import get_modules_from_all_apps_for_user import frappe from frappe import _ import json @@ -42,6 +43,24 @@ class Dashboard(Document): except ValueError as error: frappe.throw(_("Invalid json added in the custom options: {0}").format(error)) + +def get_permission_query_conditions(user): + if not user: + user = frappe.session.user + + if user == 'Administrator': + return + + roles = frappe.get_roles(user) + if "System Manager" in roles: + return None + + allowed_modules = [frappe.db.escape(module.get('module_name')) for module in get_modules_from_all_apps_for_user()] + module_condition = '`tabDashboard`.`module` in ({allowed_modules}) or `tabDashboard`.`module` is NULL'.format( + allowed_modules=','.join(allowed_modules)) + + return '{module_condition}'.format(module_condition=module_condition) + @frappe.whitelist() def get_permitted_charts(dashboard_name): permitted_charts = [] diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py index 3f8d7c3c79..2fa36b5514 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py @@ -13,12 +13,12 @@ from frappe.utils.dateutils import\ get_period, get_period_beginning, get_from_date_from_timespan, get_dates_from_timegrain from frappe.model.naming import append_number_if_name_exists from frappe.boot import get_allowed_reports +from frappe.config import get_modules_from_all_apps_for_user from frappe.model.document import Document from frappe.modules.export_file import export_to_files def get_permission_query_conditions(user): - if not user: user = frappe.session.user @@ -31,9 +31,11 @@ def get_permission_query_conditions(user): doctype_condition = False report_condition = False + module_condition = False allowed_doctypes = [frappe.db.escape(doctype) for doctype in frappe.permissions.get_doctypes_with_read()] allowed_reports = [frappe.db.escape(key) if type(key) == str else key.encode('UTF8') for key in get_allowed_reports()] + allowed_modules = [frappe.db.escape(module.get('module_name')) for module in get_modules_from_all_apps_for_user()] if allowed_doctypes: doctype_condition = '`tabDashboard Chart`.`document_type` in ({allowed_doctypes})'.format( @@ -41,18 +43,24 @@ def get_permission_query_conditions(user): if allowed_reports: report_condition = '`tabDashboard Chart`.`report_name` in ({allowed_reports})'.format( allowed_reports=','.join(allowed_reports)) + if allowed_modules: + module_condition = '''`tabDashboard Chart`.`module` in ({allowed_modules}) + or `tabDashboard Chart`.`module` is NULL'''.format( + allowed_modules=','.join(allowed_modules)) return ''' - (`tabDashboard Chart`.`chart_type` in ('Count', 'Sum', 'Average') - and {doctype_condition}) - or - (`tabDashboard Chart`.`chart_type` = 'Report' - and {report_condition}) - '''.format( - doctype_condition=doctype_condition, - report_condition=report_condition - ) - + ((`tabDashboard Chart`.`chart_type` in ('Count', 'Sum', 'Average') + and {doctype_condition}) + or + (`tabDashboard Chart`.`chart_type` = 'Report' + and {report_condition})) + and + ({module_condition}) + '''.format( + doctype_condition=doctype_condition, + report_condition=report_condition, + module_condition=module_condition + ) def has_permission(doc, ptype, user): roles = frappe.get_roles(user) diff --git a/frappe/desk/doctype/number_card/number_card.py b/frappe/desk/doctype/number_card/number_card.py index d4a2b00c57..6bddd09fc7 100644 --- a/frappe/desk/doctype/number_card/number_card.py +++ b/frappe/desk/doctype/number_card/number_card.py @@ -8,6 +8,7 @@ from frappe.model.document import Document from frappe.utils import cint from frappe.model.naming import append_number_if_name_exists from frappe.modules.export_file import export_to_files +from frappe.config import get_modules_from_all_apps_for_user class NumberCard(Document): def autoname(self): @@ -33,16 +34,24 @@ def get_permission_query_conditions(user=None): return None doctype_condition = False + module_condition = False allowed_doctypes = [frappe.db.escape(doctype) for doctype in frappe.permissions.get_doctypes_with_read()] + allowed_modules = [frappe.db.escape(module.get('module_name')) for module in get_modules_from_all_apps_for_user()] if allowed_doctypes: doctype_condition = '`tabNumber Card`.`document_type` in ({allowed_doctypes})'.format( allowed_doctypes=','.join(allowed_doctypes)) + if allowed_modules: + module_condition = '''`tabNumber Card`.`module` in ({allowed_modules}) + or `tabNumber Card`.`module` is NULL'''.format( + allowed_modules=','.join(allowed_modules)) return ''' - {doctype_condition} - '''.format(doctype_condition=doctype_condition) + {doctype_condition} + and + {module_condition} + '''.format(doctype_condition=doctype_condition, module_condition=module_condition) def has_permission(doc, ptype, user): roles = frappe.get_roles(user) diff --git a/frappe/hooks.py b/frappe/hooks.py index d8c8cd841c..3d7ae0abb4 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -94,6 +94,7 @@ permission_query_conditions = { "User": "frappe.core.doctype.user.user.get_permission_query_conditions", "Dashboard Settings": "frappe.desk.doctype.dashboard_settings.dashboard_settings.get_permission_query_conditions", "Notification Log": "frappe.desk.doctype.notification_log.notification_log.get_permission_query_conditions", + "Dashboard": "frappe.desk.doctype.dashboard.dashboard.get_permission_query_conditions", "Dashboard Chart": "frappe.desk.doctype.dashboard_chart.dashboard_chart.get_permission_query_conditions", "Number Card": "frappe.desk.doctype.number_card.number_card.get_permission_query_conditions", "Notification Settings": "frappe.desk.doctype.notification_settings.notification_settings.get_permission_query_conditions", From 585215ccee981cdc0b2a986b443d8cbdd7ad1f15 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 8 Dec 2020 13:38:12 +0530 Subject: [PATCH 200/273] fix: fields get reordered after adding new columns --- .../js/frappe/views/reports/report_view.js | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/frappe/public/js/frappe/views/reports/report_view.js b/frappe/public/js/frappe/views/reports/report_view.js index 026e120c50..13c07a21e7 100644 --- a/frappe/public/js/frappe/views/reports/report_view.js +++ b/frappe/public/js/frappe/views/reports/report_view.js @@ -708,6 +708,32 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView { super.build_fields(); } + reorder_fields() { + // generate table fields in the required format ["name", "DocType"] + // these are fields in the column before adding new fields + let table_fields = this.columns.map(df => [df.field, df.docfield.parent]); + + // filter fields that are already in table + // iterate over table_fields to preserve the existing order of fields + // The filter will ensure the unchecked fields are removed + let fields_already_in_table = table_fields.filter(df => { + return this.fields.find((field) => { + return df[0] == field[0] && df[1] == field[1] + }) + }) + + // find new fields that didn't already exists + // This will be appended to the end of the table + let fields_to_add = this.fields.filter(df => { + return !table_fields.find((field) => { + return df[0] == field[0] && df[1] == field[1] + }) + }) + + // rebuild fields + this.fields = [...fields_already_in_table, ...fields_to_add]; + } + get_fields() { let fields = this.fields.map(f => { let column_name = frappe.model.get_full_column_name(f[0], f[1]); @@ -1329,6 +1355,7 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView { this.fields.map(f => this.add_currency_column(f[0], f[1])); + this.reorder_fields(); this.build_fields(); this.setup_columns(); From e197a6fd7fbecfca9eadd8c2d92577eb20ba12b7 Mon Sep 17 00:00:00 2001 From: Anupam Date: Tue, 8 Dec 2020 11:35:41 +0530 Subject: [PATCH 201/273] feat: added data format support DD-Mon-YY --- frappe/public/js/frappe/data_import/import_preview.js | 1 + frappe/utils/data.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/frappe/public/js/frappe/data_import/import_preview.js b/frappe/public/js/frappe/data_import/import_preview.js index 6c17cb4351..477cfb0786 100644 --- a/frappe/public/js/frappe/data_import/import_preview.js +++ b/frappe/public/js/frappe/data_import/import_preview.js @@ -101,6 +101,7 @@ frappe.data_import.ImportPreview = class ImportPreview { .replace('%H', 'HH') .replace('%M', 'mm') .replace('%S', 'ss') + .replace('%b', 'Mon') : null; let column_title = ` diff --git a/frappe/utils/data.py b/frappe/utils/data.py index 41f247da45..cef4243913 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -1300,12 +1300,14 @@ def generate_hash(*args, **kwargs): def guess_date_format(date_string): DATE_FORMATS = [ + r"%d/%b/%y", r"%d-%m-%Y", r"%m-%d-%Y", r"%Y-%m-%d", r"%d-%m-%y", r"%m-%d-%y", r"%y-%m-%d", + r"%y-%b-%d", r"%d/%m/%Y", r"%m/%d/%Y", r"%Y/%m/%d", From 309f483bb3c158fe0b3186e3d6c4762f66b2049c Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 9 Dec 2020 16:54:40 +0530 Subject: [PATCH 202/273] refactor: update return statement --- frappe/desk/doctype/dashboard/dashboard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/desk/doctype/dashboard/dashboard.py b/frappe/desk/doctype/dashboard/dashboard.py index 1c04b6a2fe..fa03bf8f80 100644 --- a/frappe/desk/doctype/dashboard/dashboard.py +++ b/frappe/desk/doctype/dashboard/dashboard.py @@ -59,7 +59,7 @@ def get_permission_query_conditions(user): module_condition = '`tabDashboard`.`module` in ({allowed_modules}) or `tabDashboard`.`module` is NULL'.format( allowed_modules=','.join(allowed_modules)) - return '{module_condition}'.format(module_condition=module_condition) + return module_condition @frappe.whitelist() def get_permitted_charts(dashboard_name): From c5d08305536c77162ba7821bd4ac9ec9a0cee13b Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 10 Dec 2020 10:37:29 +0530 Subject: [PATCH 203/273] fix: cint seconds before operations --- frappe/utils/data.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frappe/utils/data.py b/frappe/utils/data.py index 34659e1cac..75e8dedbe9 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -369,6 +369,8 @@ def format_duration(seconds, hide_days=False): example: converts 12885 to '3h 34m 45s' where 12885 = seconds in float """ + + seconds = cint(seconds) total_duration = { 'days': math.floor(seconds / (3600 * 24)), From c805851ebe20ea60719a0d9929fbfb900265e28b Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Thu, 10 Dec 2020 10:52:50 +0530 Subject: [PATCH 204/273] feat(Auto Repeat): Submit on Creation configuration --- .../automation/doctype/auto_repeat/auto_repeat.js | 14 ++++++++++++++ .../doctype/auto_repeat/auto_repeat.json | 11 ++++++++++- .../automation/doctype/auto_repeat/auto_repeat.py | 5 ++++- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.js b/frappe/automation/doctype/auto_repeat/auto_repeat.js index a11de1d881..121b4bd2f0 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.js +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.js @@ -44,6 +44,20 @@ frappe.ui.form.on('Auto Repeat', { // auto repeat schedule frappe.auto_repeat.render_schedule(frm); + + frm.trigger('toggle_submit_on_creation'); + }, + + reference_doctype: function(frm) { + frm.trigger('toggle_submit_on_creation'); + }, + + toggle_submit_on_creation: function(frm) { + // submit on creation checkbox + frappe.model.with_doctype(frm.doc.reference_doctype, () => { + let meta = frappe.get_meta(frm.doc.reference_doctype); + frm.toggle_display('submit_on_creation', meta.is_submittable); + }); }, template: function(frm) { diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.json b/frappe/automation/doctype/auto_repeat/auto_repeat.json index 8ee6ca1d45..80975dd4f5 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.json +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_import": 1, "allow_rename": 1, "autoname": "format:AUT-AR-{#####}", @@ -12,6 +13,7 @@ "section_break_3", "reference_doctype", "reference_document", + "submit_on_creation", "column_break_5", "start_date", "end_date", @@ -186,9 +188,16 @@ "fieldname": "repeat_on_last_day", "fieldtype": "Check", "label": "Repeat on Last Day of the Month" + }, + { + "default": "0", + "fieldname": "submit_on_creation", + "fieldtype": "Check", + "label": "Submit on Creation" } ], - "modified": "2019-07-17 11:30:51.412317", + "links": [], + "modified": "2020-12-10 10:43:13.449172", "modified_by": "Administrator", "module": "Automation", "name": "Auto Repeat", diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.py b/frappe/automation/doctype/auto_repeat/auto_repeat.py index fcf24bf1a9..019692f136 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.py @@ -150,6 +150,9 @@ class AutoRepeat(Document): self.update_doc(new_doc, reference_doc) new_doc.insert(ignore_permissions = True) + if self.submit_on_creation: + new_doc.submit(gnore_permissions = True) + return new_doc def update_doc(self, new_doc, reference_doc): @@ -160,7 +163,7 @@ class AutoRepeat(Document): if new_doc.meta.get_field('auto_repeat'): new_doc.set('auto_repeat', self.name) - for fieldname in ['naming_series', 'ignore_pricing_rule', 'posting_time', 'select_print_heading', 'remarks', 'owner']: + for fieldname in ['naming_series', 'ignore_pricing_rule', 'posting_time', 'select_print_heading', 'user_remark', 'remarks', 'owner']: if new_doc.meta.get_field(fieldname): new_doc.set(fieldname, reference_doc.get(fieldname)) From 30fbf37f25530cec068d39a98066684cfccd8848 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Thu, 10 Dec 2020 11:48:12 +0530 Subject: [PATCH 205/273] test: submit on creation --- .../doctype/auto_repeat/auto_repeat.py | 2 +- .../doctype/auto_repeat/test_auto_repeat.py | 51 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.py b/frappe/automation/doctype/auto_repeat/auto_repeat.py index 019692f136..7eb533ce44 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.py @@ -151,7 +151,7 @@ class AutoRepeat(Document): new_doc.insert(ignore_permissions = True) if self.submit_on_creation: - new_doc.submit(gnore_permissions = True) + new_doc.submit() return new_doc diff --git a/frappe/automation/doctype/auto_repeat/test_auto_repeat.py b/frappe/automation/doctype/auto_repeat/test_auto_repeat.py index 60fa9cb59e..e40b12e3b9 100644 --- a/frappe/automation/doctype/auto_repeat/test_auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/test_auto_repeat.py @@ -111,6 +111,25 @@ class TestAutoRepeat(unittest.TestCase): 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 test_submit_on_creation(self): + doctype = 'Test Submittable DocType' + create_submittable_doctype(doctype) + + current_date = getdate() + submittable_doc = frappe.get_doc(dict(doctype=doctype, test='test submit on creation')).insert() + submittable_doc.submit() + doc = make_auto_repeat(frequency='Daily', reference_doctype=doctype, reference_document=submittable_doc.name, + start_date=add_days(current_date, -1), submit_on_creation=1) + + data = get_auto_repeat_entries(current_date) + create_repeated_entries(data) + docnames = frappe.db.get_all(doc.reference_doctype, + filters={'auto_repeat': doc.name}, + fields=['docstatus'], + limit=1 + ) + self.assertEquals(docnames[0].docstatus, 1) + def make_auto_repeat(**args): args = frappe._dict(args) @@ -118,6 +137,7 @@ def make_auto_repeat(**args): 'doctype': 'Auto Repeat', 'reference_doctype': args.reference_doctype or 'ToDo', 'reference_document': args.reference_document or frappe.db.get_value('ToDo', 'name'), + 'submit_on_creation': args.submit_on_creation or 0, 'frequency': args.frequency or 'Daily', 'start_date': args.start_date or add_days(today(), -1), 'end_date': args.end_date or "", @@ -128,3 +148,34 @@ def make_auto_repeat(**args): }).insert(ignore_permissions=True) return doc + + +def create_submittable_doctype(doctype): + if frappe.db.exists('DocType', doctype): + return + else: + doc = frappe.get_doc({ + 'doctype': 'DocType', + '__newname': doctype, + 'module': 'Custom', + 'custom': 1, + 'is_submittable': 1, + 'fields': [{ + 'fieldname': 'test', + 'label': 'Test', + 'fieldtype': 'Data' + }], + 'permissions': [{ + 'role': 'System Manager', + 'read': 1, + 'write': 1, + 'create': 1, + 'delete': 1, + 'submit': 1, + 'cancel': 1, + 'amend': 1 + }] + }).insert() + + doc.allow_auto_repeat = 1 + doc.save() \ No newline at end of file From 5a60048a0a9e57a5227796a3585b402c02435348 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Thu, 10 Dec 2020 12:31:01 +0530 Subject: [PATCH 206/273] test: for get_bench_relative_path --- frappe/tests/test_commands.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index 8c76ce2f48..2da08718a4 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -14,7 +14,7 @@ import glob import frappe import frappe.recorder from frappe.installer import add_to_installed_apps -from frappe.utils import add_to_date, now +from frappe.utils import add_to_date, get_bench_relative_path, now from frappe.utils.backups import fetch_latest_backups @@ -364,3 +364,21 @@ class TestCommands(BaseTestCommands): else: installed_apps = set(frappe.get_installed_apps()) self.assertSetEqual(list_apps, installed_apps) + + def test_get_bench_relative_path(self): + bench_path = frappe.utils.get_bench_path() + test1_path = os.path.join(bench_path, 'test1.txt') + test2_path = os.path.join(bench_path, 'sites/test2.txt') + + with open(test1_path, 'w+') as test1: + test1.write('asdf') + with open(test2_path, 'w+') as test2: + test2.write('asdf') + + self.assertTrue('test1.txt' in get_bench_relative_path('test1.txt')) + self.assertTrue('sites/test2.txt' in get_bench_relative_path('test2.txt')) + with self.assertRaises(SystemExit): + get_bench_relative_path('test3.txt') + + os.remove(test1_path) + os.remove(test2_path) From 9e954a737257b3fb11821ae56255d3bf07082cb6 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Thu, 10 Dec 2020 13:52:21 +0530 Subject: [PATCH 207/273] style: quotes --- frappe/tests/test_commands.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index 2da08718a4..0786a0e14f 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -367,18 +367,18 @@ class TestCommands(BaseTestCommands): def test_get_bench_relative_path(self): bench_path = frappe.utils.get_bench_path() - test1_path = os.path.join(bench_path, 'test1.txt') - test2_path = os.path.join(bench_path, 'sites/test2.txt') + test1_path = os.path.join(bench_path, "test1.txt") + test2_path = os.path.join(bench_path, "sites", "test2.txt") - with open(test1_path, 'w+') as test1: - test1.write('asdf') - with open(test2_path, 'w+') as test2: - test2.write('asdf') + with open(test1_path, "w+") as test1: + test1.write("asdf") + with open(test2_path, "w+") as test2: + test2.write("asdf") - self.assertTrue('test1.txt' in get_bench_relative_path('test1.txt')) - self.assertTrue('sites/test2.txt' in get_bench_relative_path('test2.txt')) + self.assertTrue("test1.txt" in get_bench_relative_path("test1.txt")) + self.assertTrue("sites/test2.txt" in get_bench_relative_path("test2.txt")) with self.assertRaises(SystemExit): - get_bench_relative_path('test3.txt') + get_bench_relative_path("test3.txt") os.remove(test1_path) os.remove(test2_path) From ec0d1dd63f727ca7d4713fbf2f739e1c49151a30 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Thu, 10 Dec 2020 17:18:13 +0530 Subject: [PATCH 208/273] fix: added server side validation for submit on creation --- frappe/automation/doctype/auto_repeat/auto_repeat.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.py b/frappe/automation/doctype/auto_repeat/auto_repeat.py index 7eb533ce44..31d6539e61 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.py @@ -21,6 +21,7 @@ class AutoRepeat(Document): def validate(self): self.update_status() self.validate_reference_doctype() + self.validate_submit_on_creation() self.validate_dates() self.validate_email_id() self.set_dates() @@ -60,6 +61,11 @@ class AutoRepeat(Document): if not frappe.get_meta(self.reference_doctype).allow_auto_repeat: frappe.throw(_("Enable Allow Auto Repeat for the doctype {0} in Customize Form").format(self.reference_doctype)) + def validate_submit_on_creation(self): + if self.submit_on_creation and not frappe.get_meta(self.reference_doctype).is_submittable: + frappe.throw(_('Cannot enable {0} for a non-submittable doctype').format( + frappe.bold('Submit on Creation'))) + def validate_dates(self): if frappe.flags.in_patch: return From 0651341bf8535af743d0fda128008ee180f3d533 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Thu, 10 Dec 2020 18:53:21 +0530 Subject: [PATCH 209/273] feat: show absolute value checkbox if doctype has appropriate fields --- .../doctype/print_format/print_format.js | 18 +++++++++++++++++- .../doctype/print_format/print_format.json | 4 +++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/frappe/printing/doctype/print_format/print_format.js b/frappe/printing/doctype/print_format/print_format.js index e6599b2496..9ef5652dda 100644 --- a/frappe/printing/doctype/print_format/print_format.js +++ b/frappe/printing/doctype/print_format/print_format.js @@ -19,6 +19,7 @@ frappe.ui.form.on("Print Format", { } frm.trigger('render_buttons'); frm.toggle_display('standard', frappe.boot.developer_mode); + frm.trigger('hide_absolute_value_field'); }, render_buttons: function (frm) { frm.page.clear_inner_toolbar(); @@ -58,5 +59,20 @@ frappe.ui.form.on("Print Format", { frm.set_value('show_section_headings', value); frm.set_value('line_breaks', value); frm.trigger('render_buttons'); + }, + doc_type: function (frm) { + frm.trigger('hide_absolute_value_field'); + }, + hide_absolute_value_field: function (frm) { + // TODO: make it work with frm.doc.doc_type + // Problem: frm isn't updated in some random cases + const doctype = locals[frm.doc.doctype][frm.doc.name]; + if (doctype) { + frappe.model.with_doctype(doctype, () => { + const meta = frappe.get_meta(doctype); + const has_int_float_currency_field = meta.fields.filter(df => in_list(['Int', 'Float', 'Currency'], df.fieldtype)); + frm.toggle_display('absolute_value', has_int_float_currency_field.length); + }); + } } -}) +}); diff --git a/frappe/printing/doctype/print_format/print_format.json b/frappe/printing/doctype/print_format/print_format.json index 3867ce4502..6e64e802c9 100644 --- a/frappe/printing/doctype/print_format/print_format.json +++ b/frappe/printing/doctype/print_format/print_format.json @@ -200,6 +200,8 @@ }, { "default": "0", + "depends_on": "doc_type", + "description": "If checked, negative numberic values of Currency, Quantity or Count would be shown as positive", "fieldname": "absolute_value", "fieldtype": "Check", "label": "Show absolute values" @@ -209,7 +211,7 @@ "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2020-11-30 15:26:35.605213", + "modified": "2020-12-10 18:58:55.598269", "modified_by": "Administrator", "module": "Printing", "name": "Print Format", From f0ae807df83009fc2fe9f7e82111b3404a395f60 Mon Sep 17 00:00:00 2001 From: pateljannat Date: Fri, 11 Dec 2020 10:31:36 +0530 Subject: [PATCH 210/273] fix: fallback for global default precision --- frappe/public/js/frappe/form/formatters.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/form/formatters.js b/frappe/public/js/frappe/form/formatters.js index 7f5c3bf472..a03a4d8405 100644 --- a/frappe/public/js/frappe/form/formatters.js +++ b/frappe/public/js/frappe/form/formatters.js @@ -50,7 +50,7 @@ frappe.form.formatters = { return frappe.form.formatters._right(value==null ? "" : cint(value), options) }, Percent: function(value, docfield, options) { - return frappe.form.formatters._right(flt(value, frappe.defaults.get_default("float_precision")) + "%", options); + return frappe.form.formatters._right(flt(value, frappe.defaults.get_default("float_precision") || 3) + "%", options); }, Rating: function(value) { return ` From e7417ebe486bd5d60a73056735743c079fc44597 Mon Sep 17 00:00:00 2001 From: pateljannat Date: Fri, 11 Dec 2020 11:31:56 +0530 Subject: [PATCH 211/273] fix: fallback for percent field precision to 2 --- frappe/public/js/frappe/form/formatters.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/form/formatters.js b/frappe/public/js/frappe/form/formatters.js index a03a4d8405..f163f59171 100644 --- a/frappe/public/js/frappe/form/formatters.js +++ b/frappe/public/js/frappe/form/formatters.js @@ -50,7 +50,8 @@ frappe.form.formatters = { return frappe.form.formatters._right(value==null ? "" : cint(value), options) }, Percent: function(value, docfield, options) { - return frappe.form.formatters._right(flt(value, frappe.defaults.get_default("float_precision") || 3) + "%", options); + var precision = docfield.precision || cint(frappe.boot.sysdefaults && frappe.boot.sysdefaults.float_precision) || 2; + return frappe.form.formatters._right(flt(value, precision) + "%", options); }, Rating: function(value) { return ` From 6398ca7db7ea9565f5fef78cab584ac771c59f51 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Fri, 11 Dec 2020 12:11:07 +0530 Subject: [PATCH 212/273] refactor: move common functions as methods to the Auto Repeat class --- .../doctype/auto_repeat/auto_repeat.js | 5 +- .../doctype/auto_repeat/auto_repeat.py | 143 +++++++++--------- .../doctype/auto_repeat/test_auto_repeat.py | 4 +- 3 files changed, 70 insertions(+), 82 deletions(-) diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.js b/frappe/automation/doctype/auto_repeat/auto_repeat.js index afee9b98bb..e914ff27b0 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.js +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.js @@ -100,10 +100,7 @@ frappe.ui.form.on('Auto Repeat', { frappe.auto_repeat.render_schedule = function(frm) { if (!frm.is_dirty() && frm.doc.status !== 'Disabled') { - frm.call({ - method: "get_auto_repeat_schedule", - doc: frm.doc - }).then((r) => { + frm.call("get_auto_repeat_schedule").then((r) => { frm.dashboard.wrapper.empty(); frm.dashboard.add_section( frappe.render_template("auto_repeat_schedule", { diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.py b/frappe/automation/doctype/auto_repeat/auto_repeat.py index 205d49df56..1fd2cdf1b3 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.py @@ -19,7 +19,6 @@ from frappe.automation.doctype.assignment_rule.assignment_rule import get_repeat month_map = {'Monthly': 1, 'Quarterly': 3, 'Half-yearly': 6, 'Yearly': 12} week_map = {'Monday': 0, 'Tuesday': 1, 'Wednesday': 2, 'Thursday': 3, 'Friday': 4, 'Saturday': 5, 'Sunday': 6} - class AutoRepeat(Document): def validate(self): self.update_status() @@ -53,7 +52,7 @@ class AutoRepeat(Document): if self.disabled: self.next_schedule_date = None else: - self.next_schedule_date = get_next_schedule_date(schedule_date=self.start_date, auto_repeat_doc=self) + self.next_schedule_date = self.get_next_schedule_date(schedule_date=self.start_date) def unlink_if_applicable(self): if self.status == 'Completed' or self.disabled: @@ -93,7 +92,7 @@ class AutoRepeat(Document): frappe.throw(_("'Recipients' not specified")) def validate_auto_repeat_days(self): - auto_repeat_days = get_auto_repeat_days(self) + auto_repeat_days = self.get_auto_repeat_days() if not len(set(auto_repeat_days)) == len(auto_repeat_days): repeated_days = get_repeated(auto_repeat_days) frappe.throw(_('Auto Repeat Day {0} has been repeated.').format(frappe.bold(repeated_days))) @@ -123,7 +122,7 @@ class AutoRepeat(Document): end_date = getdate(self.end_date) if not self.end_date: - next_date = get_next_schedule_date(schedule_date=start_date, auto_repeat_doc=self) + next_date = self.get_next_schedule_date(schedule_date=start_date) row = { "reference_document": self.reference_document, "frequency": self.frequency, @@ -132,8 +131,7 @@ class AutoRepeat(Document): schedule_details.append(row) if self.end_date: - next_date = get_next_schedule_date( - schedule_date=start_date, auto_repeat_doc=self, for_full_schedule=True) + next_date = self.get_next_schedule_date(schedule_date=start_date, for_full_schedule=True) while (getdate(next_date) < getdate(end_date)): row = { @@ -142,8 +140,7 @@ class AutoRepeat(Document): "next_scheduled_date" : next_date } schedule_details.append(row) - next_date = get_next_schedule_date( - schedule_date=next_date, auto_repeat_doc=self, for_full_schedule=True) + next_date = self.get_next_schedule_date(schedule_date=next_date, for_full_schedule=True) return schedule_details @@ -221,6 +218,68 @@ class AutoRepeat(Document): new_doc.set('from_date', from_date) new_doc.set('to_date', to_date) + def get_next_schedule_date(self, schedule_date, for_full_schedule=False): + if month_map.get(self.frequency): + month_count = month_map.get(self.frequency) + month_diff(schedule_date, self.start_date) - 1 + else: + month_count = 0 + + day_count = 0 + if month_count and self.repeat_on_last_day: + day_count = 31 + next_date = get_next_date(self.start_date, month_count, day_count) + elif month_count and self.repeat_on_day: + day_count = self.repeat_on_day + next_date = get_next_date(self.start_date, month_count, day_count) + elif month_count: + next_date = get_next_date(self.start_date, month_count) + else: + days = self.get_days(schedule_date) + 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(self.frequency) + next_date = get_next_date(self.start_date, month_count, day_count) + elif days: + days = self.get_days(next_date) + next_date = add_days(next_date, days) + + return next_date + + def get_days(self, schedule_date): + if self.frequency == "Weekly": + days = self.get_offset_for_weekly_frequency(schedule_date) + else: + # daily frequency + days = 1 + + return days + + def get_offset_for_weekly_frequency(self, schedule_date): + # if weekdays are not set, offset is 7 from current schedule date + if not self.repeat_on_days: + return 7 + + repeat_on_days = self.get_auto_repeat_days() + current_schedule_day = getdate(schedule_date).weekday() + weekdays = list(week_map.keys()) + + # if repeats on more than 1 day or + # start date's weekday is not in repeat days, then get next weekday + # else offset is 7 + if len(repeat_on_days) > 1 or weekdays[current_schedule_day] not in repeat_on_days: + weekday = get_next_weekday(current_schedule_day, repeat_on_days) + next_weekday_number = week_map.get(weekday) + # offset for upcoming weekday + return timedelta((7 + next_weekday_number - current_schedule_day) % 7).days + return 7 + + def get_auto_repeat_days(self): + return [d.day for d in self.get('repeat_on_days', [])] + def send_notification(self, new_doc): """Notify concerned people about recurring document generation""" subject = self.subject or '' @@ -301,74 +360,12 @@ class AutoRepeat(Document): ) -def get_next_schedule_date(schedule_date, auto_repeat_doc=None, for_full_schedule=False): - if month_map.get(auto_repeat_doc.frequency): - month_count = month_map.get(auto_repeat_doc.frequency) + month_diff(schedule_date, auto_repeat_doc.start_date) - 1 - else: - month_count = 0 - - day_count = 0 - if month_count and auto_repeat_doc.repeat_on_last_day: - day_count = 31 - next_date = get_next_date(auto_repeat_doc.start_date, month_count, day_count) - elif month_count and auto_repeat_doc.repeat_on_day: - day_count = auto_repeat_doc.repeat_on_day - next_date = get_next_date(auto_repeat_doc.start_date, month_count, day_count) - elif month_count: - next_date = get_next_date(auto_repeat_doc.start_date, month_count) - else: - days = get_days(schedule_date, auto_repeat_doc) - 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(auto_repeat_doc.frequency) - next_date = get_next_date(auto_repeat_doc.start_date, month_count, day_count) - elif days: - days = get_days(next_date, auto_repeat_doc) - 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) return dt -def get_days(schedule_date, auto_repeat_doc): - if auto_repeat_doc.frequency == "Weekly": - days = get_offset_for_weekly_frequency(schedule_date, auto_repeat_doc) - else: - # daily frequency - days = 1 - - return days - - -def get_offset_for_weekly_frequency(schedule_date, auto_repeat_doc): - # if weekdays are not set, offset is 7 from current schedule date - if not auto_repeat_doc.repeat_on_days: - return 7 - - repeat_on_days = get_auto_repeat_days(auto_repeat_doc) - current_schedule_day = getdate(schedule_date).weekday() - weekdays = list(week_map.keys()) - - # if repeats on more than 1 day or - # start date's weekday is not in repeat days, then get next weekday - # else offset is 7 - if len(repeat_on_days) > 1 or weekdays[current_schedule_day] not in repeat_on_days: - weekday = get_next_weekday(current_schedule_day, repeat_on_days) - next_weekday_number = week_map.get(weekday) - # offset for upcoming weekday - return timedelta((7 + next_weekday_number - current_schedule_day) % 7).days - return 7 - - def get_next_weekday(current_schedule_day, weekdays): days = list(week_map.keys()) if current_schedule_day > 0: @@ -381,10 +378,6 @@ def get_next_weekday(current_schedule_day, weekdays): return entry -def get_auto_repeat_days(doc): - return [d.day for d in doc.get('repeat_on_days', [])] - - #called through hooks def make_auto_repeat_entry(): enqueued_method = 'frappe.automation.doctype.auto_repeat.auto_repeat.create_repeated_entries' @@ -405,7 +398,7 @@ def create_repeated_entries(data): if schedule_date == current_date and not doc.disabled: doc.create_documents() - schedule_date = get_next_schedule_date(schedule_date=schedule_date, auto_repeat_doc=doc) + schedule_date = doc.get_next_schedule_date(schedule_date=schedule_date) if schedule_date and not doc.disabled: frappe.db.set_value('Auto Repeat', doc.name, 'next_schedule_date', schedule_date) diff --git a/frappe/automation/doctype/auto_repeat/test_auto_repeat.py b/frappe/automation/doctype/auto_repeat/test_auto_repeat.py index a9bfbb1cf8..0d6229cd9e 100644 --- a/frappe/automation/doctype/auto_repeat/test_auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/test_auto_repeat.py @@ -7,11 +7,9 @@ import unittest import frappe from frappe.custom.doctype.custom_field.custom_field import create_custom_field -from frappe.automation.doctype.auto_repeat.auto_repeat import get_auto_repeat_entries, create_repeated_entries +from frappe.automation.doctype.auto_repeat.auto_repeat import get_auto_repeat_entries, create_repeated_entries, week_map from frappe.utils import today, add_days, getdate, add_months -week_map = {'Monday': 0, 'Tuesday': 1, 'Wednesday': 2, 'Thursday': 3, 'Friday': 4, 'Saturday': 5, 'Sunday': 6} - def add_custom_fields(): df = dict( fieldname='auto_repeat', label='Auto Repeat', fieldtype='Link', insert_after='sender', From 56302986036a367c86d38445a59116928f826624 Mon Sep 17 00:00:00 2001 From: Nabin Hait Date: Fri, 11 Dec 2020 12:34:09 +0530 Subject: [PATCH 213/273] fix: Reload server script via patch (#12076) --- frappe/patches.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/frappe/patches.txt b/frappe/patches.txt index 0daf29e001..b459019dd7 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -21,6 +21,7 @@ execute:frappe.reload_doc('email', 'doctype', 'document_follow') execute:frappe.reload_doc('core', 'doctype', 'communication_link') #2019-10-02 execute:frappe.reload_doc('core', 'doctype', 'has_role') execute:frappe.reload_doc('core', 'doctype', 'communication') #2019-10-02 +execute:frappe.reload_doc('core', 'doctype', 'server_script') frappe.patches.v11_0.replicate_old_user_permissions frappe.patches.v11_0.reload_and_rename_view_log #2019-01-03 frappe.patches.v7_1.rename_scheduler_log_to_error_log From 5f708fa160ef9df5553aca0eb7292539ccb07cac Mon Sep 17 00:00:00 2001 From: Saurabh Date: Fri, 11 Dec 2020 12:47:16 +0530 Subject: [PATCH 214/273] feat: cache bench apps --- frappe/__init__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frappe/__init__.py b/frappe/__init__.py index c5f13f2295..2bab1cd294 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -348,7 +348,7 @@ def msgprint(msg, title=None, raise_exception=0, as_table=False, as_list=False, if as_table and type(msg) in (list, tuple): out.as_table = 1 - + if as_list and type(msg) in (list, tuple) and len(msg) > 1: out.as_list = 1 @@ -939,7 +939,11 @@ def get_installed_apps(sort=False, frappe_last=False): connect() if not local.all_apps: - local.all_apps = get_all_apps(True) + local.all_apps = cache().get_value('all_apps', get_all_apps) + + #cache bench apps + if not cache().get_value('all_apps'): + cache().set_value('all_apps', local.all_apps) installed = json.loads(db.get_global("installed_apps") or "[]") From 568426668fb60bc6c9ac044dd50930825274db11 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 11 Dec 2020 13:55:31 +0530 Subject: [PATCH 215/273] feat: add alert flag for permission validation In case default permissions are not set, the alert flag will indicate if an alert has to be shown in the UI or not --- frappe/core/doctype/doctype/doctype.py | 9 +++++---- .../page/permission_manager/permission_manager.py | 14 +++++++++++++- frappe/permissions.py | 4 ++-- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index cce5968f9c..fced5d1fa1 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -1000,10 +1000,10 @@ def validate_fields(meta): check_sort_field(meta) check_image_field(meta) -def validate_permissions_for_doctype(doctype, for_remove=False): +def validate_permissions_for_doctype(doctype, for_remove=False, alert=True): """Validates if permissions are set correctly.""" doctype = frappe.get_doc("DocType", doctype) - validate_permissions(doctype, for_remove) + validate_permissions(doctype, for_remove, alert=alert) # save permissions for perm in doctype.get("permissions"): @@ -1026,9 +1026,10 @@ def clear_permissions_cache(doctype): """, doctype): frappe.clear_cache(user=user) -def validate_permissions(doctype, for_remove=False): +def validate_permissions(doctype, for_remove=False, alert=True): permissions = doctype.get("permissions") - if not permissions: + # Some DocTypes may not have permissions by default, don't show alert for them + if not permissions and alert: frappe.msgprint(_('No Permissions Specified'), alert=True, indicator='orange') issingle = issubmittable = isimportable = False if doctype: diff --git a/frappe/core/page/permission_manager/permission_manager.py b/frappe/core/page/permission_manager/permission_manager.py index 637b526d5c..5b4ccb6ce0 100644 --- a/frappe/core/page/permission_manager/permission_manager.py +++ b/frappe/core/page/permission_manager/permission_manager.py @@ -77,8 +77,20 @@ def add(parent, role, permlevel): @frappe.whitelist() def update(doctype, role, permlevel, ptype, value=None): + """Update role permission params + + Args: + doctype (str): Name of the DocType to update params for + role (str): Role to be updated for, eg "Website Manager". + permlevel (int): perm level the provided rule applies to + ptype (str): permission type, example "read", "delete", etc. + value (None, optional): value for ptype, None indicates False + + Returns: + str: Refresh flag is permission is updated successfully + """ frappe.only_for("System Manager") - out = update_permission_property(doctype, role, permlevel, ptype, value) + out = update_permission_property(doctype, role, permlevel, ptype, value, alert=False) return 'refresh' if out else None @frappe.whitelist() diff --git a/frappe/permissions.py b/frappe/permissions.py index 0d766aec8d..e9724b7418 100644 --- a/frappe/permissions.py +++ b/frappe/permissions.py @@ -446,7 +446,7 @@ def can_export(doctype, raise_exception=False): raise frappe.PermissionError(_("You are not allowed to export {} doctype").format(doctype)) return has_access -def update_permission_property(doctype, role, permlevel, ptype, value=None, validate=True): +def update_permission_property(doctype, role, permlevel, ptype, value=None, validate=True, alert=True): '''Update a property in Custom Perm''' from frappe.core.doctype.doctype.doctype import validate_permissions_for_doctype out = setup_custom_perms(doctype) @@ -458,7 +458,7 @@ def update_permission_property(doctype, role, permlevel, ptype, value=None, vali update `tabCustom DocPerm` set `{0}`=%s where name=%s""".format(ptype), (value, name)) if validate: - validate_permissions_for_doctype(doctype) + validate_permissions_for_doctype(doctype, alert=alert) return out From 24e021474f094fc3e7bd9fd46af5b900860cee70 Mon Sep 17 00:00:00 2001 From: "mathieu.brunot" Date: Mon, 14 Dec 2020 02:01:24 +0100 Subject: [PATCH 216/273] chore: Move inline styles to CSS class Signed-off-by: mathieu.brunot --- frappe/public/css/list.css | 3 +++ frappe/public/js/frappe/views/map/map_view.js | 19 ++++++++++--------- frappe/public/less/list.less | 3 +++ 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/frappe/public/css/list.css b/frappe/public/css/list.css index 49ffbcd9e9..88ad147d33 100644 --- a/frappe/public/css/list.css +++ b/frappe/public/css/list.css @@ -404,6 +404,9 @@ input.list-row-checkbox { .map-view-container { display: flex; flex-wrap: wrap; + width: 100%; + height: calc(100vh - 284px); + z-index: 0; } .list-paging-area .gantt-view-mode { margin-left: 15px; diff --git a/frappe/public/js/frappe/views/map/map_view.js b/frappe/public/js/frappe/views/map/map_view.js index 2c068277ad..8a75ecc457 100644 --- a/frappe/public/js/frappe/views/map/map_view.js +++ b/frappe/public/js/frappe/views/map/map_view.js @@ -32,13 +32,14 @@ frappe.views.MapView = class MapView extends frappe.views.ListView { this.map_id = frappe.dom.get_unique_id(); this.$result.html(` -
    +
    `); - this.map = L.map(this.map_id).setView([12.3112899, -85.7384542], 8); //coords of India if markers does not exists + //coords of India if markers does not exists + this.map = L.map(this.map_id).setView([12.3112899, -85.7384542], 8); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', @@ -46,13 +47,13 @@ frappe.views.MapView = class MapView extends frappe.views.ListView { }).addTo(this.map); L.control.scale().addTo(this.map); -if (this.coords.features && this.coords.features.length) { - this.coords.features.forEach( - coords => L.geoJSON(coords).bindPopup(coords.properties.name).addTo(this.map) - ); - let lastCoords = this.coords.features[0].geometry.coordinates.reverse(); - this.map.panTo(lastCoords, 8); -} + if (this.coords.features && this.coords.features.length) { + this.coords.features.forEach( + coords => L.geoJSON(coords).bindPopup(coords.properties.name).addTo(this.map) + ); + let lastCoords = this.coords.features[0].geometry.coordinates.reverse(); + this.map.panTo(lastCoords, 8); + } } get_coords() { diff --git a/frappe/public/less/list.less b/frappe/public/less/list.less index 662e753b38..fba99ee50d 100644 --- a/frappe/public/less/list.less +++ b/frappe/public/less/list.less @@ -487,6 +487,9 @@ input.list-check-all, input.list-row-checkbox { .map-view-container { display: flex; flex-wrap: wrap; + width: 100%; + height: calc(100vh - 284px); + z-index: 0; } // list view From f2747a0de4258649782644e5a350bb5ebae25029 Mon Sep 17 00:00:00 2001 From: Mathieu Brunot Date: Mon, 14 Dec 2020 02:02:01 +0100 Subject: [PATCH 217/273] Update frappe/public/js/frappe/views/map/map_view.js Co-authored-by: Prssanna Desai --- frappe/public/js/frappe/views/map/map_view.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/frappe/public/js/frappe/views/map/map_view.js b/frappe/public/js/frappe/views/map/map_view.js index 2c068277ad..0ec8353ccb 100644 --- a/frappe/public/js/frappe/views/map/map_view.js +++ b/frappe/public/js/frappe/views/map/map_view.js @@ -31,11 +31,7 @@ frappe.views.MapView = class MapView extends frappe.views.ListView { render_map_view() { this.map_id = frappe.dom.get_unique_id(); - this.$result.html(` -
    - -
    - `); + this.$result.html(`
    `); this.map = L.map(this.map_id).setView([12.3112899, -85.7384542], 8); //coords of India if markers does not exists From 881c42629650837dc05940fb45167b399d8fb6a1 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 14 Dec 2020 11:39:21 +0530 Subject: [PATCH 218/273] fix: typo --- frappe/printing/doctype/print_format/print_format.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frappe/printing/doctype/print_format/print_format.json b/frappe/printing/doctype/print_format/print_format.json index 6e64e802c9..3a47fb554f 100644 --- a/frappe/printing/doctype/print_format/print_format.json +++ b/frappe/printing/doctype/print_format/print_format.json @@ -201,17 +201,17 @@ { "default": "0", "depends_on": "doc_type", - "description": "If checked, negative numberic values of Currency, Quantity or Count would be shown as positive", + "description": "If checked, negative numeric values of Currency, Quantity or Count would be shown as positive", "fieldname": "absolute_value", "fieldtype": "Check", - "label": "Show absolute values" + "label": "Show Absolute Values" } ], "icon": "fa fa-print", "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2020-12-10 18:58:55.598269", + "modified": "2020-12-14 11:38:49.132061", "modified_by": "Administrator", "module": "Printing", "name": "Print Format", From 9e4e36041ae2ec5e0c85a3ae929a716626f09312 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 14 Dec 2020 11:48:10 +0530 Subject: [PATCH 219/273] fix: toggling of show absolute values checkbox --- frappe/printing/doctype/print_format/print_format.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/printing/doctype/print_format/print_format.js b/frappe/printing/doctype/print_format/print_format.js index 9ef5652dda..786f8f97ab 100644 --- a/frappe/printing/doctype/print_format/print_format.js +++ b/frappe/printing/doctype/print_format/print_format.js @@ -66,7 +66,7 @@ frappe.ui.form.on("Print Format", { hide_absolute_value_field: function (frm) { // TODO: make it work with frm.doc.doc_type // Problem: frm isn't updated in some random cases - const doctype = locals[frm.doc.doctype][frm.doc.name]; + const doctype = locals[frm.doc.doctype][frm.doc.name].doc_type; if (doctype) { frappe.model.with_doctype(doctype, () => { const meta = frappe.get_meta(doctype); From 8c539743cf5ad77cc8cb2dd42034b7d5ef4cc5ae Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Dec 2020 08:18:58 +0000 Subject: [PATCH 220/273] chore(deps): bump ini from 1.3.5 to 1.3.8 Bumps [ini](https://github.com/isaacs/ini) from 1.3.5 to 1.3.8. - [Release notes](https://github.com/isaacs/ini/releases) - [Commits](https://github.com/isaacs/ini/compare/v1.3.5...v1.3.8) Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 26797675c6..e0383afccb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2918,9 +2918,9 @@ inherits@2.0.3: integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= ini@^1.3.4, ini@^1.3.5, ini@~1.3.0: - version "1.3.5" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" - integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== inquirer@^7.3.3: version "7.3.3" From 7a773d24611fa259749da0b0190a88e9d4590f62 Mon Sep 17 00:00:00 2001 From: conncampbell Date: Sun, 8 Nov 2020 10:02:35 -0700 Subject: [PATCH 221/273] feat: Workflow transition condition allows datetime functions --- frappe/model/workflow.py | 10 ++++++++-- .../workflow_transition/workflow_transition.json | 4 ++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/frappe/model/workflow.py b/frappe/model/workflow.py index 72ce8c9ce4..d0206a9f60 100644 --- a/frappe/model/workflow.py +++ b/frappe/model/workflow.py @@ -53,14 +53,20 @@ def get_transitions(doc, workflow = None, raise_exception=False): return transitions def get_workflow_safe_globals(): - # access to frappe.db.get_value and frappe.db.get_list + # access to frappe.db.get_value, frappe.db.get_list, and date time utils. return dict( frappe=frappe._dict( db=frappe._dict( get_value=frappe.db.get_value, get_list=frappe.db.get_list ), - session=frappe.session + session=frappe.session, + utils=frappe._dict( + now_datetime=frappe.utils.now_datetime, + add_to_date=frappe.utils.add_to_date, + get_datetime=frappe.utils.get_datetime, + now=frappe.utils.now + ) ) ) diff --git a/frappe/workflow/doctype/workflow_transition/workflow_transition.json b/frappe/workflow/doctype/workflow_transition/workflow_transition.json index 8bc06bf18a..5e5cec5880 100644 --- a/frappe/workflow/doctype/workflow_transition/workflow_transition.json +++ b/frappe/workflow/doctype/workflow_transition/workflow_transition.json @@ -295,7 +295,7 @@ "label": "Example", "length": 0, "no_copy": 0, - "options": "
    doc.grand_total > 0
    \n\n

    Conditions should be written in simple Python. Please use properties available in the form only.

    ", + "options": "
    doc.grand_total > 0
    \n\n

    Conditions should be written in simple Python. Please use properties available in the form only.

    \n

    Allowed functions: \n

      \n
    • frappe.db.get_value
    • \n
    • frappe.db.get_list
    • \n
    • frappe.session
    • \n
    • frappe.utils.now_datetime
    • \n
    • frappe.utils.get_datetime
    • \n
    • frappe.utils.add_to_date
    • \n
    • frappe.utils.now
    • \n
    \n

    Example:

    doc.creation > frappe.utils.add_to_date(frappe.utils.now_datetime(), days=-5, as_string=True, as_datetime=True) 

    ", "permlevel": 0, "precision": "", "print_hide": 0, @@ -320,7 +320,7 @@ "issingle": 0, "istable": 1, "max_attachments": 0, - "modified": "2018-10-09 10:28:53.294908", + "modified": "2020-11-08 12:11:00.294908", "modified_by": "Administrator", "module": "Workflow", "name": "Workflow Transition", From 06fcb822054d364089646eb947b431180852a46f Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Tue, 15 Dec 2020 12:35:30 +0530 Subject: [PATCH 222/273] fix: call init_callback after set_route --- frappe/public/js/frappe/form/quick_entry.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/frappe/public/js/frappe/form/quick_entry.js b/frappe/public/js/frappe/form/quick_entry.js index 0a489e26d6..eed49e070b 100644 --- a/frappe/public/js/frappe/form/quick_entry.js +++ b/frappe/public/js/frappe/form/quick_entry.js @@ -35,14 +35,15 @@ frappe.ui.form.QuickEntryForm = Class.extend({ if (this.is_quick_entry() || this.force) { this.render_dialog(); resolve(this); - } else { // No quick entry, use full Form - // but still give callback a shot at the doc - if (this.init_callback) { - this.init_callback(this.doc); - } + } else { + // no quick entry, open full form frappe.quick_entry = null; frappe.set_route('Form', this.doctype, this.doc.name) .then(() => resolve(this)); + // call init_callback for consistency + if (this.init_callback) { + this.init_callback(this.doc); + } } }); }); From 8ef2f7ed1896f7a055e42b456bba8152a69ac1a7 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 15 Dec 2020 13:54:27 +0530 Subject: [PATCH 223/273] style: Re-format and use const instead of var --- frappe/public/js/frappe/form/formatters.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/form/formatters.js b/frappe/public/js/frappe/form/formatters.js index f163f59171..f9a1d0b643 100644 --- a/frappe/public/js/frappe/form/formatters.js +++ b/frappe/public/js/frappe/form/formatters.js @@ -50,7 +50,14 @@ frappe.form.formatters = { return frappe.form.formatters._right(value==null ? "" : cint(value), options) }, Percent: function(value, docfield, options) { - var precision = docfield.precision || cint(frappe.boot.sysdefaults && frappe.boot.sysdefaults.float_precision) || 2; + const precision = ( + docfield.precision + || cint( + frappe.boot.sysdefaults + && frappe.boot.sysdefaults.float_precision + ) + || 2 + ); return frappe.form.formatters._right(flt(value, precision) + "%", options); }, Rating: function(value) { From 2d8550619d2701b2d5230e33457db1a21b6249d3 Mon Sep 17 00:00:00 2001 From: marination Date: Tue, 15 Dec 2020 16:04:16 +0530 Subject: [PATCH 224/273] fix: Reset frappe.flag.link_fields in test - Reset frappe.flag.link_fields in rename doctype tests - Teardown doctypes appropriately after test --- frappe/tests/test_rename_doc.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frappe/tests/test_rename_doc.py b/frappe/tests/test_rename_doc.py index fb0776a485..0f094749d0 100644 --- a/frappe/tests/test_rename_doc.py +++ b/frappe/tests/test_rename_doc.py @@ -55,6 +55,8 @@ class TestRenameDoc(unittest.TestCase): frappe.delete_doc("DocType", dt) frappe.db.sql_ddl(f"DROP TABLE IF EXISTS `tab{dt}`") + frappe.delete_doc_if_exists("Renamed Doc", "ToDo") + def test_rename_doc(self): """Rename an existing document via frappe.rename_doc""" old_name = choice(self.available_documents) @@ -87,6 +89,7 @@ class TestRenameDoc(unittest.TestCase): # check if module exists exists; # if custom, get_controller will return Document class # if not custom, a different class will be returned + frappe.flags.link_fields = {} self.assertNotEqual(get_controller(self.doctype.old), frappe.model.document.Document) old_doctype_path = get_doc_path("Custom", "DocType", self.doctype.old) @@ -103,6 +106,7 @@ class TestRenameDoc(unittest.TestCase): """Rename DocType via frappe.rename_doc""" from frappe.core.doctype.doctype.test_doctype import new_doctype + frappe.flags.link_fields = {} if not frappe.db.exists("DocType", "Rename This"): new_doctype( "Rename This", @@ -140,5 +144,7 @@ class TestRenameDoc(unittest.TestCase): new_name, frappe.rename_doc("Renamed Doc", old_name, new_name, force=True) ) - frappe.delete_doc_if_exists("Renamed Doc", "ToDo") + # delete_doc doesnt drop tables + # this is done to bypass inconsistencies in the db frappe.delete_doc_if_exists("DocType", "Renamed Doc") + frappe.db.sql_ddl("drop table if exists `tabRenamed Doc`") \ No newline at end of file From 9d75094ba9f5d5c32e98eca56123d8f1b8674d3e Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 15 Dec 2020 19:06:42 +0530 Subject: [PATCH 225/273] chore: Move reset flags to setup method Reason for unsetting flags in setUp: 1. frappe.flags.whatever is reset in each request...since it's a werzeug local (ref: https://werkzeug.palletsprojects.com/en/1.0.x/local/) 2. so until it is in the context of current request, it has the same value 3. when you call rename_doc via a request, it's flags are forgotten post that 4. but in a test suite, the whole module's tests run in a single process and the flags aren't (un|re)set 5. which is why frappe.flags.whatever has the same value throughout this module's tests --- frappe/tests/test_rename_doc.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/frappe/tests/test_rename_doc.py b/frappe/tests/test_rename_doc.py index 0f094749d0..9aa175d2d4 100644 --- a/frappe/tests/test_rename_doc.py +++ b/frappe/tests/test_rename_doc.py @@ -57,6 +57,10 @@ class TestRenameDoc(unittest.TestCase): frappe.delete_doc_if_exists("Renamed Doc", "ToDo") + def setUp(self): + frappe.flags.link_fields = {} + super().setUp() + def test_rename_doc(self): """Rename an existing document via frappe.rename_doc""" old_name = choice(self.available_documents) @@ -89,7 +93,6 @@ class TestRenameDoc(unittest.TestCase): # check if module exists exists; # if custom, get_controller will return Document class # if not custom, a different class will be returned - frappe.flags.link_fields = {} self.assertNotEqual(get_controller(self.doctype.old), frappe.model.document.Document) old_doctype_path = get_doc_path("Custom", "DocType", self.doctype.old) @@ -106,7 +109,6 @@ class TestRenameDoc(unittest.TestCase): """Rename DocType via frappe.rename_doc""" from frappe.core.doctype.doctype.test_doctype import new_doctype - frappe.flags.link_fields = {} if not frappe.db.exists("DocType", "Rename This"): new_doctype( "Rename This", @@ -144,7 +146,7 @@ class TestRenameDoc(unittest.TestCase): new_name, frappe.rename_doc("Renamed Doc", old_name, new_name, force=True) ) - # delete_doc doesnt drop tables + # delete_doc doesnt drop tables # this is done to bypass inconsistencies in the db frappe.delete_doc_if_exists("DocType", "Renamed Doc") - frappe.db.sql_ddl("drop table if exists `tabRenamed Doc`") \ No newline at end of file + frappe.db.sql_ddl("drop table if exists `tabRenamed Doc`") From f4f7e8e798940684cfbdb76d05ac7b88d6f4dce0 Mon Sep 17 00:00:00 2001 From: Snyk bot Date: Wed, 16 Dec 2020 07:04:07 +0200 Subject: [PATCH 226/273] chore(Snyk): Security upgrade frappe-charts from 1.5.4 to 1.5.5 (#12086) --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index d1a94d0e35..114de53384 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "driver.js": "^0.9.8", "express": "^4.17.1", "fast-deep-equal": "^2.0.1", - "frappe-charts": "^1.5.1", + "frappe-charts": "^1.5.5", "frappe-datatable": "^1.15.3", "frappe-gantt": "^0.5.0", "fuse.js": "^3.4.6", diff --git a/yarn.lock b/yarn.lock index e0383afccb..3f8eaa3d9d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2299,10 +2299,10 @@ fragment-cache@^0.2.1: dependencies: map-cache "^0.2.2" -frappe-charts@^1.5.1: - version "1.5.4" - resolved "https://registry.yarnpkg.com/frappe-charts/-/frappe-charts-1.5.4.tgz#5870f77ac6ffc8ea4dab32adda1d4e5e4fbda64b" - integrity sha512-hBr7cRLmsCC5VBj/HwKOCgdwyXnkeAO5CAvOd5H4IYFbk84VD9jOjx9fSaqAE0MygVVbY1nCN+5nb08WThW4Xw== +frappe-charts@^1.5.5: + version "1.5.5" + resolved "https://registry.yarnpkg.com/frappe-charts/-/frappe-charts-1.5.5.tgz#5f44a3639aecc6f8fc7d15752abc80bb68e26734" + integrity sha512-L9pJTsrSuRobS/EaBKT8i1x+DVOjkXyUwT85cteZAPqynU/7K+uqjQOy4tMSTv5zsTWJNWFJ37ax68T73YdR3g== frappe-datatable@^1.15.3: version "1.15.3" From 4c1756b3d633568500179c39066aeaa6da50962c Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Wed, 16 Dec 2020 11:29:05 +0530 Subject: [PATCH 227/273] fix: code clean-up and fallbacks for get method --- frappe/automation/doctype/auto_repeat/auto_repeat.js | 2 +- frappe/automation/doctype/auto_repeat/auto_repeat.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.js b/frappe/automation/doctype/auto_repeat/auto_repeat.js index e914ff27b0..c2c84692d8 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.js +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.js @@ -100,7 +100,7 @@ frappe.ui.form.on('Auto Repeat', { frappe.auto_repeat.render_schedule = function(frm) { if (!frm.is_dirty() && frm.doc.status !== 'Disabled') { - frm.call("get_auto_repeat_schedule").then((r) => { + frm.call("get_auto_repeat_schedule").then(r => { frm.dashboard.wrapper.empty(); frm.dashboard.add_section( frappe.render_template("auto_repeat_schedule", { diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.py b/frappe/automation/doctype/auto_repeat/auto_repeat.py index 1fd2cdf1b3..7dbbcdd05d 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.py @@ -241,9 +241,9 @@ class AutoRepeat(Document): if not for_full_schedule: while getdate(next_date) < getdate(today()): if month_count: - month_count += month_map.get(self.frequency) + month_count += month_map.get(self.frequency, 0) next_date = get_next_date(self.start_date, month_count, day_count) - elif days: + else: days = self.get_days(next_date) next_date = add_days(next_date, days) @@ -272,7 +272,7 @@ class AutoRepeat(Document): # else offset is 7 if len(repeat_on_days) > 1 or weekdays[current_schedule_day] not in repeat_on_days: weekday = get_next_weekday(current_schedule_day, repeat_on_days) - next_weekday_number = week_map.get(weekday) + next_weekday_number = week_map.get(weekday, 0) # offset for upcoming weekday return timedelta((7 + next_weekday_number - current_schedule_day) % 7).days return 7 From 7d70e552aaa7bde72bc88a324b7c09d097f9718f Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Wed, 16 Dec 2020 12:09:10 +0530 Subject: [PATCH 228/273] chore: added a docstring for the get_next_schedule_date method --- frappe/automation/doctype/auto_repeat/auto_repeat.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.py b/frappe/automation/doctype/auto_repeat/auto_repeat.py index 7dbbcdd05d..830af68de7 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.py @@ -219,6 +219,13 @@ class AutoRepeat(Document): new_doc.set('to_date', to_date) def get_next_schedule_date(self, schedule_date, for_full_schedule=False): + """ + Returns the next schedule date for auto repeat after a recurring document has been created. + Adds required offset to the schedule_date param and returns the next schedule date. + + :param schedule_date: The date when the last recurring document was created. + :param for_full_schedule: If True, returns the immediate next schedule date, else the full schedule. + """ if month_map.get(self.frequency): month_count = month_map.get(self.frequency) + month_diff(schedule_date, self.start_date) - 1 else: From ed01c67f543412fda99e747bf7d99b86320cb34c Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 16 Dec 2020 15:17:20 +0530 Subject: [PATCH 229/273] style: Black over updated diffs --- frappe/model/workflow.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/frappe/model/workflow.py b/frappe/model/workflow.py index d0206a9f60..43e26cc5d0 100644 --- a/frappe/model/workflow.py +++ b/frappe/model/workflow.py @@ -56,17 +56,14 @@ def get_workflow_safe_globals(): # access to frappe.db.get_value, frappe.db.get_list, and date time utils. return dict( frappe=frappe._dict( - db=frappe._dict( - get_value=frappe.db.get_value, - get_list=frappe.db.get_list - ), + db=frappe._dict(get_value=frappe.db.get_value, get_list=frappe.db.get_list), session=frappe.session, utils=frappe._dict( now_datetime=frappe.utils.now_datetime, add_to_date=frappe.utils.add_to_date, get_datetime=frappe.utils.get_datetime, - now=frappe.utils.now - ) + now=frappe.utils.now, + ), ) ) From aea6123348e78c17008d2a4104aacb0a3f55f42a Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 16 Dec 2020 17:39:29 +0530 Subject: [PATCH 230/273] chore: Drop unused? highlight.pack.js --- frappe/public/js/lib/highlight.pack.js | 1 - 1 file changed, 1 deletion(-) delete mode 100755 frappe/public/js/lib/highlight.pack.js diff --git a/frappe/public/js/lib/highlight.pack.js b/frappe/public/js/lib/highlight.pack.js deleted file mode 100755 index ecee8ad109..0000000000 --- a/frappe/public/js/lib/highlight.pack.js +++ /dev/null @@ -1 +0,0 @@ -var hljs=new function(){function e(e){return e.replace(/&/gm,"&").replace(//gm,">")}function t(e){return e.nodeName.toLowerCase()}function n(e,t){var n=e&&e.exec(t);return n&&0==n.index}function r(e){var t=(e.className+" "+(e.parentNode?e.parentNode.className:"")).split(/\s+/);return t=t.map(function(e){return e.replace(/^lang(uage)?-/,"")}),t.filter(function(e){return m(e)||/no(-?)highlight/.test(e)})[0]}function i(e,t){var n={};for(var r in e)n[r]=e[r];if(t)for(var r in t)n[r]=t[r];return n}function a(e){var n=[];return function r(e,i){for(var a=e.firstChild;a;a=a.nextSibling)3==a.nodeType?i+=a.nodeValue.length:1==a.nodeType&&(n.push({event:"start",offset:i,node:a}),i=r(a,i),t(a).match(/br|hr|img|input/)||n.push({event:"stop",offset:i,node:a}));return i}(e,0),n}function s(n,r,i){function a(){return n.length&&r.length?n[0].offset!=r[0].offset?n[0].offset"}function o(e){l+=""}function c(e){("start"==e.event?s:o)(e.node)}for(var u=0,l="",f=[];n.length||r.length;){var h=a();if(l+=e(i.substr(u,h[0].offset-u)),u=h[0].offset,h==n){f.reverse().forEach(o);do c(h.splice(0,1)[0]),h=a();while(h==n&&h.length&&h[0].offset==u);f.reverse().forEach(s)}else"start"==h[0].event?f.push(h[0].node):f.pop(),c(h.splice(0,1)[0])}return l+e(i.substr(u))}function o(e){function t(e){return e&&e.source||e}function n(n,r){return RegExp(t(n),"m"+(e.cI?"i":"")+(r?"g":""))}function r(a,s){if(!a.compiled){if(a.compiled=!0,a.k=a.k||a.bK,a.k){var o={},c=function(t,n){e.cI&&(n=n.toLowerCase()),n.split(" ").forEach(function(e){var n=e.split("|");o[n[0]]=[t,n[1]?Number(n[1]):1]})};"string"==typeof a.k?c("keyword",a.k):Object.keys(a.k).forEach(function(e){c(e,a.k[e])}),a.k=o}a.lR=n(a.l||/\b[A-Za-z0-9_]+\b/,!0),s&&(a.bK&&(a.b="\\b("+a.bK.split(" ").join("|")+")\\b"),a.b||(a.b=/\B|\b/),a.bR=n(a.b),a.e||a.eW||(a.e=/\B|\b/),a.e&&(a.eR=n(a.e)),a.tE=t(a.e)||"",a.eW&&s.tE&&(a.tE+=(a.e?"|":"")+s.tE)),a.i&&(a.iR=n(a.i)),void 0===a.r&&(a.r=1),a.c||(a.c=[]);var u=[];a.c.forEach(function(e){e.v?e.v.forEach(function(t){u.push(i(e,t))}):u.push("self"==e?a:e)}),a.c=u,a.c.forEach(function(e){r(e,a)}),a.starts&&r(a.starts,s);var l=a.c.map(function(e){return e.bK?"\\.?("+e.b+")\\.?":e.b}).concat([a.tE,a.i]).map(t).filter(Boolean);a.t=l.length?n(l.join("|"),!0):{exec:function(){return null}}}}r(e)}function c(t,r,i,a){function s(e,t){for(var r=0;r";return a+=e+'">',a+t+s}function p(){if(!w.k)return e(B);var t="",n=0;w.lR.lastIndex=0;for(var r=w.lR.exec(B);r;){t+=e(B.substr(n,r.index-n));var i=h(w,r);i?(y+=i[1],t+=g(i[0],e(r[0]))):t+=e(r[0]),n=w.lR.lastIndex,r=w.lR.exec(B)}return t+e(B.substr(n))}function v(){if(w.sL&&!E[w.sL])return e(B);var t=w.sL?c(w.sL,B,!0,L[w.sL]):u(B);return w.r>0&&(y+=t.r),"continuous"==w.subLanguageMode&&(L[w.sL]=t.top),g(t.language,t.value,!1,!0)}function b(){return void 0!==w.sL?v():p()}function d(t,n){var r=t.cN?g(t.cN,"",!0):"";t.rB?(M+=r,B=""):t.eB?(M+=e(n)+r,B=""):(M+=r,B=n),w=Object.create(t,{parent:{value:w}})}function R(t,n){if(B+=t,void 0===n)return M+=b(),0;var r=s(n,w);if(r)return M+=b(),d(r,n),r.rB?0:n.length;var i=l(w,n);if(i){var a=w;a.rE||a.eE||(B+=n),M+=b();do w.cN&&(M+=""),y+=w.r,w=w.parent;while(w!=i.parent);return a.eE&&(M+=e(n)),B="",i.starts&&d(i.starts,""),a.rE?0:n.length}if(f(n,w))throw new Error('Illegal lexeme "'+n+'" for mode "'+(w.cN||"")+'"');return B+=n,n.length||1}var x=m(t);if(!x)throw new Error('Unknown language: "'+t+'"');o(x);for(var w=a||x,L={},M="",k=w;k!=x;k=k.parent)k.cN&&(M=g(k.cN,"",!0)+M);var B="",y=0;try{for(var C,I,j=0;;){if(w.t.lastIndex=j,C=w.t.exec(r),!C)break;I=R(r.substr(j,C.index-j),C[0]),j=C.index+I}R(r.substr(j));for(var k=w;k.parent;k=k.parent)k.cN&&(M+="");return{r:y,value:M,language:t,top:w}}catch(A){if(-1!=A.message.indexOf("Illegal"))return{r:0,value:e(r)};throw A}}function u(t,n){n=n||N.languages||Object.keys(E);var r={r:0,value:e(t)},i=r;return n.forEach(function(e){if(m(e)){var n=c(e,t,!1);n.language=e,n.r>i.r&&(i=n),n.r>r.r&&(i=r,r=n)}}),i.language&&(r.second_best=i),r}function l(e){return N.tabReplace&&(e=e.replace(/^((<[^>]+>|\t)+)/gm,function(e,t){return t.replace(/\t/g,N.tabReplace)})),N.useBR&&(e=e.replace(/\n/g,"
    ")),e}function f(e,t,n){var r=t?R[t]:n,i=[e.trim()];return e.match(/(\s|^)hljs(\s|$)/)||i.push("hljs"),r&&i.push(r),i.join(" ").trim()}function h(e){var t=r(e);if(!/no(-?)highlight/.test(t)){var n;N.useBR?(n=document.createElementNS("http://www.w3.org/1999/xhtml","div"),n.innerHTML=e.innerHTML.replace(/\n/g,"").replace(//g,"\n")):n=e;var i=n.textContent,o=t?c(t,i,!0):u(i),h=a(n);if(h.length){var g=document.createElementNS("http://www.w3.org/1999/xhtml","div");g.innerHTML=o.value,o.value=s(h,a(g),i)}o.value=l(o.value),e.innerHTML=o.value,e.className=f(e.className,t,o.language),e.result={language:o.language,re:o.r},o.second_best&&(e.second_best={language:o.second_best.language,re:o.second_best.r})}}function g(e){N=i(N,e)}function p(){if(!p.called){p.called=!0;var e=document.querySelectorAll("pre code");Array.prototype.forEach.call(e,h)}}function v(){addEventListener("DOMContentLoaded",p,!1),addEventListener("load",p,!1)}function b(e,t){var n=E[e]=t(this);n.aliases&&n.aliases.forEach(function(t){R[t]=e})}function d(){return Object.keys(E)}function m(e){return E[e]||E[R[e]]}var N={classPrefix:"hljs-",tabReplace:null,useBR:!1,languages:void 0},E={},R={};this.highlight=c,this.highlightAuto=u,this.fixMarkup=l,this.highlightBlock=h,this.configure=g,this.initHighlighting=p,this.initHighlightingOnLoad=v,this.registerLanguage=b,this.listLanguages=d,this.getLanguage=m,this.inherit=i,this.IR="[a-zA-Z][a-zA-Z0-9_]*",this.UIR="[a-zA-Z_][a-zA-Z0-9_]*",this.NR="\\b\\d+(\\.\\d+)?",this.CNR="(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",this.BNR="\\b(0b[01]+)",this.RSR="!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",this.BE={b:"\\\\[\\s\\S]",r:0},this.ASM={cN:"string",b:"'",e:"'",i:"\\n",c:[this.BE]},this.QSM={cN:"string",b:'"',e:'"',i:"\\n",c:[this.BE]},this.PWM={b:/\b(a|an|the|are|I|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such)\b/},this.CLCM={cN:"comment",b:"//",e:"$",c:[this.PWM]},this.CBCM={cN:"comment",b:"/\\*",e:"\\*/",c:[this.PWM]},this.HCM={cN:"comment",b:"#",e:"$",c:[this.PWM]},this.NM={cN:"number",b:this.NR,r:0},this.CNM={cN:"number",b:this.CNR,r:0},this.BNM={cN:"number",b:this.BNR,r:0},this.CSSNM={cN:"number",b:this.NR+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?",r:0},this.RM={cN:"regexp",b:/\//,e:/\/[gimuy]*/,i:/\n/,c:[this.BE,{b:/\[/,e:/\]/,r:0,c:[this.BE]}]},this.TM={cN:"title",b:this.IR,r:0},this.UTM={cN:"title",b:this.UIR,r:0}};hljs.registerLanguage("markdown",function(){return{aliases:["md","mkdown","mkd"],c:[{cN:"header",v:[{b:"^#{1,6}",e:"$"},{b:"^.+?\\n[=-]{2,}$"}]},{b:"<",e:">",sL:"xml",r:0},{cN:"bullet",b:"^([*+-]|(\\d+\\.))\\s+"},{cN:"strong",b:"[*_]{2}.+?[*_]{2}"},{cN:"emphasis",v:[{b:"\\*.+?\\*"},{b:"_.+?_",r:0}]},{cN:"blockquote",b:"^>\\s+",e:"$"},{cN:"code",v:[{b:"`.+?`"},{b:"^( {4}| )",e:"$",r:0}]},{cN:"horizontal_rule",b:"^[-\\*]{3,}",e:"$"},{b:"\\[.+?\\][\\(\\[].*?[\\)\\]]",rB:!0,c:[{cN:"link_label",b:"\\[",e:"\\]",eB:!0,rE:!0,r:0},{cN:"link_url",b:"\\]\\(",e:"\\)",eB:!0,eE:!0},{cN:"link_reference",b:"\\]\\[",e:"\\]",eB:!0,eE:!0}],r:10},{b:"^\\[.+\\]:",rB:!0,c:[{cN:"link_reference",b:"\\[",e:"\\]:",eB:!0,eE:!0,starts:{cN:"link_url",e:"$"}}]}]}});hljs.registerLanguage("javascript",function(r){return{aliases:["js"],k:{keyword:"in if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const class",literal:"true false null undefined NaN Infinity",built_in:"eval isFinite isNaN parseFloat parseInt decodeURI decodeURIComponent encodeURI encodeURIComponent escape unescape Object Function Boolean Error EvalError InternalError RangeError ReferenceError StopIteration SyntaxError TypeError URIError Number Math Date String RegExp Array Float32Array Float64Array Int16Array Int32Array Int8Array Uint16Array Uint32Array Uint8Array Uint8ClampedArray ArrayBuffer DataView JSON Intl arguments require module console window document"},c:[{cN:"pi",b:/^\s*('|")use strict('|")/,r:10},r.ASM,r.QSM,r.CLCM,r.CBCM,r.CNM,{b:"("+r.RSR+"|\\b(case|return|throw)\\b)\\s*",k:"return throw case",c:[r.CLCM,r.CBCM,r.RM,{b:/;/,r:0,sL:"xml"}],r:0},{cN:"function",bK:"function",e:/\{/,eE:!0,c:[r.inherit(r.TM,{b:/[A-Za-z$_][0-9A-Za-z$_]*/}),{cN:"params",b:/\(/,e:/\)/,c:[r.CLCM,r.CBCM],i:/["'\(]/}],i:/\[|%/},{b:/\$[(.]/},{b:"\\."+r.IR,r:0}]}});hljs.registerLanguage("json",function(e){var t={literal:"true false null"},i=[e.QSM,e.CNM],l={cN:"value",e:",",eW:!0,eE:!0,c:i,k:t},c={b:"{",e:"}",c:[{cN:"attribute",b:'\\s*"',e:'"\\s*:\\s*',eB:!0,eE:!0,c:[e.BE],i:"\\n",starts:l}],i:"\\S"},n={b:"\\[",e:"\\]",c:[e.inherit(l,{cN:null})],i:"\\S"};return i.splice(i.length,0,c,n),{c:i,k:t,i:"\\S"}});hljs.registerLanguage("python",function(e){var r={cN:"prompt",b:/^(>>>|\.\.\.) /},b={cN:"string",c:[e.BE],v:[{b:/(u|b)?r?'''/,e:/'''/,c:[r],r:10},{b:/(u|b)?r?"""/,e:/"""/,c:[r],r:10},{b:/(u|r|ur)'/,e:/'/,r:10},{b:/(u|r|ur)"/,e:/"/,r:10},{b:/(b|br)'/,e:/'/},{b:/(b|br)"/,e:/"/},e.ASM,e.QSM]},i={cN:"number",r:0,v:[{b:e.BNR+"[lLjJ]?"},{b:"\\b(0o[0-7]+)[lLjJ]?"},{b:e.CNR+"[lLjJ]?"}]},l={cN:"params",b:/\(/,e:/\)/,c:["self",r,i,b]},n={e:/:/,i:/[${=;\n]/,c:[e.UTM,l]};return{aliases:["py","gyp"],k:{keyword:"and elif is global as in if from raise for except finally print import pass return exec else break not with class assert yield try while continue del or def lambda nonlocal|10 None True False",built_in:"Ellipsis NotImplemented"},i:/(<\/|->|\?)/,c:[r,i,b,e.HCM,e.inherit(n,{cN:"function",bK:"def",r:10}),e.inherit(n,{cN:"class",bK:"class"}),{cN:"decorator",b:/@/,e:/$/},{b:/\b(print|exec)\(/}]}});hljs.registerLanguage("xml",function(){var t="[A-Za-z0-9\\._:-]+",e={b:/<\?(php)?(?!\w)/,e:/\?>/,sL:"php",subLanguageMode:"continuous"},c={eW:!0,i:/]+/}]}]}]};return{aliases:["html","xhtml","rss","atom","xsl","plist"],cI:!0,c:[{cN:"doctype",b:"",r:10,c:[{b:"\\[",e:"\\]"}]},{cN:"comment",b:"",r:10},{cN:"cdata",b:"<\\!\\[CDATA\\[",e:"\\]\\]>",r:10},{cN:"tag",b:"|$)",e:">",k:{title:"style"},c:[c],starts:{e:"",rE:!0,sL:"css"}},{cN:"tag",b:"|$)",e:">",k:{title:"script"},c:[c],starts:{e:"",rE:!0,sL:"javascript"}},e,{cN:"pi",b:/<\?\w+/,e:/\?>/,r:10},{cN:"tag",b:"",c:[{cN:"title",b:/[^ \/><\n\t]+/,r:0},c]}]}});hljs.registerLanguage("css",function(e){var c="[a-zA-Z-][a-zA-Z0-9_-]*",a={cN:"function",b:c+"\\(",rB:!0,eE:!0,e:"\\("};return{cI:!0,i:"[=/|']",c:[e.CBCM,{cN:"id",b:"\\#[A-Za-z0-9_-]+"},{cN:"class",b:"\\.[A-Za-z0-9_-]+",r:0},{cN:"attr_selector",b:"\\[",e:"\\]",i:"$"},{cN:"pseudo",b:":(:)?[a-zA-Z0-9\\_\\-\\+\\(\\)\\\"\\']+"},{cN:"at_rule",b:"@(font-face|page)",l:"[a-z-]+",k:"font-face page"},{cN:"at_rule",b:"@",e:"[{;]",c:[{cN:"keyword",b:/\S+/},{b:/\s/,eW:!0,eE:!0,r:0,c:[a,e.ASM,e.QSM,e.CSSNM]}]},{cN:"tag",b:c,r:0},{cN:"rules",b:"{",e:"}",i:"[^\\s]",r:0,c:[e.CBCM,{cN:"rule",b:"[^\\s]",rB:!0,e:";",eW:!0,c:[{cN:"attribute",b:"[A-Z\\_\\.\\-]+",e:":",eE:!0,i:"[^\\s]",starts:{cN:"value",eW:!0,eE:!0,c:[a,e.CSSNM,e.QSM,e.ASM,e.CBCM,{cN:"hexcolor",b:"#[0-9A-Fa-f]+"},{cN:"important",b:"!important"}]}}]}]}]}}); \ No newline at end of file From 00255a4c9e8e2829a15b27c1fe1a40bfbd9f95d0 Mon Sep 17 00:00:00 2001 From: Himanshu Date: Wed, 16 Dec 2020 17:40:18 +0530 Subject: [PATCH 231/273] fix: strip html before checking for null values Co-authored-by: Sahil Khan --- frappe/public/js/frappe/ui/field_group.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/ui/field_group.js b/frappe/public/js/frappe/ui/field_group.js index d432e553f1..393f2af154 100644 --- a/frappe/public/js/frappe/ui/field_group.js +++ b/frappe/public/js/frappe/ui/field_group.js @@ -86,7 +86,7 @@ frappe.ui.FieldGroup = frappe.ui.form.Layout.extend({ var f = this.fields_dict[key]; if (f.get_value) { var v = f.get_value(); - if (f.df.reqd && is_null(v)) + if(f.df.reqd && is_null(strip_html(v))) errors.push(__(f.df.label)); if (f.df.reqd From c876cdc63e256b2e281c931c5fb17974fb56f6d1 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 16 Dec 2020 19:04:59 +0530 Subject: [PATCH 232/273] fix: Update controllers via delete_doc and rename_doc only in developer_mode --- frappe/core/doctype/doctype/doctype.py | 1 - frappe/model/delete_doc.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index a4d53351d6..3e283e1699 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -296,7 +296,6 @@ class DocType(Document): and ( frappe.conf.developer_mode or frappe.flags.allow_doctype_export - or frappe.flags.in_test ) ) if allow_doctype_export: diff --git a/frappe/model/delete_doc.py b/frappe/model/delete_doc.py index f8344f014c..15de673e4b 100644 --- a/frappe/model/delete_doc.py +++ b/frappe/model/delete_doc.py @@ -76,7 +76,7 @@ def delete_doc(doctype=None, name=None, force=0, ignore_doctypes=None, for_reloa delete_from_table(doctype, name, ignore_doctypes, None) - if not doc.custom and not ( + if frappe.conf.developer_mode and not doc.custom and not ( for_reload or frappe.flags.in_migrate or frappe.flags.in_install From bd7ff5277cbad85f7b26a4b20159439f49b46a1a Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 16 Dec 2020 19:27:02 +0530 Subject: [PATCH 233/273] fix(hljs): Import core instead of highlight ref: https://unpkg.com/browse/highlight.js@10.4.1/lib/highlight.js --- frappe/website/js/syntax_highlight.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/website/js/syntax_highlight.js b/frappe/website/js/syntax_highlight.js index 199174b1e5..80914d9d99 100644 --- a/frappe/website/js/syntax_highlight.js +++ b/frappe/website/js/syntax_highlight.js @@ -1,4 +1,4 @@ -const hljs = require('highlight.js/lib/highlight'); +const hljs = require('highlight.js/lib/core'); hljs.registerLanguage('javascript', require('highlight.js/lib/languages/javascript')); hljs.registerLanguage('python', require('highlight.js/lib/languages/python')); From 2b4299a10524723c84b353d4621de87b0f7180d3 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 16 Dec 2020 19:10:37 +0530 Subject: [PATCH 234/273] test: Set frappe.conf.developer_mode for test_rename_doc --- frappe/tests/test_rename_doc.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frappe/tests/test_rename_doc.py b/frappe/tests/test_rename_doc.py index 9aa175d2d4..58cc5bb125 100644 --- a/frappe/tests/test_rename_doc.py +++ b/frappe/tests/test_rename_doc.py @@ -14,6 +14,10 @@ class TestRenameDoc(unittest.TestCase): @classmethod def setUpClass(self): """Setting Up data for the tests defined under TestRenameDoc""" + # set developer_mode to rename doc controllers + self._original_developer_flag = frappe.conf.developer_mode + frappe.conf.developer_mode = 1 + # data generation: for base and merge tests self.available_documents = [] self.test_doctype = "ToDo" @@ -57,6 +61,9 @@ class TestRenameDoc(unittest.TestCase): frappe.delete_doc_if_exists("Renamed Doc", "ToDo") + # reset original value of developer_mode conf + frappe.conf.developer_mode = self._original_developer_flag + def setUp(self): frappe.flags.link_fields = {} super().setUp() From 72c25d28b33075ee024f45c203dd83b79f02aeb4 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Thu, 17 Dec 2020 17:37:22 +0530 Subject: [PATCH 235/273] fix: Improve breadrumbs markup schema for website --- frappe/templates/includes/breadcrumbs.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/templates/includes/breadcrumbs.html b/frappe/templates/includes/breadcrumbs.html index e281c4b111..61c03201bc 100644 --- a/frappe/templates/includes/breadcrumbs.html +++ b/frappe/templates/includes/breadcrumbs.html @@ -6,12 +6,12 @@ {% for parent in parents %} {% endfor %} - From c873c36971313997ecfb5ddbb025b1d43fc89a4a Mon Sep 17 00:00:00 2001 From: Rushabh Mehta Date: Thu, 17 Dec 2020 13:12:49 +0530 Subject: [PATCH 236/273] feat(minor): add before_commit methods --- frappe/__init__.py | 1 + frappe/database/database.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/frappe/__init__.py b/frappe/__init__.py index 276e296cd3..9958ae9700 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -148,6 +148,7 @@ def init(site, sites_path=None, new_site=False): "new_site": new_site }) local.rollback_observers = [] + local.before_commit = [] local.test_objects = {} local.site = site diff --git a/frappe/database/database.py b/frappe/database/database.py index 616dd3c3ec..179206a4af 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -746,6 +746,9 @@ class Database(object): def commit(self): """Commit current transaction. Calls SQL `COMMIT`.""" + for method in frappe.local.before_commit: + frappe.call(method[0], *(method[1] or []), **(method[2] or {})) + self.sql("commit") frappe.local.rollback_observers = [] @@ -753,6 +756,9 @@ class Database(object): enqueue_jobs_after_commit() flush_local_link_count() + def add_before_commit(self, method, args=None, kwargs=None): + frappe.local.before_commit.append([method, args, kwargs]) + @staticmethod def flush_realtime_log(): for args in frappe.local.realtime_log: From 41a3b8c2842ff066194970cffb2fa8cb446e9246 Mon Sep 17 00:00:00 2001 From: Suraj Shetty <13928957+surajshetty3416@users.noreply.github.com> Date: Thu, 17 Dec 2020 20:44:23 +0530 Subject: [PATCH 237/273] fix(workflow): Update modified timestamp (#12093) To sync changes in https://github.com/frappe/frappe/pull/11787 after migrate --- frappe/workflow/doctype/workflow/workflow.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/workflow/doctype/workflow/workflow.json b/frappe/workflow/doctype/workflow/workflow.json index 3cb72d0eed..e8db8dcb10 100644 --- a/frappe/workflow/doctype/workflow/workflow.json +++ b/frappe/workflow/doctype/workflow/workflow.json @@ -99,7 +99,7 @@ "icon": "fa fa-random", "idx": 1, "links": [], - "modified": "2020-07-16 04:29:20.898040", + "modified": "2020-12-17 20:35:16.898040", "modified_by": "Administrator", "module": "Workflow", "name": "Workflow", From 018e45fdcc9c73187ad27c6972376e75821aa4c9 Mon Sep 17 00:00:00 2001 From: sahil28297 <37302950+sahil28297@users.noreply.github.com> Date: Fri, 18 Dec 2020 00:13:49 +0530 Subject: [PATCH 238/273] chore: add space after if keyword --- frappe/public/js/frappe/ui/field_group.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/ui/field_group.js b/frappe/public/js/frappe/ui/field_group.js index 393f2af154..67aeb4474e 100644 --- a/frappe/public/js/frappe/ui/field_group.js +++ b/frappe/public/js/frappe/ui/field_group.js @@ -86,7 +86,7 @@ frappe.ui.FieldGroup = frappe.ui.form.Layout.extend({ var f = this.fields_dict[key]; if (f.get_value) { var v = f.get_value(); - if(f.df.reqd && is_null(strip_html(v))) + if (f.df.reqd && is_null(strip_html(v))) errors.push(__(f.df.label)); if (f.df.reqd From ebd6de1ae14c5a5c8cac11db6e38d7ace4c821cb Mon Sep 17 00:00:00 2001 From: pateljannat Date: Fri, 18 Dec 2020 12:04:50 +0530 Subject: [PATCH 239/273] fix: document naming rule validation for fields --- .../document_naming_rule/document_naming_rule.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/frappe/core/doctype/document_naming_rule/document_naming_rule.py b/frappe/core/doctype/document_naming_rule/document_naming_rule.py index 3ff47facc3..62d007609f 100644 --- a/frappe/core/doctype/document_naming_rule/document_naming_rule.py +++ b/frappe/core/doctype/document_naming_rule/document_naming_rule.py @@ -6,8 +6,19 @@ from __future__ import unicode_literals import frappe from frappe.model.document import Document from frappe.utils.data import evaluate_filters +from frappe import _ class DocumentNamingRule(Document): + def validate(self): + self.validate_fields_in_conditions() + + def validate_fields_in_conditions(self): + for condition in self.conditions: + docfields = frappe.get_meta(self.document_type).fields + matching_field = list(filter(lambda x: x.fieldname == condition.field, docfields)) + if not len(matching_field): + frappe.throw(_("{0} is not a field of doctype {1}").format(frappe.bold(condition.field), frappe.bold(self.document_type))) + def apply(self, doc): ''' Apply naming rules for the given document. Will set `name` if the rule is matched. From afa8a744970985b60583e158f781c9262123422e Mon Sep 17 00:00:00 2001 From: prssanna Date: Thu, 17 Dec 2020 10:59:47 +0530 Subject: [PATCH 240/273] fix: rendering of percentage stat --- .../js/frappe/widgets/number_card_widget.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/frappe/public/js/frappe/widgets/number_card_widget.js b/frappe/public/js/frappe/widgets/number_card_widget.js index c41f9bc6e7..ccc9ea7bfd 100644 --- a/frappe/public/js/frappe/widgets/number_card_widget.js +++ b/frappe/public/js/frappe/widgets/number_card_widget.js @@ -172,7 +172,7 @@ export default class NumberCardWidget extends Widget { get_number_for_custom_card(res) { if (typeof res === 'object') { this.number = res.value; - this.get_formatted_number(res); + this.set_formatted_number(res); } else { this.formatted_number = res; } @@ -184,7 +184,7 @@ export default class NumberCardWidget extends Widget { return frappe.model.with_doctype(this.card_doc.document_type, () => { const based_on_df = frappe.meta.get_docfield(this.card_doc.document_type, this.card_doc.aggregate_function_based_on); - this.get_formatted_number(based_on_df); + this.set_formatted_number(based_on_df); }); } else { this.formatted_number = res; @@ -199,10 +199,10 @@ export default class NumberCardWidget extends Widget { }, []); const col = res.columns.find(col => col.fieldname == field); this.number = frappe.report_utils.get_result_of_fn(this.card_doc.report_function, vals); - this.get_formatted_number(col); + this.set_formatted_number(col); } - get_formatted_number(df) { + set_formatted_number(df) { const default_country = frappe.sys_defaults.country; const shortened_number = frappe.utils.shorten_number(this.number, default_country, 5); let number_parts = shortened_number.split(' '); @@ -250,10 +250,16 @@ export default class NumberCardWidget extends Widget { }; const stats_qualifier = stats_qualifier_map[this.card_doc.stats_time_interval]; + let get_stat = () => { + const parts = this.percentage_stat.split(' '); + const symbol = parts[1] || ''; + return Math.abs(parts[0]) + ' ' + symbol; + }; + $(this.body).find('.widget-content').append(`
    ${caret_html} - ${Math.abs(this.percentage_stat)} % + ${get_stat()} % ${stats_qualifier} From 6d989531914dc6b6a0d0c9b9e653aaab6bb520be Mon Sep 17 00:00:00 2001 From: pateljannat Date: Fri, 18 Dec 2020 14:35:00 +0530 Subject: [PATCH 241/273] fix: change request --- .../doctype/document_naming_rule/document_naming_rule.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frappe/core/doctype/document_naming_rule/document_naming_rule.py b/frappe/core/doctype/document_naming_rule/document_naming_rule.py index 62d007609f..5ae9528cea 100644 --- a/frappe/core/doctype/document_naming_rule/document_naming_rule.py +++ b/frappe/core/doctype/document_naming_rule/document_naming_rule.py @@ -13,10 +13,9 @@ class DocumentNamingRule(Document): self.validate_fields_in_conditions() def validate_fields_in_conditions(self): + docfields = [x.fieldname for x in frappe.get_meta(self.document_type).fields] for condition in self.conditions: - docfields = frappe.get_meta(self.document_type).fields - matching_field = list(filter(lambda x: x.fieldname == condition.field, docfields)) - if not len(matching_field): + if condition.field not in docfields: frappe.throw(_("{0} is not a field of doctype {1}").format(frappe.bold(condition.field), frappe.bold(self.document_type))) def apply(self, doc): From d3a046a72ca20d1da1364b47a814963c83b691a5 Mon Sep 17 00:00:00 2001 From: pateljannat Date: Fri, 18 Dec 2020 21:18:47 +0530 Subject: [PATCH 242/273] fix: check for doctype change before validation --- .../doctype/document_naming_rule/document_naming_rule.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/frappe/core/doctype/document_naming_rule/document_naming_rule.py b/frappe/core/doctype/document_naming_rule/document_naming_rule.py index 5ae9528cea..4b34293af6 100644 --- a/frappe/core/doctype/document_naming_rule/document_naming_rule.py +++ b/frappe/core/doctype/document_naming_rule/document_naming_rule.py @@ -13,10 +13,11 @@ class DocumentNamingRule(Document): self.validate_fields_in_conditions() def validate_fields_in_conditions(self): - docfields = [x.fieldname for x in frappe.get_meta(self.document_type).fields] - for condition in self.conditions: - if condition.field not in docfields: - frappe.throw(_("{0} is not a field of doctype {1}").format(frappe.bold(condition.field), frappe.bold(self.document_type))) + if self.has_value_changed("document_type"): + docfields = [x.fieldname for x in frappe.get_meta(self.document_type).fields] + for condition in self.conditions: + if condition.field not in docfields: + frappe.throw(_("{0} is not a field of doctype {1}").format(frappe.bold(condition.field), frappe.bold(self.document_type))) def apply(self, doc): ''' From a6705135dcb8679d3f82deacf1cd93476182d867 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Sat, 19 Dec 2020 08:49:14 +0530 Subject: [PATCH 243/273] fix: Link attachment in webform for new file (bp #12097) (#12102) (cherry picked from commit 85d6a640358fa0337aaac405e71b7d14395fb298) Co-authored-by: Suraj Shetty <13928957+surajshetty3416@users.noreply.github.com> --- frappe/public/js/frappe/web_form/web_form.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/frappe/public/js/frappe/web_form/web_form.js b/frappe/public/js/frappe/web_form/web_form.js index 4dc1a50bc4..0421147d49 100644 --- a/frappe/public/js/frappe/web_form/web_form.js +++ b/frappe/public/js/frappe/web_form/web_form.js @@ -103,6 +103,7 @@ export default class WebForm extends frappe.ui.FieldGroup { } save() { + let is_new = this.is_new; if (this.validate && !this.validate()) { frappe.throw(__("Couldn't save, please check the data you have entered"), __("Validation Error")); } @@ -139,6 +140,18 @@ export default class WebForm extends frappe.ui.FieldGroup { this.handle_success(response.message); frappe.web_form.events.trigger('after_save'); this.after_save && this.after_save(); + // args doctype and docname added to link doctype in file manager + if (is_new) { + frappe.call({ + type: 'POST', + method: "frappe.handler.upload_file", + args: { + file_url: response.message.attachment, + doctype: response.message.doctype, + docname: response.message.name + } + }); + } } }, always: function() { From 5a77383aa8ec771e1bc55a23ac2ec95a53920753 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Sat, 19 Dec 2020 13:19:07 +0530 Subject: [PATCH 244/273] fix: donot remove the modules from txt file if developer mode is off --- frappe/core/doctype/module_def/module_def.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/core/doctype/module_def/module_def.py b/frappe/core/doctype/module_def/module_def.py index 930c46e60b..7e63572162 100644 --- a/frappe/core/doctype/module_def/module_def.py +++ b/frappe/core/doctype/module_def/module_def.py @@ -43,7 +43,7 @@ class ModuleDef(Document): def on_trash(self): """Delete module name from modules.txt""" - if frappe.flags.in_uninstall or self.custom: + if not frappe.conf.get('developer_mode') or frappe.flags.in_uninstall or self.custom: return modules = None From 8e380fe151e940e1389fff60959b2dc396eb57e6 Mon Sep 17 00:00:00 2001 From: prssanna Date: Mon, 21 Dec 2020 12:34:14 +0530 Subject: [PATCH 245/273] fix: don't set Message as mandatory in communication dialog --- frappe/public/js/frappe/views/communication.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/views/communication.js b/frappe/public/js/frappe/views/communication.js index 29b21242af..3ee4b54621 100755 --- a/frappe/public/js/frappe/views/communication.js +++ b/frappe/public/js/frappe/views/communication.js @@ -67,7 +67,7 @@ frappe.views.CommunicationComposer = Class.extend({ {fieldtype: "Section Break"}, { label:__("Message"), - fieldtype:"Text Editor", reqd: 1, + fieldtype:"Text Editor", fieldname:"content", onchange: frappe.utils.debounce(this.save_as_draft.bind(this), 300) }, From 8cf120e722b198e11dd151be118b03e3a00eb3da Mon Sep 17 00:00:00 2001 From: prssanna Date: Mon, 21 Dec 2020 13:11:57 +0530 Subject: [PATCH 246/273] style: fix formatting --- .../public/js/frappe/views/communication.js | 105 +++++++++++++----- 1 file changed, 76 insertions(+), 29 deletions(-) diff --git a/frappe/public/js/frappe/views/communication.js b/frappe/public/js/frappe/views/communication.js index 3ee4b54621..c69be04347 100755 --- a/frappe/public/js/frappe/views/communication.js +++ b/frappe/public/js/frappe/views/communication.js @@ -55,38 +55,85 @@ frappe.views.CommunicationComposer = Class.extend({ get_fields: function() { let contactList = []; var fields= [ - {label:__("To"), fieldtype:"MultiSelect", reqd: 0, fieldname:"recipients",options:contactList}, - {fieldtype: "Section Break", collapsible: 1, label: __("CC, BCC & Email Template")}, - {label:__("CC"), fieldtype:"MultiSelect", fieldname:"cc",options:contactList}, - {label:__("BCC"), fieldtype:"MultiSelect", fieldname:"bcc",options:contactList}, - {label:__("Email Template"), fieldtype:"Link", options:"Email Template", - fieldname:"email_template"}, - {fieldtype: "Section Break"}, - {label:__("Subject"), fieldtype:"Data", reqd: 1, - fieldname:"subject", length:524288}, - {fieldtype: "Section Break"}, { - label:__("Message"), - fieldtype:"Text Editor", - fieldname:"content", + label: __("To"), + fieldtype: "MultiSelect", + reqd: 0, + fieldname: "recipients", + options: contactList + }, + { + fieldtype: "Section Break", + collapsible: 1, + label: __("CC, BCC & Email Template") + }, + { + label: __("CC"), + fieldtype: "MultiSelect", + fieldname: "cc", + options: contactList + }, + { + label: __("BCC"), + fieldtype: "MultiSelect", + fieldname: "bcc", + options: contactList + }, + { + label: __("Email Template"), + fieldtype: "Link", + options: "Email Template", + fieldname: "email_template" + }, + { fieldtype: "Section Break" }, + { + label: __("Subject"), + fieldtype: "Data", + reqd: 1, + fieldname: "subject", + length: 524288 + }, + { fieldtype: "Section Break" }, + { + label: __("Message"), + fieldtype: "Text Editor", + fieldname: "content", onchange: frappe.utils.debounce(this.save_as_draft.bind(this), 300) }, - - {fieldtype: "Section Break"}, - {fieldtype: "Column Break"}, - {label:__("Send me a copy"), fieldtype:"Check", - fieldname:"send_me_a_copy", 'default': frappe.boot.user.send_me_a_copy}, - {label:__("Send Read Receipt"), fieldtype:"Check", - fieldname:"send_read_receipt"}, - {label:__("Attach Document Print"), fieldtype:"Check", - fieldname:"attach_document_print"}, - {label:__("Select Print Format"), fieldtype:"Select", - fieldname:"select_print_format"}, - {label:__("Select Languages"), fieldtype:"Select", - fieldname:"language_sel"}, - {fieldtype: "Column Break"}, - {label:__("Select Attachments"), fieldtype:"HTML", - fieldname:"select_attachments"} + { fieldtype: "Section Break" }, + { fieldtype: "Column Break" }, + { + label: __("Send me a copy"), + fieldtype: "Check", + fieldname: "send_me_a_copy", + 'default': frappe.boot.user.send_me_a_copy + }, + { + label: __("Send Read Receipt"), + fieldtype: "Check", + fieldname: "send_read_receipt" + }, + { + label: __("Attach Document Print"), + fieldtype: "Check", + fieldname: "attach_document_print" + }, + { + label: __("Select Print Format"), + fieldtype: "Select", + fieldname: "select_print_format" + }, + { + label: __("Select Languages"), + fieldtype: "Select", + fieldname: "language_sel" + }, + { fieldtype: "Column Break" }, + { + label: __("Select Attachments"), + fieldtype: "HTML", + fieldname: "select_attachments" + } ]; // add from if user has access to multiple email accounts From f4ba3e7c0a9dc91cf80fa5e1c8b0ab354fe2128f Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Tue, 22 Dec 2020 12:42:58 +0530 Subject: [PATCH 247/273] fix: Update breadcrumb markup schema --- frappe/templates/includes/breadcrumbs.html | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/frappe/templates/includes/breadcrumbs.html b/frappe/templates/includes/breadcrumbs.html index 61c03201bc..ccc77de253 100644 --- a/frappe/templates/includes/breadcrumbs.html +++ b/frappe/templates/includes/breadcrumbs.html @@ -3,16 +3,20 @@ From 877a25225a5f4b0d35808f7f2042b36c9b018fdd Mon Sep 17 00:00:00 2001 From: prssanna Date: Tue, 22 Dec 2020 13:16:19 +0530 Subject: [PATCH 248/273] fix: null as default value for rating field --- frappe/public/js/frappe/form/controls/rating.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/form/controls/rating.js b/frappe/public/js/frappe/form/controls/rating.js index 34e890d45c..191db35538 100644 --- a/frappe/public/js/frappe/form/controls/rating.js +++ b/frappe/public/js/frappe/form/controls/rating.js @@ -47,7 +47,7 @@ frappe.ui.form.ControlRating = frappe.ui.form.ControlInt.extend({ }); }, get_value() { - return cint(this.value); + return cint(this.value, null); }, set_formatted_input(value) { let el = $(this.input_area).find('i'); From 4961774facbe7bb70d87d396d61f61c736b3ef4f Mon Sep 17 00:00:00 2001 From: "mathieu.brunot" Date: Wed, 23 Dec 2020 01:20:39 +0100 Subject: [PATCH 249/273] chore: Define constants for map and geolocation Signed-off-by: mathieu.brunot --- frappe/public/js/frappe/form/controls/geolocation.js | 8 ++++---- frappe/public/js/frappe/views/map/map_view.js | 12 +++++------- frappe/public/js/frappe/widgets/utils.js | 11 ++++++++++- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/frappe/public/js/frappe/form/controls/geolocation.js b/frappe/public/js/frappe/form/controls/geolocation.js index 9dfad09299..b6a04e5218 100644 --- a/frappe/public/js/frappe/form/controls/geolocation.js +++ b/frappe/public/js/frappe/form/controls/geolocation.js @@ -1,3 +1,5 @@ +import { map_defaults } from "../../widgets/utils"; + frappe.ui.form.ControlGeolocation = frappe.ui.form.ControlData.extend({ horizontal: false, @@ -90,11 +92,9 @@ frappe.ui.form.ControlGeolocation = frappe.ui.form.ControlData.extend({ }); L.Icon.Default.imagePath = '/assets/frappe/images/leaflet/'; - this.map = L.map(this.map_id).setView([19.0800, 72.8961], 13); + this.map = L.map(this.map_id).setView(map_defaults.center, map_defaults.zoom); - L.tileLayer('http://{s}.tile.osm.org/{z}/{x}/{y}.png', { - attribution: '© OpenStreetMap contributors' - }).addTo(this.map); + L.tileLayer(map_defaults.tiles, map_defaults.options).addTo(this.map); }, bind_leaflet_locate_control() { diff --git a/frappe/public/js/frappe/views/map/map_view.js b/frappe/public/js/frappe/views/map/map_view.js index 12b4cef921..b6119eef1a 100644 --- a/frappe/public/js/frappe/views/map/map_view.js +++ b/frappe/public/js/frappe/views/map/map_view.js @@ -1,6 +1,8 @@ /** * frappe.views.MapView */ +import { map_defaults } from "../../widgets/utils"; + frappe.provide("frappe.views"); frappe.views.MapView = class MapView extends frappe.views.ListView { @@ -33,14 +35,10 @@ frappe.views.MapView = class MapView extends frappe.views.ListView { this.$result.html(`
    `); + L.Icon.Default.imagePath = '/assets/frappe/images/leaflet/'; + this.map = L.map(this.map_id).setView(map_defaults.center, map_defaults.zoom); - //coords of India if markers does not exists - this.map = L.map(this.map_id).setView([12.3112899, -85.7384542], 8); - - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { - attribution: '© OpenStreetMap contributors', - maxZoom: 18 - }).addTo(this.map); + L.tileLayer(map_defaults.tiles, map_defaults.options).addTo(this.map); L.control.scale().addTo(this.map); if (this.coords.features && this.coords.features.length) { diff --git a/frappe/public/js/frappe/widgets/utils.js b/frappe/public/js/frappe/widgets/utils.js index 4599b4adc8..03fb6995e0 100644 --- a/frappe/public/js/frappe/widgets/utils.js +++ b/frappe/public/js/frappe/widgets/utils.js @@ -163,4 +163,13 @@ function get_number_system(country) { return number_system_map[country]; } -export { generate_route, generate_grid, build_summary_item, shorten_number }; +const map_defaults = { + center: [19.0800, 72.8961], + zoom: 13, + tiles: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + options: { + attribution: '© OpenStreetMap contributors' + } +}; + +export { generate_route, generate_grid, build_summary_item, shorten_number, map_defaults }; From 5131697f3cebde4a258fc44f5aa61529a9b94c41 Mon Sep 17 00:00:00 2001 From: "mathieu.brunot" Date: Wed, 23 Dec 2020 01:34:59 +0100 Subject: [PATCH 250/273] chore: Improve format of map defaults Signed-off-by: mathieu.brunot --- .../js/frappe/form/controls/geolocation.js | 8 +++++--- frappe/public/js/frappe/views/map/map_view.js | 9 +++++---- frappe/public/js/frappe/widgets/utils.js | 19 ++++++++----------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/frappe/public/js/frappe/form/controls/geolocation.js b/frappe/public/js/frappe/form/controls/geolocation.js index b6a04e5218..31a5854f9a 100644 --- a/frappe/public/js/frappe/form/controls/geolocation.js +++ b/frappe/public/js/frappe/form/controls/geolocation.js @@ -1,4 +1,4 @@ -import { map_defaults } from "../../widgets/utils"; +frappe.provide('frappe.widget.utils'); frappe.ui.form.ControlGeolocation = frappe.ui.form.ControlData.extend({ horizontal: false, @@ -92,9 +92,11 @@ frappe.ui.form.ControlGeolocation = frappe.ui.form.ControlData.extend({ }); L.Icon.Default.imagePath = '/assets/frappe/images/leaflet/'; - this.map = L.map(this.map_id).setView(map_defaults.center, map_defaults.zoom); + this.map = L.map(this.map_id).setView(frappe.widget.utils.map_defaults.center, + frappe.widget.utils.map_defaults.zoom); - L.tileLayer(map_defaults.tiles, map_defaults.options).addTo(this.map); + L.tileLayer(frappe.widget.utils.map_defaults.tiles, + frappe.widget.utils.map_defaults.options).addTo(this.map); }, bind_leaflet_locate_control() { diff --git a/frappe/public/js/frappe/views/map/map_view.js b/frappe/public/js/frappe/views/map/map_view.js index b6119eef1a..539ac86e99 100644 --- a/frappe/public/js/frappe/views/map/map_view.js +++ b/frappe/public/js/frappe/views/map/map_view.js @@ -1,8 +1,7 @@ /** * frappe.views.MapView */ -import { map_defaults } from "../../widgets/utils"; - +frappe.provide('frappe.widget.utils'); frappe.provide("frappe.views"); frappe.views.MapView = class MapView extends frappe.views.ListView { @@ -36,9 +35,11 @@ frappe.views.MapView = class MapView extends frappe.views.ListView { this.$result.html(`
    `); L.Icon.Default.imagePath = '/assets/frappe/images/leaflet/'; - this.map = L.map(this.map_id).setView(map_defaults.center, map_defaults.zoom); + this.map = L.map(this.map_id).setView(frappe.widget.utils.map_defaults.center, + frappe.widget.utils.map_defaults.zoom); - L.tileLayer(map_defaults.tiles, map_defaults.options).addTo(this.map); + L.tileLayer(frappe.widget.utils.map_defaults.tiles, + frappe.widget.utils.map_defaults.options).addTo(this.map); L.control.scale().addTo(this.map); if (this.coords.features && this.coords.features.length) { diff --git a/frappe/public/js/frappe/widgets/utils.js b/frappe/public/js/frappe/widgets/utils.js index 5121ee398e..e3632856bb 100644 --- a/frappe/public/js/frappe/widgets/utils.js +++ b/frappe/public/js/frappe/widgets/utils.js @@ -21,15 +21,12 @@ frappe.widget.utils = { }

    ${value}

    ` ); }, + map_defaults: { + center: [19.0800, 72.8961], + zoom: 13, + tiles: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + options: { + attribution: '© OpenStreetMap contributors' + } + }, }; - -const map_defaults = { - center: [19.0800, 72.8961], - zoom: 13, - tiles: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', - options: { - attribution: '© OpenStreetMap contributors' - } -}; - -export { map_defaults }; From 424c0c50f8055bc18feed762c8c3e7279e4f74ac Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 23 Dec 2020 13:50:18 +0530 Subject: [PATCH 251/273] fix: set alert flag to false by default --- frappe/core/doctype/doctype/doctype.py | 4 ++-- frappe/core/page/permission_manager/permission_manager.py | 4 ++-- frappe/permissions.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index fced5d1fa1..71fca9b597 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -1000,7 +1000,7 @@ def validate_fields(meta): check_sort_field(meta) check_image_field(meta) -def validate_permissions_for_doctype(doctype, for_remove=False, alert=True): +def validate_permissions_for_doctype(doctype, for_remove=False, alert=False): """Validates if permissions are set correctly.""" doctype = frappe.get_doc("DocType", doctype) validate_permissions(doctype, for_remove, alert=alert) @@ -1026,7 +1026,7 @@ def clear_permissions_cache(doctype): """, doctype): frappe.clear_cache(user=user) -def validate_permissions(doctype, for_remove=False, alert=True): +def validate_permissions(doctype, for_remove=False, alert=False): permissions = doctype.get("permissions") # Some DocTypes may not have permissions by default, don't show alert for them if not permissions and alert: diff --git a/frappe/core/page/permission_manager/permission_manager.py b/frappe/core/page/permission_manager/permission_manager.py index 5b4ccb6ce0..be8921e2ff 100644 --- a/frappe/core/page/permission_manager/permission_manager.py +++ b/frappe/core/page/permission_manager/permission_manager.py @@ -90,7 +90,7 @@ def update(doctype, role, permlevel, ptype, value=None): str: Refresh flag is permission is updated successfully """ frappe.only_for("System Manager") - out = update_permission_property(doctype, role, permlevel, ptype, value, alert=False) + out = update_permission_property(doctype, role, permlevel, ptype, value) return 'refresh' if out else None @frappe.whitelist() @@ -104,7 +104,7 @@ def remove(doctype, role, permlevel): if not frappe.get_all('Custom DocPerm', dict(parent=doctype)): frappe.throw(_('There must be atleast one permission rule.'), title=_('Cannot Remove')) - validate_permissions_for_doctype(doctype, for_remove=True) + validate_permissions_for_doctype(doctype, for_remove=True, alert=True) @frappe.whitelist() def reset(doctype): diff --git a/frappe/permissions.py b/frappe/permissions.py index e9724b7418..15bb2c8887 100644 --- a/frappe/permissions.py +++ b/frappe/permissions.py @@ -446,7 +446,7 @@ def can_export(doctype, raise_exception=False): raise frappe.PermissionError(_("You are not allowed to export {} doctype").format(doctype)) return has_access -def update_permission_property(doctype, role, permlevel, ptype, value=None, validate=True, alert=True): +def update_permission_property(doctype, role, permlevel, ptype, value=None, validate=True): '''Update a property in Custom Perm''' from frappe.core.doctype.doctype.doctype import validate_permissions_for_doctype out = setup_custom_perms(doctype) From 3b6ae2de6c0f1fab9ef858b0736ed43b47e87adb Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 23 Dec 2020 13:58:11 +0530 Subject: [PATCH 252/273] fix: reference error --- frappe/permissions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/permissions.py b/frappe/permissions.py index 15bb2c8887..0d766aec8d 100644 --- a/frappe/permissions.py +++ b/frappe/permissions.py @@ -458,7 +458,7 @@ def update_permission_property(doctype, role, permlevel, ptype, value=None, vali update `tabCustom DocPerm` set `{0}`=%s where name=%s""".format(ptype), (value, name)) if validate: - validate_permissions_for_doctype(doctype, alert=alert) + validate_permissions_for_doctype(doctype) return out From 3db2fd2c9f24155edc8651f3b35a2bc3e731dae2 Mon Sep 17 00:00:00 2001 From: Abhishek Balam Date: Wed, 23 Dec 2020 21:37:24 +0530 Subject: [PATCH 253/273] fix: Email Section label typo --- frappe/core/doctype/system_settings/system_settings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index 79fb84923a..7443c1b34a 100644 --- a/frappe/core/doctype/system_settings/system_settings.json +++ b/frappe/core/doctype/system_settings/system_settings.json @@ -1,4 +1,4 @@ -{ +"label": "EMail"{ "actions": [], "creation": "2014-04-17 16:53:52.640856", "doctype": "DocType", @@ -357,7 +357,7 @@ "collapsible": 1, "fieldname": "email", "fieldtype": "Section Break", - "label": "EMail" + "label": "Email" }, { "description": "Your organization name and address for the email footer.", @@ -490,4 +490,4 @@ "sort_field": "modified", "sort_order": "ASC", "track_changes": 1 -} \ No newline at end of file +} From f38615585b924b9781f1da6e7c02de97b7809c09 Mon Sep 17 00:00:00 2001 From: Abhishek Balam Date: Wed, 23 Dec 2020 21:38:43 +0530 Subject: [PATCH 254/273] fix:email section typo --- frappe/core/doctype/system_settings/system_settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index 7443c1b34a..565ee373f1 100644 --- a/frappe/core/doctype/system_settings/system_settings.json +++ b/frappe/core/doctype/system_settings/system_settings.json @@ -1,4 +1,4 @@ -"label": "EMail"{ +{ "actions": [], "creation": "2014-04-17 16:53:52.640856", "doctype": "DocType", From d6488a043ee0be982db5e54199fb242dab8dda0e Mon Sep 17 00:00:00 2001 From: "mathieu.brunot" Date: Thu, 24 Dec 2020 13:59:02 +0100 Subject: [PATCH 255/273] refactor: Move map default to utils Signed-off-by: mathieu.brunot --- frappe/public/js/frappe/form/controls/geolocation.js | 2 +- frappe/public/js/frappe/utils/utils.js | 8 ++++++++ frappe/public/js/frappe/views/map/map_view.js | 2 +- frappe/public/js/frappe/widgets/utils.js | 8 -------- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/frappe/public/js/frappe/form/controls/geolocation.js b/frappe/public/js/frappe/form/controls/geolocation.js index 31a5854f9a..96a80fb1d1 100644 --- a/frappe/public/js/frappe/form/controls/geolocation.js +++ b/frappe/public/js/frappe/form/controls/geolocation.js @@ -1,4 +1,4 @@ -frappe.provide('frappe.widget.utils'); +frappe.provide('frappe.utils.utils'); frappe.ui.form.ControlGeolocation = frappe.ui.form.ControlData.extend({ horizontal: false, diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js index f8f25293b3..32be29df92 100644 --- a/frappe/public/js/frappe/utils/utils.js +++ b/frappe/public/js/frappe/utils/utils.js @@ -1051,6 +1051,14 @@ Object.assign(frappe.utils, { return number_system_map[country]; }, + map_defaults: { + center: [19.0800, 72.8961], + zoom: 13, + tiles: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + options: { + attribution: '© OpenStreetMap contributors' + } + }, }); // Array de duplicate diff --git a/frappe/public/js/frappe/views/map/map_view.js b/frappe/public/js/frappe/views/map/map_view.js index 539ac86e99..205df5f4d3 100644 --- a/frappe/public/js/frappe/views/map/map_view.js +++ b/frappe/public/js/frappe/views/map/map_view.js @@ -1,7 +1,7 @@ /** * frappe.views.MapView */ -frappe.provide('frappe.widget.utils'); +frappe.provide('frappe.utils.utils'); frappe.provide("frappe.views"); frappe.views.MapView = class MapView extends frappe.views.ListView { diff --git a/frappe/public/js/frappe/widgets/utils.js b/frappe/public/js/frappe/widgets/utils.js index e3632856bb..ade35dae35 100644 --- a/frappe/public/js/frappe/widgets/utils.js +++ b/frappe/public/js/frappe/widgets/utils.js @@ -21,12 +21,4 @@ frappe.widget.utils = { }

    ${value}

    ` ); }, - map_defaults: { - center: [19.0800, 72.8961], - zoom: 13, - tiles: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', - options: { - attribution: '© OpenStreetMap contributors' - } - }, }; From bc9d6cff2e2a3be17101f0957f771ab9e4f5b377 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Thu, 24 Dec 2020 18:36:59 +0530 Subject: [PATCH 256/273] fix(patch): Remove Package Publish Tool doctypes (#12113) --- frappe/patches.txt | 1 + frappe/patches/v13_0/delete_package_publish_tool.py | 11 +++++++++++ 2 files changed, 12 insertions(+) create mode 100644 frappe/patches/v13_0/delete_package_publish_tool.py diff --git a/frappe/patches.txt b/frappe/patches.txt index b459019dd7..1a086303ba 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -317,3 +317,4 @@ frappe.patches.v13_0.web_template_set_module #2020-10-05 frappe.patches.v13_0.remove_custom_link execute:frappe.delete_doc("DocType", "Footer Item") frappe.patches.v13_0.replace_field_target_with_open_in_new_tab +frappe.patches.v13_0.delete_package_publish_tool diff --git a/frappe/patches/v13_0/delete_package_publish_tool.py b/frappe/patches/v13_0/delete_package_publish_tool.py new file mode 100644 index 0000000000..25024f58dd --- /dev/null +++ b/frappe/patches/v13_0/delete_package_publish_tool.py @@ -0,0 +1,11 @@ +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +from __future__ import unicode_literals +import frappe + + +def execute(): + frappe.delete_doc("DocType", "Package Publish Tool", ignore_missing=True) + frappe.delete_doc("DocType", "Package Document Type", ignore_missing=True) + frappe.delete_doc("DocType", "Package Publish Target", ignore_missing=True) From 9dde944f102a165ca2668b7c1e93207950a456fc Mon Sep 17 00:00:00 2001 From: "mathieu.brunot" Date: Thu, 24 Dec 2020 15:32:23 +0100 Subject: [PATCH 257/273] fix: Fix call to utils map defaults Signed-off-by: mathieu.brunot --- frappe/public/js/frappe/form/controls/geolocation.js | 8 ++++---- frappe/public/js/frappe/views/map/map_view.js | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/frappe/public/js/frappe/form/controls/geolocation.js b/frappe/public/js/frappe/form/controls/geolocation.js index 96a80fb1d1..9e4d1d82ec 100644 --- a/frappe/public/js/frappe/form/controls/geolocation.js +++ b/frappe/public/js/frappe/form/controls/geolocation.js @@ -92,11 +92,11 @@ frappe.ui.form.ControlGeolocation = frappe.ui.form.ControlData.extend({ }); L.Icon.Default.imagePath = '/assets/frappe/images/leaflet/'; - this.map = L.map(this.map_id).setView(frappe.widget.utils.map_defaults.center, - frappe.widget.utils.map_defaults.zoom); + this.map = L.map(this.map_id).setView(frappe.utils.map_defaults.center, + frappe.utils.map_defaults.zoom); - L.tileLayer(frappe.widget.utils.map_defaults.tiles, - frappe.widget.utils.map_defaults.options).addTo(this.map); + L.tileLayer(frappe.utils.map_defaults.tiles, + frappe.utils.map_defaults.options).addTo(this.map); }, bind_leaflet_locate_control() { diff --git a/frappe/public/js/frappe/views/map/map_view.js b/frappe/public/js/frappe/views/map/map_view.js index 205df5f4d3..a6936d58e1 100644 --- a/frappe/public/js/frappe/views/map/map_view.js +++ b/frappe/public/js/frappe/views/map/map_view.js @@ -35,11 +35,11 @@ frappe.views.MapView = class MapView extends frappe.views.ListView { this.$result.html(`
    `); L.Icon.Default.imagePath = '/assets/frappe/images/leaflet/'; - this.map = L.map(this.map_id).setView(frappe.widget.utils.map_defaults.center, - frappe.widget.utils.map_defaults.zoom); + this.map = L.map(this.map_id).setView(frappe.utils.map_defaults.center, + frappe.utils.map_defaults.zoom); - L.tileLayer(frappe.widget.utils.map_defaults.tiles, - frappe.widget.utils.map_defaults.options).addTo(this.map); + L.tileLayer(frappe.utils.map_defaults.tiles, + frappe.utils.map_defaults.options).addTo(this.map); L.control.scale().addTo(this.map); if (this.coords.features && this.coords.features.length) { From c982875b6a5490dc706d0c5412d5183c43ddb24f Mon Sep 17 00:00:00 2001 From: prssanna Date: Mon, 28 Dec 2020 13:40:12 +0530 Subject: [PATCH 258/273] fix: Show cancel button only if document is cancellable --- frappe/model/workflow.py | 5 ++--- frappe/public/js/frappe/form/toolbar.js | 20 +++++++++++++++++--- frappe/public/js/frappe/form/workflow.js | 13 ++----------- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/frappe/model/workflow.py b/frappe/model/workflow.py index 43e26cc5d0..3e8125f9b1 100644 --- a/frappe/model/workflow.py +++ b/frappe/model/workflow.py @@ -120,9 +120,8 @@ def apply_workflow(doc, action): return doc @frappe.whitelist() -def can_cancel_document(doc): - doc = frappe.get_doc(frappe.parse_json(doc)) - workflow = get_workflow(doc.doctype) +def can_cancel_document(doctype): + workflow = get_workflow(doctype) for state_doc in workflow.states: if state_doc.doc_status == '2': for transition in workflow.transitions: diff --git a/frappe/public/js/frappe/form/toolbar.js b/frappe/public/js/frappe/form/toolbar.js index c7fb69a2b5..d8a2b91277 100644 --- a/frappe/public/js/frappe/form/toolbar.js +++ b/frappe/public/js/frappe/form/toolbar.js @@ -441,9 +441,23 @@ frappe.ui.form.Toolbar = Class.extend({ me.frm.page.set_view('main'); }, 'octicon octicon-pencil'); } else if(status === "Cancel") { - this.page.set_secondary_action(__(status), function() { - me.frm.savecancel(this); - }, "octicon octicon-circle-slash"); + let add_cancel_button = () => { + this.page.set_secondary_action(__(status), function() { + me.frm.savecancel(this); + }, "octicon octicon-circle-slash"); + }; + if (this.has_workflow()) { + frappe.xcall( + 'frappe.model.workflow.can_cancel_document', { + 'doctype': this.frm.doc.doctype, + }).then((can_cancel) => { + if (can_cancel) { + add_cancel_button(); + } + }); + } else { + add_cancel_button(); + } } else { var click = { "Save": function() { diff --git a/frappe/public/js/frappe/form/workflow.js b/frappe/public/js/frappe/form/workflow.js index 4c59e8219b..16d9f8676b 100644 --- a/frappe/public/js/frappe/form/workflow.js +++ b/frappe/public/js/frappe/form/workflow.js @@ -85,7 +85,7 @@ frappe.ui.form.States = Class.extend({ frappe.workflow.get_transitions(this.frm.doc).then(transitions => { this.frm.page.clear_actions_menu(); transitions.forEach(d => { - if(frappe.user_roles.includes(d.allowed) && has_approval_access(d)) { + if (frappe.user_roles.includes(d.allowed) && has_approval_access(d)) { added = true; me.frm.page.add_action_item(__(d.action), function() { // set the workflow_action for use in form scripts @@ -103,17 +103,8 @@ frappe.ui.form.States = Class.extend({ }); } }); - if (!added) { - //call function and clear cancel button if Cancel doc state is defined in the workfloe - frappe.xcall('frappe.model.workflow.can_cancel_document', {doc: this.frm.doc}).then((can_cancel) => { - if (!can_cancel) { - this.frm.page.clear_secondary_action(); - } - }); - } else { - this.setup_btn(added); - } + this.setup_btn(added); }); }, From 6a02f5ad52cda9ae9fe534a70a04f8b5bc310756 Mon Sep 17 00:00:00 2001 From: "mathieu.brunot" Date: Tue, 29 Dec 2020 01:27:13 +0100 Subject: [PATCH 259/273] style: Fix Sider issues Signed-off-by: mathieu.brunot --- frappe/public/js/frappe/views/map/map_view.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frappe/public/js/frappe/views/map/map_view.js b/frappe/public/js/frappe/views/map/map_view.js index a6936d58e1..878311b9bd 100644 --- a/frappe/public/js/frappe/views/map/map_view.js +++ b/frappe/public/js/frappe/views/map/map_view.js @@ -43,11 +43,11 @@ frappe.views.MapView = class MapView extends frappe.views.ListView { L.control.scale().addTo(this.map); if (this.coords.features && this.coords.features.length) { - this.coords.features.forEach( - coords => L.geoJSON(coords).bindPopup(coords.properties.name).addTo(this.map) - ); - let lastCoords = this.coords.features[0].geometry.coordinates.reverse(); - this.map.panTo(lastCoords, 8); + this.coords.features.forEach( + coords => L.geoJSON(coords).bindPopup(coords.properties.name).addTo(this.map) + ); + let lastCoords = this.coords.features[0].geometry.coordinates.reverse(); + this.map.panTo(lastCoords, 8); } } From 97b693c6b0c31f7ae652d2faa88648440fd5f81a Mon Sep 17 00:00:00 2001 From: Saurabh Date: Tue, 29 Dec 2020 16:58:28 +0530 Subject: [PATCH 260/273] feat: Added permission to grant only `Select` access to document (#12063) * feat: add permtype 'select' to DocPerm and CustomDocPerm * feat: add 'select' perm in rights tupple * feat: provisions to handle select permission * feat: toggle href based on permissions * feat: pass permission type explicitly while validating link in permission check * fix: sider * feat: added test cases to validate select perm * feat: add method frappe.only_has_select_perm to explicitly check the select perm * fix: if user only has select perm then do not show anchor tag for link fields * fix: sider --- frappe/__init__.py | 15 + .../custom_docperm/custom_docperm.json | 11 +- frappe/core/doctype/docperm/docperm.json | 664 ++---------------- .../permission_manager/permission_manager.js | 2 +- frappe/desk/search.py | 3 +- frappe/model/db_query.py | 12 +- frappe/permissions.py | 7 +- frappe/public/js/frappe/form/controls/link.js | 10 + frappe/public/js/frappe/form/formatters.js | 15 +- frappe/public/js/frappe/model/model.js | 8 +- frappe/tests/test_permissions.py | 20 +- frappe/utils/user.py | 9 +- 12 files changed, 151 insertions(+), 625 deletions(-) diff --git a/frappe/__init__.py b/frappe/__init__.py index 9958ae9700..4040a38e62 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -628,6 +628,21 @@ def clear_cache(user=None, doctype=None): local.role_permissions = {} +def only_has_select_perm(doctype, user=None, ignore_permissions=False): + if ignore_permissions: + return False + + if not user: + user = local.session.user + + import frappe.permissions + permissions = frappe.permissions.get_role_permissions(doctype, user=user) + + if permissions.get('select') and not permissions.get('read'): + return True + else: + return False + def has_permission(doctype=None, ptype="read", doc=None, user=None, verbose=False, throw=False): """Raises `frappe.PermissionError` if not permitted. diff --git a/frappe/core/doctype/custom_docperm/custom_docperm.json b/frappe/core/doctype/custom_docperm/custom_docperm.json index f8f7f58be1..93f5431903 100644 --- a/frappe/core/doctype/custom_docperm/custom_docperm.json +++ b/frappe/core/doctype/custom_docperm/custom_docperm.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_import": 1, "autoname": "hash", "creation": "2017-01-11 04:21:35.217943", @@ -13,6 +14,7 @@ "column_break_2", "permlevel", "section_break_4", + "select", "read", "write", "create", @@ -211,9 +213,16 @@ "fieldtype": "Data", "label": "Reference Document Type", "read_only": 1 + }, + { + "default": "0", + "fieldname": "select", + "fieldtype": "Check", + "label": "Select" } ], - "modified": "2019-10-31 16:58:16.157079", + "links": [], + "modified": "2020-12-03 15:20:48.296730", "modified_by": "Administrator", "module": "Core", "name": "Custom DocPerm", diff --git a/frappe/core/doctype/docperm/docperm.json b/frappe/core/doctype/docperm/docperm.json index 1a23118a29..4411a67435 100644 --- a/frappe/core/doctype/docperm/docperm.json +++ b/frappe/core/doctype/docperm/docperm.json @@ -1,775 +1,229 @@ { - "allow_copy": 0, - "allow_guest_to_view": 0, - "allow_import": 0, - "allow_rename": 0, + "actions": [], "autoname": "hash", - "beta": 0, "creation": "2013-02-22 01:27:33", - "custom": 0, - "docstatus": 0, "doctype": "DocType", "document_type": "Setup", "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "role_and_level", + "role", + "if_owner", + "column_break_2", + "permlevel", + "section_break_4", + "select", + "read", + "write", + "create", + "delete", + "column_break_8", + "submit", + "cancel", + "amend", + "additional_permissions", + "report", + "export", + "import", + "set_user_permissions", + "column_break_19", + "share", + "print", + "email" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "role_and_level", "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Role and Level", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Role and Level" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "role", "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, "label": "Role", - "length": 0, - "no_copy": 0, "oldfieldname": "role", "oldfieldtype": "Link", "options": "Role", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, "print_width": "150px", - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, "width": "150px" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, + "default": "0", "description": "Apply this rule if the User is the Owner", "fieldname": "if_owner", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "If user is the owner", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "If user is the owner" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "column_break_2", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldtype": "Column Break" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "default": "0", "fieldname": "permlevel", "fieldtype": "Int", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, "label": "Level", - "length": 0, - "no_copy": 0, "oldfieldname": "permlevel", "oldfieldtype": "Int", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, "print_width": "40px", - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, "width": "40px" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "section_break_4", "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Permissions", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Permissions" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "default": "1", "fieldname": "read", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, "label": "Read", - "length": 0, - "no_copy": 0, "oldfieldname": "read", "oldfieldtype": "Check", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, "print_width": "32px", - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, "width": "32px" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "default": "1", "fieldname": "write", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, "label": "Write", - "length": 0, - "no_copy": 0, "oldfieldname": "write", "oldfieldtype": "Check", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, "print_width": "32px", - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, "width": "32px" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "default": "1", "fieldname": "create", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, "label": "Create", - "length": 0, - "no_copy": 0, "oldfieldname": "create", "oldfieldtype": "Check", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, "print_width": "32px", - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, "width": "32px" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "default": "1", "fieldname": "delete", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, - "label": "Delete", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Delete" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "column_break_8", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldtype": "Column Break" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, + "default": "0", "fieldname": "submit", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, "label": "Submit", - "length": 0, - "no_copy": 0, "oldfieldname": "submit", "oldfieldtype": "Check", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, "print_width": "32px", - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, "width": "32px" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, + "default": "0", "fieldname": "cancel", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, "in_list_view": 1, - "in_standard_filter": 0, "label": "Cancel", - "length": 0, - "no_copy": 0, "oldfieldname": "cancel", "oldfieldtype": "Check", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, "print_width": "32px", - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, "width": "32px" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, + "default": "0", "fieldname": "amend", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, "label": "Amend", - "length": 0, - "no_copy": 0, "oldfieldname": "amend", "oldfieldtype": "Check", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, "print_width": "32px", - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, "width": "32px" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "additional_permissions", "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Additional Permissions", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Additional Permissions" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "default": "1", "fieldname": "report", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, "label": "Report", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, "print_width": "32px", - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0, "width": "32px" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "default": "1", "fieldname": "export", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Export", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Export" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, + "default": "0", "fieldname": "import", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Import", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Import" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, + "default": "0", "description": "This role update User Permissions for a user", "fieldname": "set_user_permissions", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Set User Permissions", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Set User Permissions" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "fieldname": "column_break_19", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldtype": "Column Break" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "default": "1", "fieldname": "share", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Share", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Share" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "default": "1", "fieldname": "print", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Print", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Print" }, { - "allow_bulk_edit": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, "default": "1", "fieldname": "email", "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Email", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "label": "Email" + }, + { + "default": "0", + "fieldname": "select", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Select" } ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, "idx": 1, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, "istable": 1, - "max_attachments": 0, - "modified": "2018-05-29 11:54:38.613936", + "links": [], + "modified": "2020-12-03 15:15:30.488212", "modified_by": "Administrator", "module": "Core", "name": "DocPerm", "owner": "Administrator", "permissions": [], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 0, - "sort_order": "ASC", - "track_changes": 0, - "track_seen": 0 + "sort_field": "modified", + "sort_order": "ASC" } \ No newline at end of file diff --git a/frappe/core/page/permission_manager/permission_manager.js b/frappe/core/page/permission_manager/permission_manager.js index 0d3267c7d5..02fbf943d5 100644 --- a/frappe/core/page/permission_manager/permission_manager.js +++ b/frappe/core/page/permission_manager/permission_manager.js @@ -269,7 +269,7 @@ frappe.PermissionEngine = Class.extend({ .css({"margin-top": "15px"}); }, - rights: ["read", "write", "create", "delete", "submit", "cancel", "amend", + rights: ["select", "read", "write", "create", "delete", "submit", "cancel", "amend", "print", "email", "report", "import", "export", "set_user_permissions", "share"], set_show_users: function(cell, role) { diff --git a/frappe/desk/search.py b/frappe/desk/search.py index f249c36746..f4e6543844 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -150,7 +150,8 @@ def search_widget(doctype, txt, query=None, searchfield=None, start=0, # 2 is the index of _relevance column order_by = "_relevance, {0}, `tab{1}`.idx desc".format(order_by_based_on_meta, doctype) - ignore_permissions = True if doctype == "DocType" else (cint(ignore_user_permissions) and has_permission(doctype)) + ptype = 'select' if frappe.only_has_select_perm(doctype) else 'read' + ignore_permissions = True if doctype == "DocType" else (cint(ignore_user_permissions) and has_permission(doctype, ptype=ptype)) if doctype in UNTRANSLATED_DOCTYPES: page_length = None diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index b936251b50..c799586d61 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -40,7 +40,10 @@ class DatabaseQuery(object): ignore_ifnull=False, save_user_settings=False, save_user_settings_fields=False, update=None, add_total_row=None, user_settings=None, reference_doctype=None, return_query=False, strict=True, pluck=None, ignore_ddl=False): - if not ignore_permissions and not frappe.has_permission(self.doctype, "read", user=user): + if not ignore_permissions and \ + not frappe.has_permission(self.doctype, "select", user=user) and \ + not frappe.has_permission(self.doctype, "read", user=user): + frappe.flags.error_message = _('Insufficient Permission for {0}').format(frappe.bold(self.doctype)) raise frappe.PermissionError(self.doctype) @@ -315,7 +318,10 @@ class DatabaseQuery(object): def append_table(self, table_name): self.tables.append(table_name) doctype = table_name[4:-1] - if (not self.flags.ignore_permissions) and (not frappe.has_permission(doctype)): + ptype = 'select' if frappe.only_has_select_perm(doctype) else 'read' + + if (not self.flags.ignore_permissions) and\ + (not frappe.has_permission(doctype, ptype=ptype)): frappe.flags.error_message = _('Insufficient Permission for {0}').format(frappe.bold(doctype)) raise frappe.PermissionError(doctype) @@ -576,7 +582,7 @@ class DatabaseQuery(object): self.shared = frappe.share.get_shared(self.doctype, self.user) if (not meta.istable and - not role_permissions.get("read") and + not (role_permissions.get("select") or role_permissions.get("read")) and not self.flags.ignore_permissions and not has_any_user_permission_for_doctype(self.doctype, self.user, self.reference_doctype)): only_if_shared = True diff --git a/frappe/permissions.py b/frappe/permissions.py index 0d766aec8d..a45fbdcd06 100644 --- a/frappe/permissions.py +++ b/frappe/permissions.py @@ -7,7 +7,7 @@ import frappe, copy, json from frappe import _, msgprint from frappe.utils import cint import frappe.share -rights = ("read", "write", "create", "delete", "submit", "cancel", "amend", +rights = ("select", "read", "write", "create", "delete", "submit", "cancel", "amend", "print", "email", "report", "import", "export", "set_user_permissions", "share") # TODO: @@ -73,6 +73,7 @@ def has_permission(doctype, ptype="read", doc=None, verbose=False, user=None, ra role_permissions = get_role_permissions(meta, user=user) perm = role_permissions.get(ptype) + if not perm: push_perm_check_log(_('User {0} does not have doctype access via role permission for document {1}').format(frappe.bold(user), frappe.bold(doctype))) @@ -192,9 +193,9 @@ def get_role_permissions(doctype_meta, user=None): and ptype != 'create'): perms['if_owner'][ptype] = 1 # has no access if not owner - # only provide read access so that user is able to at-least access list + # only provide select or read access so that user is able to at-least access list # (and the documents will be filtered based on owner sin further checks) - perms[ptype] = 1 if ptype == 'read' else 0 + perms[ptype] = 1 if ptype in ['select', 'read'] else 0 frappe.local.role_permissions[cache_key] = perms diff --git a/frappe/public/js/frappe/form/controls/link.js b/frappe/public/js/frappe/form/controls/link.js index 56f9430238..111ee7d8f6 100644 --- a/frappe/public/js/frappe/form/controls/link.js +++ b/frappe/public/js/frappe/form/controls/link.js @@ -215,6 +215,7 @@ frappe.ui.form.ControlLink = frappe.ui.form.ControlData.extend({ } me.$input.cache[doctype][term] = r.results; me.awesomplete.list = me.$input.cache[doctype][term]; + me.toggle_href(doctype); } }); }, 500)); @@ -296,6 +297,15 @@ frappe.ui.form.ControlLink = frappe.ui.form.ControlData.extend({ // returns [{value: 'Manufacturer 1', 'description': 'mobile part 1, mobile part 2'}] }, + toggle_href(doctype) { + if (frappe.model.can_select(doctype) && !frappe.model.can_read(doctype)) { + // remove href from link field as user has only select perm + this.$input_area.find(".link-btn").addClass('hide'); + } else { + this.$input_area.find(".link-btn").removeClass('hide'); + } + }, + get_filter_description(filters) { let doctype = this.get_options(); let filter_array = []; diff --git a/frappe/public/js/frappe/form/formatters.js b/frappe/public/js/frappe/form/formatters.js index f9a1d0b643..2b8956653b 100644 --- a/frappe/public/js/frappe/form/formatters.js +++ b/frappe/public/js/frappe/form/formatters.js @@ -128,11 +128,16 @@ frappe.form.formatters = { return repl('%(value)s', {onclick: docfield.link_onclick.replace(/"/g, '"'), value:value}); } else if(docfield && doctype) { - return ` - ${__(options && options.label || value)}` + if (!frappe.model.can_select(doctype) && frappe.model.can_read(doctype)) { + return ` + ${__(options && options.label || value)}`; + } else { + return value; + } + } else { return value; } diff --git a/frappe/public/js/frappe/model/model.js b/frappe/public/js/frappe/model/model.js index 1d302215dd..e82f64c6fc 100644 --- a/frappe/public/js/frappe/model/model.js +++ b/frappe/public/js/frappe/model/model.js @@ -135,8 +135,8 @@ $.extend(frappe.model, { let cached_timestamp = null; let cached_doc = null; - let cached_docs = frappe.model.get_from_localstorage(doctype) - + let cached_docs = frappe.model.get_from_localstorage(doctype); + if (cached_docs) { cached_doc = cached_docs.filter(doc => doc.name === doctype)[0]; if(cached_doc) { @@ -252,6 +252,10 @@ $.extend(frappe.model, { return frappe.boot.user.can_create.indexOf(doctype)!==-1; }, + can_select: function(doctype) { + return frappe.boot.user.can_select.indexOf(doctype)!==-1; + }, + can_read: function(doctype) { return frappe.boot.user.can_read.indexOf(doctype)!==-1; }, diff --git a/frappe/tests/test_permissions.py b/frappe/tests/test_permissions.py index dddc790c94..6897d500c9 100644 --- a/frappe/tests/test_permissions.py +++ b/frappe/tests/test_permissions.py @@ -9,7 +9,7 @@ import frappe.defaults import unittest import frappe.model.meta from frappe.permissions import (add_user_permission, remove_user_permission, - clear_user_permissions_for_doctype, get_doc_permissions, add_permission) + clear_user_permissions_for_doctype, get_doc_permissions, add_permission, update_permission_property) from frappe.core.page.permission_manager.permission_manager import update, reset from frappe.test_runner import make_test_records_for_doctype from frappe.core.doctype.user_permission.user_permission import clear_user_permissions @@ -58,6 +58,24 @@ class TestPermissions(unittest.TestCase): post = frappe.get_doc("Blog Post", "-test-blog-post") self.assertTrue(post.has_permission("read")) + def test_select_permission(self): + # grant only select perm to blog post + add_permission('Blog Post', 'Sales User', 0) + update_permission_property('Blog Post', 'Sales User', 0, 'select', 1) + update_permission_property('Blog Post', 'Sales User', 0, 'read', 0) + update_permission_property('Blog Post', 'Sales User', 0, 'write', 0) + + frappe.clear_cache(doctype="Blog Post") + frappe.set_user("test3@example.com") + + # validate select perm + post = frappe.get_doc("Blog Post", "-test-blog-post") + self.assertTrue(post.has_permission("select")) + + # validate does not have read and write perm + self.assertFalse(post.has_permission("read")) + self.assertRaises(frappe.PermissionError, post.save) + def test_user_permissions_in_doc(self): add_user_permission("Blog Category", "-test-blog-category-1", "test2@example.com") diff --git a/frappe/utils/user.py b/frappe/utils/user.py index 7ee47cb197..ee9ee5dae9 100755 --- a/frappe/utils/user.py +++ b/frappe/utils/user.py @@ -22,6 +22,7 @@ class UserPermissions: self.all_read = [] self.can_create = [] + self.can_select = [] self.can_read = [] self.can_write = [] self.can_cancel = [] @@ -104,6 +105,9 @@ class UserPermissions: if not p.get("read") and (dt in user_shared): p["read"] = 1 + if p.get('select'): + self.can_select.append(dt) + if not dtp.get('istable'): if p.get('create') and not dtp.get('issingle'): if dtp.get('in_create'): @@ -193,9 +197,8 @@ class UserPermissions: d.name = self.name d.roles = self.get_roles() d.defaults = self.get_defaults() - - for key in ("can_create", "can_write", "can_read", "can_cancel", "can_delete", - "can_get_report", "allow_modules", "all_read", "can_search", + for key in ("can_select", "can_create", "can_write", "can_read", "can_cancel", + "can_delete", "can_get_report", "allow_modules", "all_read", "can_search", "in_create", "can_export", "can_import", "can_print", "can_email", "can_set_user_permissions"): d[key] = list(set(getattr(self, key))) From f055604167cf762655d6d0d49cee07016e1b3317 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 31 Dec 2020 13:11:46 +0530 Subject: [PATCH 261/273] fix: default str for json dumps --- frappe/integrations/doctype/webhook/webhook.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/integrations/doctype/webhook/webhook.py b/frappe/integrations/doctype/webhook/webhook.py index f1556aa661..ad64d9f714 100644 --- a/frappe/integrations/doctype/webhook/webhook.py +++ b/frappe/integrations/doctype/webhook/webhook.py @@ -85,7 +85,7 @@ def enqueue_webhook(doc, webhook): for i in range(3): try: - r = requests.post(webhook.request_url, data=json.dumps(data), headers=headers, timeout=5) + r = requests.post(webhook.request_url, data=json.dumps(data, default=str), headers=headers, timeout=5) r.raise_for_status() frappe.logger().debug({"webhook_success": r.text}) break From 282030be5fb7e9485d3f3702228b5f6966ba8870 Mon Sep 17 00:00:00 2001 From: Anupam Date: Thu, 31 Dec 2020 15:19:35 +0530 Subject: [PATCH 262/273] fix: auto-repeat issue --- frappe/automation/doctype/auto_repeat/auto_repeat.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.js b/frappe/automation/doctype/auto_repeat/auto_repeat.js index c2c84692d8..d54ae8d62c 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.js +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.js @@ -54,10 +54,12 @@ frappe.ui.form.on('Auto Repeat', { toggle_submit_on_creation: function(frm) { // submit on creation checkbox - frappe.model.with_doctype(frm.doc.reference_doctype, () => { - let meta = frappe.get_meta(frm.doc.reference_doctype); - frm.toggle_display('submit_on_creation', meta.is_submittable); - }); + if (frm.doc.reference_doctype) { + frappe.model.with_doctype(frm.doc.reference_doctype, () => { + let meta = frappe.get_meta(frm.doc.reference_doctype); + frm.toggle_display('submit_on_creation', meta.is_submittable); + }); + } }, template: function(frm) { From aad5ace31ab02665e48fe1f5083477bedace6adc Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 4 Jan 2021 11:38:39 +0530 Subject: [PATCH 263/273] fix: clear cache after removing server scripts --- frappe/core/doctype/server_script/test_server_script.py | 1 + 1 file changed, 1 insertion(+) diff --git a/frappe/core/doctype/server_script/test_server_script.py b/frappe/core/doctype/server_script/test_server_script.py index 957cbbf72d..8dd6d03fee 100644 --- a/frappe/core/doctype/server_script/test_server_script.py +++ b/frappe/core/doctype/server_script/test_server_script.py @@ -81,6 +81,7 @@ class TestServerScript(unittest.TestCase): def tearDownClass(cls): frappe.db.commit() frappe.db.sql('truncate `tabServer Script`') + frappe.cache().delete_key('server_script_map') def setUp(self): frappe.cache().delete_value('server_script_map') From 35c612e07629362cb0b9ce275b1e5133c39fbb70 Mon Sep 17 00:00:00 2001 From: Anupam Kumar Date: Tue, 5 Jan 2021 16:15:25 +0530 Subject: [PATCH 264/273] fix: translator url (#12144) --- frappe/hooks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/hooks.py b/frappe/hooks.py index 3d7ae0abb4..ea0a91a639 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -18,7 +18,7 @@ app_email = "info@frappe.io" docs_app = "frappe_io" -translator_url = "https://translatev2.erpnext.com" +translator_url = "https://translate.erpnext.com" before_install = "frappe.utils.install.before_install" after_install = "frappe.utils.install.after_install" From 0b5868af0072fe5bb3dbe7e9dc4a97a5872fe800 Mon Sep 17 00:00:00 2001 From: gavin Date: Tue, 5 Jan 2021 16:52:38 +0530 Subject: [PATCH 265/273] fix: Strip HTML only if string is passed, else evaluate like before (#12157) --- frappe/public/js/frappe/ui/field_group.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/ui/field_group.js b/frappe/public/js/frappe/ui/field_group.js index 67aeb4474e..c37ea57dae 100644 --- a/frappe/public/js/frappe/ui/field_group.js +++ b/frappe/public/js/frappe/ui/field_group.js @@ -86,7 +86,10 @@ frappe.ui.FieldGroup = frappe.ui.form.Layout.extend({ var f = this.fields_dict[key]; if (f.get_value) { var v = f.get_value(); - if (f.df.reqd && is_null(strip_html(v))) + if ( + f.df.reqd && + is_null(typeof v === 'string' ? strip_html(v) : v) + ) errors.push(__(f.df.label)); if (f.df.reqd From dfc5fb3b5d0d9d00b01ccfa72d2f3a3ec7716625 Mon Sep 17 00:00:00 2001 From: Anupam Kumar Date: Tue, 5 Jan 2021 17:05:35 +0530 Subject: [PATCH 266/273] fix: list view comment count (#12156) --- frappe/core/doctype/comment/comment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/core/doctype/comment/comment.py b/frappe/core/doctype/comment/comment.py index a2105c1511..04ecc83b38 100644 --- a/frappe/core/doctype/comment/comment.py +++ b/frappe/core/doctype/comment/comment.py @@ -150,7 +150,7 @@ def update_comments_in_parent(reference_doctype, reference_name, _comments): try: # use sql, so that we do not mess with the timestamp frappe.db.sql("""update `tab{0}` set `_comments`=%s where name=%s""".format(reference_doctype), # nosec - (json.dumps(_comments[-50:]), reference_name)) + (json.dumps(_comments[-100:]), reference_name)) except Exception as e: if frappe.db.is_column_missing(e) and getattr(frappe.local, 'request', None): From f54ec2ba11bb386cf88afe2cfe94106ef732af2d Mon Sep 17 00:00:00 2001 From: Tim Gates Date: Tue, 5 Jan 2021 22:48:09 +1100 Subject: [PATCH 267/273] docs: fix simple typo, transaltion -> translation (#12136) Co-authored-by: Suraj Shetty <13928957+surajshetty3416@users.noreply.github.com> --- frappe/translate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/translate.py b/frappe/translate.py index 3685daf986..2cee8c34b5 100644 --- a/frappe/translate.py +++ b/frappe/translate.py @@ -190,7 +190,7 @@ def get_full_dict(lang): frappe.local.lang_full_dict = load_lang(lang) try: - # get user specific transaltion data + # get user specific translation data user_translations = get_user_translations(lang) frappe.local.lang_full_dict.update(user_translations) except Exception: From 3e6dd594efc321406100b2dca816ebe6d0a9bd16 Mon Sep 17 00:00:00 2001 From: Wolfram Schmidt Date: Tue, 5 Jan 2021 12:48:54 +0100 Subject: [PATCH 268/273] fix: translation (#12117) --- frappe/translations/de.csv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/translations/de.csv b/frappe/translations/de.csv index f1d72c1443..5b45d8c217 100644 --- a/frappe/translations/de.csv +++ b/frappe/translations/de.csv @@ -1577,7 +1577,7 @@ Monospace,Monospace, More articles on {0},Weitere Artikel zum {0}, More content for the bottom of the page.,Zusätzlicher Inhalt für den unteren Teil der Seite., Most Used,Am Meisten verwendet, -Move To,Ziehen nach, +Move To,Bewegen nach, Move To Trash,In den Papierkorb verschieben, Move to Row Number,Gehe zu Zeilennummer, Mr,Hr., From 213744aa4949279c13f8f92c95700bab9c711be6 Mon Sep 17 00:00:00 2001 From: Sagar Vora Date: Wed, 6 Jan 2021 17:34:48 +0530 Subject: [PATCH 269/273] test: fix test_money_in_words (#12166) --- frappe/tests/test_translate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/frappe/tests/test_translate.py b/frappe/tests/test_translate.py index 4dcaf3e979..4f1b69cc76 100644 --- a/frappe/tests/test_translate.py +++ b/frappe/tests/test_translate.py @@ -18,6 +18,7 @@ class TestTranslate(unittest.TestCase): frappe.local.lang = 'fr' self.assertEqual(_('Change'), 'Changement') self.assertEqual(_('Change', context='Coins'), 'la monnaie') + frappe.local.lang = 'en' expected_output = [ ('apps/frappe/frappe/tests/translation_test_file.txt', 'Warning: Unable to find {0} in any table related to {1}', 'This is some context', 2), From 4f407ac2f4f40102c95098d6cad47dd0d799f4e0 Mon Sep 17 00:00:00 2001 From: "hasnain2808@gmail.com" Date: Thu, 7 Jan 2021 10:46:28 +0530 Subject: [PATCH 270/273] fix: strip_html breaks when it gets undefined --- frappe/public/js/frappe/utils/common.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/utils/common.js b/frappe/public/js/frappe/utils/common.js index 0a145b098b..20eb4393a3 100644 --- a/frappe/public/js/frappe/utils/common.js +++ b/frappe/public/js/frappe/utils/common.js @@ -108,7 +108,7 @@ window.replace_all = function(s, t1, t2) { } window.strip_html = function(txt) { - return txt.replace(/<[^>]*>/g, ""); + return cstr(txt).replace(/<[^>]*>/g, ""); } window.strip = function(s, chars) { From 11c01cd52d557722b5f6e53ede65370f2e97f5ff Mon Sep 17 00:00:00 2001 From: "Manduul. B" Date: Fri, 8 Jan 2021 13:41:50 +0800 Subject: [PATCH 271/273] fix: Wrong closing of h5 tag (#12178) --- frappe/website/doctype/blog_post/templates/blog_post_row.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/website/doctype/blog_post/templates/blog_post_row.html b/frappe/website/doctype/blog_post/templates/blog_post_row.html index 7daf27adc8..53539c33e0 100644 --- a/frappe/website/doctype/blog_post/templates/blog_post_row.html +++ b/frappe/website/doctype/blog_post/templates/blog_post_row.html @@ -21,7 +21,7 @@ {%- if post.featured -%}
    {{ post.title }}
    {%- else -%} -
    {{ post.title }}
    +
    {{ post.title }}
    {%- endif -%}

    {{ post.intro }}

    @@ -38,4 +38,4 @@
    - \ No newline at end of file + From 988367828086655fea2d7ed8f5bfe60689019bf2 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Fri, 8 Jan 2021 11:28:16 +0530 Subject: [PATCH 272/273] fix(DocType): typos in version_html (endif instead of endfor) (bp #12106) (#12112) * fix(DocType): typos in version_html (endif instead of endfor) (cherry picked from commit a4f48766a1d44497eaab7306c1b900ddc3d297c2) Co-authored-by: ci2014 --- frappe/core/doctype/version/version_view.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frappe/core/doctype/version/version_view.html b/frappe/core/doctype/version/version_view.html index 5383be82a1..67f005ed4c 100644 --- a/frappe/core/doctype/version/version_view.html +++ b/frappe/core/doctype/version/version_view.html @@ -21,7 +21,7 @@ {{ item[1] }} {{ item[2] }} - {% endif %} + {% endfor %} {% endif %} @@ -58,7 +58,7 @@ - {% endif %} + {% endfor %} @@ -93,4 +93,4 @@ {% endfor %} {% endif %} - \ No newline at end of file + From f939ec87cc41b41f008850a85505376874dc3083 Mon Sep 17 00:00:00 2001 From: Snyk bot Date: Fri, 8 Jan 2021 08:01:33 +0200 Subject: [PATCH 273/273] fix(Snyk): Security upgrade socket.io from 2.3.0 to 2.4.0 (#12181) The following vulnerabilities are fixed with an upgrade: - https://snyk.io/vuln/SNYK-JS-SOCKETIO-1024859 --- package.json | 2 +- yarn.lock | 135 ++++++++++++++++++++------------------------------- 2 files changed, 53 insertions(+), 84 deletions(-) diff --git a/package.json b/package.json index 8603d8e071..fcbc349307 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "redis": "^2.8.0", "showdown": "^1.9.1", "snyk": "^1.425.4", - "socket.io": "^2.3.0", + "socket.io": "^2.4.0", "superagent": "^3.8.2", "touch": "^3.1.0", "vue": "^2.6.11", diff --git a/yarn.lock b/yarn.lock index 072810faa3..3810b88e47 100644 --- a/yarn.lock +++ b/yarn.lock @@ -569,11 +569,6 @@ async-foreach@^0.1.3: resolved "https://registry.yarnpkg.com/async-foreach/-/async-foreach-0.1.3.tgz#36121f845c0578172de419a97dbeb1d16ec34542" integrity sha1-NhIfhFwFeBct5Bmpfb6x0W7DRUI= -async-limiter@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.0.tgz#78faed8c3d074ab81f22b4e985d79e8738f720f8" - integrity sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg== - async@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/async/-/async-3.2.0.tgz#b3a2685c5ebb641d3de02d161002c60fc9f85720" @@ -677,13 +672,6 @@ bcrypt-pbkdf@^1.0.0, bcrypt-pbkdf@^1.0.2: dependencies: tweetnacl "^0.14.3" -better-assert@~1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/better-assert/-/better-assert-1.0.2.tgz#40866b9e1b9e0b55b481894311e68faffaebc522" - integrity sha1-QIZrnhueC1W0gYlDEeaPr/rrxSI= - dependencies: - callsite "1.0.0" - big.js@^3.1.3: version "3.2.0" resolved "https://registry.yarnpkg.com/big.js/-/big.js-3.2.0.tgz#a5fc298b81b9e0dca2e458824784b65c52ba588e" @@ -914,11 +902,6 @@ caller-path@^2.0.0: dependencies: caller-callsite "^2.0.0" -callsite@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/callsite/-/callsite-1.0.0.tgz#280398e5d664bd74038b6f0905153e6e8af1bc20" - integrity sha1-KAOY5dZkvXQDi28JBRU+borxvCA= - callsites@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-2.0.0.tgz#06eb84f00eea413da86affefacbffb36093b3c50" @@ -1172,6 +1155,11 @@ component-emitter@1.2.1, component-emitter@^1.2.0, component-emitter@^1.2.1: resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6" integrity sha1-E3kY1teCg/ffemt8WmPhQOaUJeY= +component-emitter@~1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" + integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== + component-inherit@0.0.3: version "0.0.3" resolved "https://registry.yarnpkg.com/component-inherit/-/component-inherit-0.0.3.tgz#645fc4adf58b72b649d5cae65135619db26ff143" @@ -1230,16 +1218,16 @@ cookie-signature@1.0.6: resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw= -cookie@0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb" - integrity sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s= - cookie@0.4.0, cookie@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba" integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg== +cookie@~0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1" + integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA== + cookiejar@^2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.2.tgz#dd8a235530752f988f9a0844f3fc589e3111125c" @@ -1829,20 +1817,20 @@ endian-reader@^0.3.0: resolved "https://registry.yarnpkg.com/endian-reader/-/endian-reader-0.3.0.tgz#84eca436b80aed0d0639c47291338b932efe50a0" integrity sha1-hOykNrgK7Q0GOcRykTOLky7+UKA= -engine.io-client@~3.4.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.4.0.tgz#82a642b42862a9b3f7a188f41776b2deab643700" - integrity sha512-a4J5QO2k99CM2a0b12IznnyQndoEvtA4UAldhGzKqnHf42I3Qs2W5SPnDvatZRcMaNZs4IevVicBPayxYt6FwA== +engine.io-client@~3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.5.0.tgz#fc1b4d9616288ce4f2daf06dcf612413dec941c7" + integrity sha512-12wPRfMrugVw/DNyJk34GQ5vIVArEcVMXWugQGGuw2XxUSztFNmJggZmv8IZlLyEdnpO1QB9LkcjeWewO2vxtA== dependencies: - component-emitter "1.2.1" + component-emitter "~1.3.0" component-inherit "0.0.3" - debug "~4.1.0" + debug "~3.1.0" engine.io-parser "~2.2.0" has-cors "1.1.0" indexof "0.0.1" - parseqs "0.0.5" - parseuri "0.0.5" - ws "~6.1.0" + parseqs "0.0.6" + parseuri "0.0.6" + ws "~7.4.2" xmlhttprequest-ssl "~1.5.4" yeast "0.1.2" @@ -1857,17 +1845,17 @@ engine.io-parser@~2.2.0: blob "0.0.5" has-binary2 "~1.0.2" -engine.io@~3.4.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-3.4.0.tgz#3a962cc4535928c252759a00f98519cb46c53ff3" - integrity sha512-XCyYVWzcHnK5cMz7G4VTu2W7zJS7SM1QkcelghyIk/FmobWBtXE7fwhBusEKvCSqc3bMh8fNFMlUkCKTFRxH2w== +engine.io@~3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-3.5.0.tgz#9d6b985c8a39b1fe87cd91eb014de0552259821b" + integrity sha512-21HlvPUKaitDGE4GXNtQ7PLP0Sz4aWLddMPw2VTyFz1FVZqu/kZsJUO8WNpKuE/OCL7nkfRaOui2ZCJloGznGA== dependencies: accepts "~1.3.4" base64id "2.0.0" - cookie "0.3.1" + cookie "~0.4.1" debug "~4.1.0" engine.io-parser "~2.2.0" - ws "^7.1.2" + ws "~7.4.2" entities@^1.1.1: version "1.1.2" @@ -4184,11 +4172,6 @@ object-assign@^4.0.1, object-assign@^4.1.0: resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= -object-component@0.0.3: - version "0.0.3" - resolved "https://registry.yarnpkg.com/object-component/-/object-component-0.0.3.tgz#f0c69aa50efc95b866c186f400a33769cb2f1291" - integrity sha1-8MaapQ78lbhmwYb0AKM3acsvEpE= - object-copy@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c" @@ -4473,19 +4456,15 @@ parse-passwd@^1.0.0: resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6" integrity sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY= -parseqs@0.0.5: - version "0.0.5" - resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d" - integrity sha1-1SCKNzjkZ2bikbouoXNoSSGouJ0= - dependencies: - better-assert "~1.0.0" +parseqs@0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.6.tgz#8e4bb5a19d1cdc844a08ac974d34e273afa670d5" + integrity sha512-jeAGzMDbfSHHA091hr0r31eYfTig+29g3GKKE/PPbEQ65X0lmMwlEoqmhzu0iztID5uJpZsFlUPDP8ThPL7M8w== -parseuri@0.0.5: - version "0.0.5" - resolved "https://registry.yarnpkg.com/parseuri/-/parseuri-0.0.5.tgz#80204a50d4dbb779bfdc6ebe2778d90e4bce320a" - integrity sha1-gCBKUNTbt3m/3G6+J3jZDkvOMgo= - dependencies: - better-assert "~1.0.0" +parseuri@0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/parseuri/-/parseuri-0.0.6.tgz#e1496e829e3ac2ff47f39a4dd044b32823c4a25a" + integrity sha512-AUjen8sAkGgao7UyCX6Ahv0gIK2fABKmYjvP4xmy5JaKvcbTRueIqIPHLAfq30xJddqSE033IOMUSOMCcK3Sow== parseurl@~1.3.3: version "1.3.3" @@ -6231,23 +6210,20 @@ socket.io-adapter@~1.1.0: resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-1.1.1.tgz#2a805e8a14d6372124dd9159ad4502f8cb07f06b" integrity sha1-KoBeihTWNyEk3ZFZrUUC+MsH8Gs= -socket.io-client@2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.3.0.tgz#14d5ba2e00b9bcd145ae443ab96b3f86cbcc1bb4" - integrity sha512-cEQQf24gET3rfhxZ2jJ5xzAOo/xhZwK+mOqtGRg5IowZsMgwvHwnf/mCRapAAkadhM26y+iydgwsXGObBB5ZdA== +socket.io-client@2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.4.0.tgz#aafb5d594a3c55a34355562fc8aea22ed9119a35" + integrity sha512-M6xhnKQHuuZd4Ba9vltCLT9oa+YvTsP8j9NcEiLElfIg8KeYPyhWOes6x4t+LTAC8enQbE/995AdTem2uNyKKQ== dependencies: backo2 "1.0.2" - base64-arraybuffer "0.1.5" component-bind "1.0.0" - component-emitter "1.2.1" - debug "~4.1.0" - engine.io-client "~3.4.0" + component-emitter "~1.3.0" + debug "~3.1.0" + engine.io-client "~3.5.0" has-binary2 "~1.0.2" - has-cors "1.1.0" indexof "0.0.1" - object-component "0.0.3" - parseqs "0.0.5" - parseuri "0.0.5" + parseqs "0.0.6" + parseuri "0.0.6" socket.io-parser "~3.3.0" to-array "0.1.4" @@ -6269,16 +6245,16 @@ socket.io-parser@~3.4.0: debug "~4.1.0" isarray "2.0.1" -socket.io@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.3.0.tgz#cd762ed6a4faeca59bc1f3e243c0969311eb73fb" - integrity sha512-2A892lrj0GcgR/9Qk81EaY2gYhCBxurV0PfmmESO6p27QPrUK1J3zdns+5QPqvUYK2q657nSj0guoIil9+7eFg== +socket.io@^2.4.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.4.1.tgz#95ad861c9a52369d7f1a68acf0d4a1b16da451d2" + integrity sha512-Si18v0mMXGAqLqCVpTxBa8MGqriHGQh8ccEOhmsmNS3thNCGBwO8WGrwMibANsWtQQ5NStdZwHqZR3naJVFc3w== dependencies: debug "~4.1.0" - engine.io "~3.4.0" + engine.io "~3.5.0" has-binary2 "~1.0.2" socket.io-adapter "~1.1.0" - socket.io-client "2.3.0" + socket.io-client "2.4.0" socket.io-parser "~3.4.0" socks-proxy-agent@^4.0.1: @@ -7267,17 +7243,10 @@ write-file-atomic@^3.0.0: signal-exit "^3.0.2" typedarray-to-buffer "^3.1.5" -ws@^7.1.2: - version "7.2.1" - resolved "https://registry.yarnpkg.com/ws/-/ws-7.2.1.tgz#03ed52423cd744084b2cf42ed197c8b65a936b8e" - integrity sha512-sucePNSafamSKoOqoNfBd8V0StlkzJKL2ZAhGQinCfNQ+oacw+Pk7lcdAElecBF2VkLNZRiIb5Oi1Q5lVUVt2A== - -ws@~6.1.0: - version "6.1.4" - resolved "https://registry.yarnpkg.com/ws/-/ws-6.1.4.tgz#5b5c8800afab925e94ccb29d153c8d02c1776ef9" - integrity sha512-eqZfL+NE/YQc1/ZynhojeV8q+H050oR8AZ2uIev7RU10svA9ZnJUddHcOUZTJLinZ9yEfdA2kSATS2qZK5fhJA== - dependencies: - async-limiter "~1.0.0" +ws@~7.4.2: + version "7.4.2" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.2.tgz#782100048e54eb36fe9843363ab1c68672b261dd" + integrity sha512-T4tewALS3+qsrpGI/8dqNMLIVdq/g/85U98HPMa6F0m6xTbvhXU6RCQLqPH3+SlomNV/LdY6RXEbBpMH6EOJnA== xdg-basedir@^4.0.0: version "4.0.0"