diff --git a/.eslintrc b/.eslintrc index 7e469f7672..e79571f556 100644 --- a/.eslintrc +++ b/.eslintrc @@ -78,6 +78,7 @@ "has_common": true, "has_words": true, "validate_email": true, + "validate_phone": true, "get_number_format": true, "format_number": true, "format_currency": true, diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000000..2ff8752871 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,17 @@ +# Each line is a file pattern followed by one or more owners. + +# These owners will be the default owners for everything in +# the repo. Unless a later match takes precedence, + +* @surajshetty3416, @netchampfaris +website/ @scmmishra +templates/ @scmmishra +www/ @scmmishra +integrations/ @Mangesh-Khairnar +patches/ @surajshetty3416 @sahil28297 +dashboard/ @prssanna +email/ @Thunderbottom +event_streaming/ @ruchamahabal +data_import* @netchampfaris +core/ @surajshetty3416 +requirements.txt @gavindsouza \ No newline at end of file diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 08e082c234..f970f51419 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -15,7 +15,7 @@ import frappe import frappe.website.render from frappe import _ from frappe.utils import now, cint -from frappe.model import no_value_fields, default_fields, data_fieldtypes, table_fields +from frappe.model import no_value_fields, default_fields, data_fieldtypes, table_fields, data_field_options from frappe.model.document import Document from frappe.custom.doctype.property_setter.property_setter import make_property_setter from frappe.custom.doctype.custom_field.custom_field import create_custom_field @@ -99,7 +99,6 @@ class DocType(Document): if self.default_print_format and not self.custom: frappe.throw(_('Standard DocType cannot have default print format, use Customize Form')) - def set_default_in_list_view(self): '''Set default in-list-view for first 4 mandatory fields''' if not [d.fieldname for d in self.fields if d.in_list_view]: @@ -110,14 +109,12 @@ class DocType(Document): cnt += 1 if cnt == 4: break - def set_default_translatable(self): '''Ensure that non-translatable never will be translatable''' for d in self.fields: if d.translatable and not supports_translation(d.fieldtype): d.translatable = 0 - def check_developer_mode(self): """Throw exception if not developer mode or via patch""" if frappe.flags.in_patch or frappe.flags.in_test: @@ -126,7 +123,6 @@ class DocType(Document): if not frappe.conf.get("developer_mode") and not self.custom: frappe.throw(_("Not in Developer Mode! Set in site_config.json or make 'Custom' DocType."), CannotCreateStandardDoctypeError) - def setup_fields_to_fetch(self): '''Setup query to update values for newly set fetch values''' try: @@ -171,21 +167,18 @@ class DocType(Document): ) ) - def update_fields_to_fetch(self): '''Update fetch values based on queries setup''' if self.flags.update_fields_to_fetch_queries and not self.issingle: for query in self.flags.update_fields_to_fetch_queries: frappe.db.sql(query) - def validate_document_type(self): if self.document_type=="Transaction": self.document_type = "Document" if self.document_type=="Master": self.document_type = "Setup" - def validate_website(self): """Ensure that website generator has field 'route'""" if self.has_web_view: @@ -196,7 +189,6 @@ class DocType(Document): # clear website cache frappe.website.render.clear_cache() - def change_modified_of_parent(self): """Change the timestamp of parent DocType if the current one is a child to clear caches.""" if frappe.flags.in_import: @@ -206,7 +198,6 @@ class DocType(Document): for p in parent_list: frappe.db.sql('UPDATE `tabDocType` SET modified=%s WHERE `name`=%s', (now(), p.parent)) - def scrub_field_names(self): """Sluggify fieldnames if not set from Label.""" restricted = ('name','parent','creation','modified','modified_by', @@ -236,7 +227,6 @@ class DocType(Document): # unique is automatically an index if d.unique: d.search_index = 0 - def validate_series(self, autoname=None, name=None): """Validate if `autoname` property is correctly set.""" if not autoname: autoname = self.autoname @@ -273,7 +263,6 @@ class DocType(Document): if used_in: frappe.throw(_("Series {0} already used in {1}").format(prefix, used_in[0][0])) - def on_update(self): """Update database schema, make controller templates if `custom` is not set and clear cache.""" self.delete_duplicate_custom_fields() @@ -327,7 +316,6 @@ class DocType(Document): dt = {0} and fieldname in ({1}) '''.format('%s', ', '.join(['%s'] * len(fields))), tuple([self.name] + fields), as_dict=True) - def sync_global_search(self): '''If global search settings are changed, rebuild search properties for this table''' global_search_fields_before_update = [d.fieldname for d in @@ -345,7 +333,6 @@ class DocType(Document): frappe.enqueue('frappe.utils.global_search.rebuild_for_doctype', now=now, doctype=self.name) - def set_base_class_for_controller(self): '''Updates the controller class to subclass from `WebsiteGenertor`, if it is a subclass of `Document`''' @@ -365,14 +352,12 @@ class DocType(Document): with open(controller_path, 'w') as f: f.write(code) - def run_module_method(self, method): from frappe.modules import load_doctype_module module = load_doctype_module(self.name, self.module) if hasattr(module, method): getattr(module, method)() - def before_rename(self, old, new, merge=False): """Throw exception if merge. DocTypes cannot be merged.""" if not self.custom and frappe.session.user != "Administrator": @@ -388,7 +373,6 @@ class DocType(Document): 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.""" @@ -399,7 +383,6 @@ class DocType(Document): else: frappe.db.sql("rename table `tab%s` to `tab%s`" % (old, new)) - def rename_files_and_folders(self, old, new): # move files new_path = get_doc_path(self.module, 'doctype', new) @@ -416,7 +399,6 @@ class DocType(Document): self.rename_inside_controller(new, old, new_path) frappe.msgprint(_('Renamed files and replaced code in controllers, please check!')) - def rename_inside_controller(self, new, old, new_path): for fname in ('{}.js', '{}.py', '{}_list.js', '{}_calendar.js', 'test_{}.py', 'test_{}.js'): fname = os.path.join(new_path, fname.format(frappe.scrub(new))) @@ -442,7 +424,6 @@ class DocType(Document): if not (self.issingle and self.istable): self.preserve_naming_series_options_in_property_setter() - def preserve_naming_series_options_in_property_setter(self): """Preserve naming_series as property setter if it does not exist""" naming_series = self.get("fields", {"fieldname": "naming_series"}) @@ -462,7 +443,6 @@ class DocType(Document): if naming_series[0].default: make_property_setter(self.name, "naming_series", "default", naming_series[0].default, "Text", validate_fields_for_doctype=False) - def before_export(self, docdict): # remove null and empty fields def remove_null_fields(o): @@ -507,7 +487,6 @@ class DocType(Document): except ValueError: pass - @staticmethod def prepare_for_import(docdict): # set order of fields from field_order @@ -530,19 +509,16 @@ class DocType(Document): if "field_order" in docdict: del docdict["field_order"] - def export_doc(self): """Export to standard folder `[module]/doctype/[name]/[name].json`.""" from frappe.modules.export_file import export_to_files export_to_files(record_list=[['DocType', self.name]], create_init=True) - def import_doc(self): """Import from standard folder `[module]/doctype/[name]/[name].json`.""" from frappe.modules.import_module import import_from_files import_from_files(record_list=[[self.module, 'doctype', self.name]]) - def make_controller_template(self): """Make boilerplate controller template.""" make_boilerplate("controller._py", self) @@ -559,7 +535,6 @@ class DocType(Document): make_boilerplate('templates/controller.html', self.as_dict()) make_boilerplate('templates/controller_row.html', self.as_dict()) - def make_amendable(self): """If is_submittable is set, add amended_from docfields.""" if self.is_submittable: @@ -575,7 +550,6 @@ class DocType(Document): "no_copy": 1 }) - def make_repeatable(self): """If allow_auto_repeat is set, add auto_repeat custom field.""" if self.allow_auto_repeat: @@ -644,14 +618,12 @@ class DocType(Document): }) self.nsm_parent_field = parent_field_name - def get_max_idx(self): """Returns the highest `idx`""" max_idx = frappe.db.sql("""select max(idx) from `tabDocField` where parent = %s""", self.name) return max_idx and max_idx[0][0] or 0 - def validate_name(self, name=None): if not name: name = self.name @@ -671,7 +643,6 @@ def validate_fields_for_doctype(doctype): doc.delete_duplicate_custom_fields() validate_fields(frappe.get_meta(doctype, cached=False)) - # this is separate because it is also called via custom field def validate_fields(meta): """Validate doctype fields. Checks @@ -695,29 +666,24 @@ def validate_fields(meta): def check_illegal_characters(fieldname): validate_column_name(fieldname) - def check_invalid_fieldnames(docname, fieldname): invalid_fields = ('doctype',) if fieldname in invalid_fields: frappe.throw(_("{0}: Fieldname cannot be one of {1}") .format(docname, ", ".join([frappe.bold(d) for d in invalid_fields]))) - def check_unique_fieldname(docname, fieldname): duplicates = list(filter(None, map(lambda df: df.fieldname==fieldname and str(df.idx) or None, fields))) if len(duplicates) > 1: frappe.throw(_("{0}: Fieldname {1} appears multiple times in rows {2}").format(docname, fieldname, ", ".join(duplicates)), UniqueFieldnameError) - def check_fieldname_length(fieldname): validate_column_length(fieldname) - def check_illegal_mandatory(docname, d): if (d.fieldtype in no_value_fields) and d.fieldtype not in table_fields and d.reqd: frappe.throw(_("{0}: Field {1} of type {2} cannot be mandatory").format(docname, d.label, d.fieldtype), IllegalMandatoryError) - def check_link_table_options(docname, d): if frappe.flags.in_patch: return if d.fieldtype in ("Link",) + table_fields: @@ -736,28 +702,23 @@ def validate_fields(meta): # fix case d.options = options - def check_hidden_and_mandatory(docname, d): if d.hidden and d.reqd and not d.default: frappe.throw(_("{0}: Field {1} in row {2} cannot be hidden and mandatory without default").format(docname, d.label, d.idx), HiddenAndMandatoryWithoutDefaultError) - def check_width(d): if d.fieldtype == "Currency" and cint(d.width) < 100: frappe.throw(_("Max width for type Currency is 100px in row {0}").format(d.idx)) - def check_in_list_view(d): if d.in_list_view and (d.fieldtype in not_allowed_in_list_view): frappe.throw(_("'In List View' not allowed for type {0} in row {1}").format(d.fieldtype, d.idx)) - def check_in_global_search(d): if d.in_global_search and d.fieldtype in no_value_fields: frappe.throw(_("'In Global Search' not allowed for type {0} in row {1}") .format(d.fieldtype, d.idx)) - def check_dynamic_link_options(d): if d.fieldtype=="Dynamic Link": doctype_pointer = list(filter(lambda df: df.fieldname==d.options, fields)) @@ -765,7 +726,6 @@ def validate_fields(meta): or (doctype_pointer[0].fieldtype=="Link" and doctype_pointer[0].options!="DocType"): frappe.throw(_("Options 'Dynamic Link' type of field must point to another Link Field with options as 'DocType'")) - def check_illegal_default(d): if d.fieldtype == "Check" and not d.default: d.default = '0' @@ -774,12 +734,10 @@ def validate_fields(meta): if d.fieldtype == "Select" and d.default and (d.default not in d.options.split("\n")): frappe.throw(_("Default for {0} must be an option").format(d.fieldname)) - def check_precision(d): if d.fieldtype in ("Currency", "Float", "Percent") and d.precision is not None and not (1 <= cint(d.precision) <= 6): frappe.throw(_("Precision should be between 1 and 6")) - def check_unique_and_text(docname, d): if meta.issingle: d.unique = 0 @@ -801,7 +759,6 @@ def validate_fields(meta): if d.search_index and d.fieldtype in ("Text", "Long Text", "Small Text", "Code", "Text Editor"): frappe.throw(_("{0}:Fieldtype {1} for {2} cannot be indexed").format(docname, d.fieldtype, d.label), CannotIndexedError) - def check_fold(fields): fold_exists = False for i, f in enumerate(fields): @@ -816,7 +773,6 @@ def validate_fields(meta): else: frappe.throw(_("Fold can not be at the end of the form")) - def check_search_fields(meta, fields): """Throw exception if `search_fields` don't contain valid fields.""" if not meta.search_fields: @@ -833,7 +789,6 @@ def validate_fields(meta): (fieldname not in fieldname_list): frappe.throw(_("Search field {0} is not valid").format(fieldname)) - def check_title_field(meta): """Throw exception if `title_field` isn't a valid fieldname.""" if not meta.get("title_field"): @@ -860,7 +815,6 @@ def validate_fields(meta): _validate_title_field_pattern(df.options) _validate_title_field_pattern(df.default) - def check_image_field(meta): '''check image_field exists and is of type "Attach Image"''' if not meta.image_field: @@ -872,7 +826,6 @@ def validate_fields(meta): if df[0].fieldtype != 'Attach Image': frappe.throw(_("Image field must be of type Attach Image"), InvalidFieldNameError) - def check_is_published_field(meta): if not meta.is_published_field: return @@ -880,7 +833,6 @@ def validate_fields(meta): if meta.is_published_field not in fieldname_list: frappe.throw(_("Is Published Field must be a valid fieldname"), InvalidFieldNameError) - def check_timeline_field(meta): if not meta.timeline_field: return @@ -892,7 +844,6 @@ def validate_fields(meta): if df.fieldtype not in ("Link", "Dynamic Link"): frappe.throw(_("Timeline field must be a Link or Dynamic Link"), InvalidFieldNameError) - def check_sort_field(meta): '''Validate that sort_field(s) is a valid field''' if meta.sort_field: @@ -905,7 +856,6 @@ def validate_fields(meta): frappe.throw(_("Sort field {0} must be a valid fieldname").format(fieldname), InvalidFieldNameError) - def check_illegal_depends_on_conditions(docfield): ''' assignment operation should not be allowed in the depends on condition.''' depends_on_fields = ["depends_on", "collapsible_depends_on", "mandatory_depends_on", "read_only_depends_on"] @@ -915,7 +865,6 @@ def validate_fields(meta): re.match("""[\w\.:_]+\s*={1}\s*[\w\.@'"]+""", depends_on): frappe.throw(_("Invalid {0} condition").format(frappe.unscrub(field)), frappe.ValidationError) - def check_table_multiselect_option(docfield): '''check if the doctype provided in Option has atleast 1 Link field''' if not docfield.fieldtype == 'Table MultiSelect': return @@ -928,7 +877,6 @@ def validate_fields(meta): frappe.throw(_('DocType {0} provided for the field {1} must have atleast one Link field') .format(doctype, docfield.fieldname), frappe.ValidationError) - def scrub_options_in_select(field): """Strip options for whitespaces""" @@ -940,11 +888,20 @@ def validate_fields(meta): options_list.append(_option) field.options = '\n'.join(options_list) - def scrub_fetch_from(field): if hasattr(field, 'fetch_from') and getattr(field, 'fetch_from'): field.fetch_from = field.fetch_from.strip('\n').strip() + def validate_data_field_type(docfield): + if docfield.fieldtype == "Data": + if docfield.options and (docfield.options not in data_field_options): + df_str = frappe.bold(_(docfield.label)) + text_str = _("{0} is an invalid Data field.").format(df_str) + "
" * 2 + _("Only Options allowed for Data field are:") + "
" + df_options_str = "" + + frappe.msgprint(text_str + df_options_str, title="Invalid Data Field", raise_exception=True) + + fields = meta.get("fields") fieldname_list = [d.fieldname for d in fields] @@ -975,6 +932,7 @@ def validate_fields(meta): check_table_multiselect_option(d) scrub_options_in_select(d) scrub_fetch_from(d) + validate_data_field_type(d) check_fold(fields) check_search_fields(meta, fields) @@ -984,7 +942,6 @@ def validate_fields(meta): check_sort_field(meta) check_image_field(meta) - def validate_permissions_for_doctype(doctype, for_remove=False): """Validates if permissions are set correctly.""" doctype = frappe.get_doc("DocType", doctype) @@ -996,7 +953,6 @@ def validate_permissions_for_doctype(doctype, for_remove=False): clear_permissions_cache(doctype.name) - def clear_permissions_cache(doctype): frappe.clear_cache(doctype=doctype) delete_notification_count_for(doctype) @@ -1011,7 +967,6 @@ def clear_permissions_cache(doctype): """, doctype): frappe.clear_cache(user=user) - def validate_permissions(doctype, for_remove=False): permissions = doctype.get("permissions") if not permissions: @@ -1105,7 +1060,6 @@ def validate_permissions(doctype, for_remove=False): check_level_zero_is_set(d) remove_rights_for_single(d) - def make_module_and_roles(doc, perm_fieldname="permissions"): """Make `Module Def` and `Role` records if already not made. Called while installing.""" try: @@ -1136,7 +1090,6 @@ def make_module_and_roles(doc, perm_fieldname="permissions"): else: raise - def check_if_fieldname_conflicts_with_methods(doctype, fieldname): doc = frappe.get_doc({"doctype": doctype}) method_list = [method for method in dir(doc) if isinstance(method, str) and callable(getattr(doc, method))] @@ -1144,7 +1097,6 @@ def check_if_fieldname_conflicts_with_methods(doctype, fieldname): if fieldname in method_list: frappe.throw(_("Fieldname {0} conflicting with meta object").format(fieldname)) - def clear_linked_doctype_cache(): frappe.cache().delete_value('linked_doctypes_without_ignore_user_permissions_enabled') diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py index 969a71ab7d..fe9f88b9b3 100644 --- a/frappe/core/doctype/doctype/test_doctype.py +++ b/frappe/core/doctype/doctype/test_doctype.py @@ -113,6 +113,32 @@ class TestDocType(unittest.TestCase): if condition: self.assertFalse(re.match(pattern, condition)) + def test_data_field_options(self): + doctype_name = "Test Data Fields" + valid_data_field_options = frappe.model.data_field_options + ("",) + invalid_data_field_options = ("Invalid Option 1", frappe.utils.random_string(5)) + + for field_option in (valid_data_field_options + invalid_data_field_options): + test_doctype = frappe.get_doc({ + "doctype": "DocType", + "name": doctype_name, + "module": "Core", + "custom": 1, + "fields": [{ + "fieldname": "{0}_field".format(field_option), + "fieldtype": "Data", + "options": field_option + }] + }) + + if field_option in invalid_data_field_options: + # assert that only data options in frappe.model.data_field_options are valid + self.assertRaises(frappe.ValidationError, test_doctype.insert) + else: + test_doctype.insert() + self.assertEqual(test_doctype.name, doctype_name) + test_doctype.delete() + def test_sync_field_order(self): from frappe.modules.import_file import get_file_path import os @@ -349,4 +375,4 @@ class TestDocType(unittest.TestCase): # delete doctype link_doc.delete() doc.delete() - frappe.db.commit() \ No newline at end of file + frappe.db.commit() diff --git a/frappe/core/doctype/report/report.py b/frappe/core/doctype/report/report.py index 2d49915f59..967b28b8b2 100644 --- a/frappe/core/doctype/report/report.py +++ b/frappe/core/doctype/report/report.py @@ -6,7 +6,7 @@ import frappe import json, datetime from frappe import _, scrub import frappe.desk.query_report -from frappe.utils import cint +from frappe.utils import cint, cstr from frappe.model.document import Document from frappe.modules.export_file import export_to_files from frappe.modules import make_boilerplate @@ -92,6 +92,18 @@ class Report(Document): make_boilerplate("controller.py", self, {"name": self.name}) make_boilerplate("controller.js", self, {"name": self.name}) + def execute_query_report(self, filters): + if not self.query: + frappe.throw(_("Must specify a Query to run"), title=_('Report Document Error')) + + if not self.query.lower().startswith("select"): + frappe.throw(_("Query must be a SELECT"), title=_('Report Document Error')) + + result = [list(t) for t in frappe.db.sql(self.query, filters)] + columns = [cstr(c[0]) for c in frappe.db.get_description()] + + return [columns, result] + def execute_script_report(self, filters): # save the timestamp to automatically set to prepared threshold = 30 diff --git a/frappe/core/doctype/user/user.js b/frappe/core/doctype/user/user.js index c4873ee40e..b17548d994 100644 --- a/frappe/core/doctype/user/user.js +++ b/frappe/core/doctype/user/user.js @@ -97,6 +97,48 @@ frappe.ui.form.on('User', { }); }, __("Password")); + frappe.db.get_single_value("LDAP Settings", "enabled").then((value) => { + if (value === 1 && frm.doc.name != "Administrator") { + frm.add_custom_button(__("Reset LDAP Password"), function() { + const d = new frappe.ui.Dialog({ + title: __("Reset LDAP Password"), + fields: [ + { + label: __("New Password"), + fieldtype: "Password", + fieldname: "new_password", + reqd: 1 + }, + { + label: __("Confirm New Password"), + fieldtype: "Password", + fieldname: "confirm_password", + reqd: 1 + }, + { + label: __("Logout All Sessions"), + fieldtype: "Check", + fieldname: "logout_sessions" + } + ], + primary_action: (values) => { + d.hide(); + if (values.new_password !== values.confirm_password) { + frappe.throw(__("Passwords do not match!")); + } + frappe.call( + "frappe.integrations.doctype.ldap_settings.ldap_settings.reset_password", { + user: frm.doc.email, + password: values.new_password, + logout: values.logout_sessions + }); + } + }); + d.show(); + }, __("Password")); + } + }); + frm.add_custom_button(__("Reset OTP Secret"), function() { frappe.call({ method: "frappe.core.doctype.user.user.reset_otp_secret", diff --git a/frappe/core/page/dashboard/dashboard.js b/frappe/core/page/dashboard/dashboard.js index 511aac7010..88bfba9e84 100644 --- a/frappe/core/page/dashboard/dashboard.js +++ b/frappe/core/page/dashboard/dashboard.js @@ -59,7 +59,7 @@ class Dashboard { } show_dashboard(current_dashboard_name) { - if(this.dashboard_name !== current_dashboard_name) { + if (this.dashboard_name !== current_dashboard_name) { this.dashboard_name = current_dashboard_name; let title = this.dashboard_name; if (!this.dashboard_name.toLowerCase().includes(__('dashboard'))) { @@ -76,30 +76,42 @@ class Dashboard { } refresh() { - this.get_dashboard_doc().then((doc) => { - this.dashboard_doc = doc; - this.charts = this.dashboard_doc.charts - .map(chart => { - return { - chart_name: chart.chart, - label: chart.chart, - ...chart - } - }); + this.get_permitted_dashboard_charts().then(charts => { + if (!charts.length) { + frappe.msgprint(__('No Permitted Charts on this Dashboard'), __('No Permitted Charts')) + } - this.chart_group = new frappe.widget.WidgetGroup({ - title: null, - container: this.container, - type: "chart", - columns: 2, - allow_sorting: false, - widgets: this.charts, - }); + frappe.dashboard_utils.get_dashboard_settings().then((settings) => { + let chart_config = settings.chart_config? JSON.parse(settings.chart_config): {}; + this.charts = + charts.map(chart => { + return { + chart_name: chart.chart, + label: chart.chart, + chart_settings: chart_config[chart.chart] || {}, + ...chart + } + }); + this.chart_group = new frappe.widget.WidgetGroup({ + title: null, + container: this.container, + type: "chart", + columns: 2, + allow_sorting: false, + widgets: this.charts, + }); + }) }); } - get_dashboard_doc() { - return frappe.model.with_doc('Dashboard', this.dashboard_name); + get_permitted_dashboard_charts() { + return frappe.xcall( + 'frappe.desk.doctype.dashboard.dashboard.get_permitted_charts', + { + dashboard_name: this.dashboard_name + }).then(charts => { + return charts; + }); } set_dropdown() { diff --git a/frappe/core/page/dashboard/dashboard.json b/frappe/core/page/dashboard/dashboard.json index 891dcb26f8..58fda5a34c 100644 --- a/frappe/core/page/dashboard/dashboard.json +++ b/frappe/core/page/dashboard/dashboard.json @@ -4,7 +4,7 @@ "docstatus": 0, "doctype": "Page", "idx": 0, - "modified": "2019-01-08 19:19:48.073410", + "modified": "2020-03-26 13:30:44.603948", "modified_by": "Administrator", "module": "Core", "name": "dashboard", diff --git a/frappe/core/utils.py b/frappe/core/utils.py index 55767ffe34..55cfbc34d7 100644 --- a/frappe/core/utils.py +++ b/frappe/core/utils.py @@ -67,3 +67,19 @@ def find_all(list_of_dict, match_function): if match_function(entry): found.append(entry) return found + +def ljust_list(_list, length, fill_word=None): + """ + Similar to ljust but for list. + + Usage: + $ ljust_list([1, 2, 3], 5) + > [1, 2, 3, None, None] + """ + # make a copy to avoid mutation of passed list + _list = list(_list) + fill_length = length - len(_list) + if fill_length > 0: + _list.extend([fill_word] * fill_length) + + return _list diff --git a/frappe/desk/desktop.py b/frappe/desk/desktop.py index ef84114745..1cb03355c6 100644 --- a/frappe/desk/desktop.py +++ b/frappe/desk/desktop.py @@ -146,8 +146,9 @@ class Workspace: charts = charts + self.extended_charts for chart in charts: - chart.label = chart.label if chart.label else chart.chart_name - all_charts.append(chart) + if frappe.has_permission('Dashboard Chart', doc=chart.chart_name): + chart.label = chart.label if chart.label else chart.chart_name + all_charts.append(chart) return all_charts diff --git a/frappe/desk/doctype/dashboard/dashboard.json b/frappe/desk/doctype/dashboard/dashboard.json index 239f35bea8..c177ee70ac 100644 --- a/frappe/desk/doctype/dashboard/dashboard.json +++ b/frappe/desk/doctype/dashboard/dashboard.json @@ -34,7 +34,7 @@ } ], "links": [], - "modified": "2020-01-26 20:00:10.069817", + "modified": "2020-03-25 21:09:37.080132", "modified_by": "Administrator", "module": "Desk", "name": "Dashboard", @@ -51,6 +51,27 @@ "role": "System Manager", "share": 1, "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Dashboard Manager", + "share": 1, + "write": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "All", + "share": 1 } ], "quick_entry": 1, diff --git a/frappe/desk/doctype/dashboard/dashboard.py b/frappe/desk/doctype/dashboard/dashboard.py index c8f22d29b9..5c344956bf 100644 --- a/frappe/desk/doctype/dashboard/dashboard.py +++ b/frappe/desk/doctype/dashboard/dashboard.py @@ -12,3 +12,12 @@ class Dashboard(Document): # make all other dashboards non-default frappe.db.sql('''update tabDashboard set is_default = 0 where name != %s''', self.name) + +@frappe.whitelist() +def get_permitted_charts(dashboard_name): + permitted_charts = [] + dashboard = frappe.get_doc('Dashboard', dashboard_name) + for chart in dashboard.charts: + if frappe.has_permission('Dashboard Chart', doc=chart.chart): + permitted_charts.append(chart) + return permitted_charts diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.json b/frappe/desk/doctype/dashboard_chart/dashboard_chart.json index 0a017a0de2..9652ae3945 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.json +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.json @@ -215,7 +215,7 @@ } ], "links": [], - "modified": "2020-03-13 19:19:37.162771", + "modified": "2020-03-31 16:00:01.987059", "modified_by": "Administrator", "module": "Desk", "name": "Dashboard Chart", @@ -232,6 +232,27 @@ "role": "System Manager", "share": 1, "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Dashboard Manager", + "share": 1, + "write": 1 + }, + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "All", + "share": 1 } ], "sort_field": "modified", diff --git a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py index f01c976b9c..b2a6f0a0ff 100644 --- a/frappe/desk/doctype/dashboard_chart/dashboard_chart.py +++ b/frappe/desk/doctype/dashboard_chart/dashboard_chart.py @@ -10,8 +10,51 @@ import json from frappe.core.page.dashboard.dashboard import cache_source, get_from_date_from_timespan from frappe.utils import nowdate, add_to_date, getdate, get_last_day, formatdate, get_datetime from frappe.model.naming import append_number_if_name_exists +from frappe.boot import get_allowed_reports from frappe.model.document import Document + +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_doctypes = tuple(frappe.permissions.get_doctypes_with_read()) + allowed_reports = tuple([key.encode('UTF8') for key in get_allowed_reports()]) + + return ''' + `tabDashboard Chart`.`document_type` in {allowed_doctypes} + or `tabDashboard Chart`.`report_name` in {allowed_reports} + '''.format( + allowed_doctypes=allowed_doctypes, + allowed_reports=allowed_reports + ) + + +def has_permission(doc, ptype, user): + roles = frappe.get_roles(user) + if "System Manager" in roles: + return True + + + if doc.chart_type == 'Report': + allowed_reports = tuple([key.encode('UTF8') for key in get_allowed_reports()]) + if doc.report_name in allowed_reports: + return True + else: + allowed_doctypes = tuple(frappe.permissions.get_doctypes_with_read()) + if doc.document_type in allowed_doctypes: + return True + + return False + @frappe.whitelist() @cache_source def get(chart_name = None, chart = None, no_cache = None, filters = None, from_date = None, diff --git a/frappe/desk/doctype/dashboard_settings/__init__.py b/frappe/desk/doctype/dashboard_settings/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/desk/doctype/dashboard_settings/dashboard_settings.js b/frappe/desk/doctype/dashboard_settings/dashboard_settings.js new file mode 100644 index 0000000000..8e7966366d --- /dev/null +++ b/frappe/desk/doctype/dashboard_settings/dashboard_settings.js @@ -0,0 +1,8 @@ +// Copyright (c) 2020, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Dashboard Settings', { + // refresh: function(frm) { + + // } +}); diff --git a/frappe/desk/doctype/dashboard_settings/dashboard_settings.json b/frappe/desk/doctype/dashboard_settings/dashboard_settings.json new file mode 100644 index 0000000000..e1eb75db47 --- /dev/null +++ b/frappe/desk/doctype/dashboard_settings/dashboard_settings.json @@ -0,0 +1,51 @@ +{ + "actions": [], + "autoname": "Prompt", + "creation": "2020-03-31 19:41:45.785014", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "user", + "chart_config" + ], + "fields": [ + { + "fieldname": "user", + "fieldtype": "Link", + "label": "User", + "options": "User", + "read_only": 1 + }, + { + "fieldname": "chart_config", + "fieldtype": "Code", + "label": "Chart Configuration", + "options": "JSON", + "read_only": 1 + } + ], + "in_create": 1, + "links": [], + "modified": "2020-04-01 00:07:26.489561", + "modified_by": "Administrator", + "module": "Desk", + "name": "Dashboard Settings", + "owner": "Administrator", + "permissions": [ + { + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "All", + "share": 1, + "write": 1 + } + ], + "read_only": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/desk/doctype/dashboard_settings/dashboard_settings.py b/frappe/desk/doctype/dashboard_settings/dashboard_settings.py new file mode 100644 index 0000000000..4697d897fc --- /dev/null +++ b/frappe/desk/doctype/dashboard_settings/dashboard_settings.py @@ -0,0 +1,43 @@ +# -*- 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 +import frappe +import json + +class DashboardSettings(Document): + pass + + +@frappe.whitelist() +def create_dashboard_settings(user): + if not frappe.db.exists("Dashboard Settings", user): + doc = frappe.new_doc('Dashboard Settings') + doc.name = user + doc.insert(ignore_permissions=True) + frappe.db.commit() + return doc + +def get_permission_query_conditions(user): + if not user: user = frappe.session.user + + return '''(`tabDashboard Settings`.name = '{user}')'''.format(user=user) + +@frappe.whitelist() +def save_chart_config(reset, config, chart_name): + reset = frappe.parse_json(reset) + doc = frappe.get_doc('Dashboard Settings', frappe.session.user) + chart_config = frappe.parse_json(doc.chart_config) or {} + + if reset: + chart_config[chart_name] = {} + else: + config = frappe.parse_json(config) + if not chart_name in chart_config: + chart_config[chart_name] = {} + chart_config[chart_name].update(config) + + frappe.db.set_value('Dashboard Settings', frappe.session.user, 'chart_config', json.dumps(chart_config)) \ No newline at end of file diff --git a/frappe/desk/query_report.py b/frappe/desk/query_report.py index d210af02fd..aaf859e7fd 100644 --- a/frappe/desk/query_report.py +++ b/frappe/desk/query_report.py @@ -8,7 +8,7 @@ import os, json from frappe import _ from frappe.modules import scrub, get_module_path -from frappe.utils import flt, cint, get_html_format, cstr, get_url_to_form +from frappe.utils import flt, cint, get_html_format, get_url_to_form from frappe.model.utils import render_include from frappe.translate import send_translations import frappe.desk.reportview @@ -16,6 +16,7 @@ from frappe.permissions import get_role_permissions from six import string_types, iteritems from datetime import timedelta from frappe.utils import gzip_decompress +from frappe.core.utils import ljust_list def get_report_doc(report_name): doc = frappe.get_doc("Report", report_name) @@ -42,44 +43,32 @@ def get_report_doc(report_name): return doc -def generate_report_result(report, filters=None, user=None): - status = None - if not user: - user = frappe.session.user - if not filters: - filters = [] +def generate_report_result(report, filters=None, user=None, custom_columns=None): + user = user or frappe.session.user + filters = filters or [] if filters and isinstance(filters, string_types): filters = json.loads(filters) - columns, result, message, chart, report_summary, skip_total_row = [], [], None, None, None, 0 + + res = [] + if report.report_type == "Query Report": - if not report.query: - status = "error" - frappe.msgprint(_("Must specify a Query to run"), raise_exception=True) - - if not report.query.lower().startswith("select"): - status = "error" - frappe.msgprint(_("Query must be a SELECT"), raise_exception=True) - - result = [list(t) for t in frappe.db.sql(report.query, filters)] - columns = [cstr(c[0]) for c in frappe.db.get_description()] + res = report.execute_query_report(filters) elif report.report_type == 'Script Report': res = report.execute_script_report(filters) - columns, result = res[0], res[1] - if len(res) > 2: - message = res[2] - if len(res) > 3: - chart = res[3] - if len(res) > 4: - report_summary = res[4] - if len(res) > 5: - skip_total_row = cint(res[5]) + columns, result, message, chart, report_summary, skip_total_row = \ + ljust_list(res, 6) if report.custom_columns: columns = json.loads(report.custom_columns) result = add_data_to_custom_columns(columns, result) + if custom_columns: + result = add_data_to_custom_columns(custom_columns, result) + + for custom_column in custom_columns: + columns.insert(custom_column['insert_after_index'] + 1, custom_column) if result: result = get_filtered_data(report.ref_doctype, columns, result, user) @@ -93,8 +82,8 @@ def generate_report_result(report, filters=None, user=None): "message": message, "chart": chart, "report_summary": report_summary, - "skip_total_row": skip_total_row, - "status": status, + "skip_total_row": skip_total_row or 0, + "status": None, "execution_time": frappe.cache().hget('report_execution_time', report.name) or 0 } @@ -161,7 +150,7 @@ def get_script(report_name): @frappe.whitelist() @frappe.read_only() -def run(report_name, filters=None, user=None, ignore_prepared_report=False): +def run(report_name, filters=None, user=None, ignore_prepared_report=False, custom_columns=None): report = get_report_doc(report_name) if not user: @@ -183,7 +172,7 @@ def run(report_name, filters=None, user=None, ignore_prepared_report=False): dn = "" result = get_prepared_report_result(report, filters, dn, user) else: - result = generate_report_result(report, filters, user) + result = generate_report_result(report, filters, user, custom_columns) result["add_total_row"] = report.add_total_row and not result.get('skip_total_row', False) @@ -294,6 +283,8 @@ def export_query(): if isinstance(data.get("file_format_type"), string_types): file_format_type = data["file_format_type"] + custom_columns = frappe.parse_json(data["custom_columns"]) + include_indentation = data["include_indentation"] if isinstance(data.get("visible_idx"), string_types): visible_idx = json.loads(data.get("visible_idx")) @@ -301,7 +292,7 @@ def export_query(): visible_idx = None if file_format_type == "Excel": - data = run(report_name, filters) + data = run(report_name, filters, custom_columns=custom_columns) data = frappe._dict(data) if not data.columns: frappe.respond_as_web_page(_("No data to export"), diff --git a/frappe/exceptions.py b/frappe/exceptions.py index 3d63f4b2b4..732fc39e9a 100644 --- a/frappe/exceptions.py +++ b/frappe/exceptions.py @@ -78,6 +78,7 @@ class TimestampMismatchError(ValidationError): pass class EmptyTableError(ValidationError): pass class LinkExistsError(ValidationError): pass class InvalidEmailAddressError(ValidationError): pass +class InvalidPhoneNumberError(ValidationError): pass class TemplateNotFoundError(ValidationError): pass class UniqueValidationError(ValidationError): pass class AppNotInstalledError(ValidationError): pass diff --git a/frappe/hooks.py b/frappe/hooks.py index c44c05fdf4..4f65303be9 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -86,7 +86,9 @@ permission_query_conditions = { "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions", "ToDo": "frappe.desk.doctype.todo.todo.get_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 Chart": "frappe.desk.doctype.dashboard_chart.dashboard_chart.get_permission_query_conditions", "Notification Settings": "frappe.desk.doctype.notification_settings.notification_settings.get_permission_query_conditions", "Note": "frappe.desk.doctype.note.note.get_permission_query_conditions", "Kanban Board": "frappe.desk.doctype.kanban_board.kanban_board.get_permission_query_conditions", @@ -101,6 +103,7 @@ has_permission = { "ToDo": "frappe.desk.doctype.todo.todo.has_permission", "User": "frappe.core.doctype.user.user.has_permission", "Note": "frappe.desk.doctype.note.note.has_permission", + "Dashboard Chart": "frappe.desk.doctype.dashboard_chart.dashboard_chart.has_permission", "Kanban Board": "frappe.desk.doctype.kanban_board.kanban_board.has_permission", "Contact": "frappe.contacts.address_and_contact.has_permission", "Address": "frappe.contacts.address_and_contact.has_permission", diff --git a/frappe/integrations/doctype/ldap_settings/ldap_settings.py b/frappe/integrations/doctype/ldap_settings/ldap_settings.py index c0f12df04a..558f7117c0 100644 --- a/frappe/integrations/doctype/ldap_settings/ldap_settings.py +++ b/frappe/integrations/doctype/ldap_settings/ldap_settings.py @@ -4,7 +4,7 @@ from __future__ import unicode_literals import frappe -from frappe import _ +from frappe import _, safe_encode from frappe.model.document import Document @@ -19,7 +19,7 @@ class LDAPSettings(Document): else: frappe.throw(_("LDAP Search String needs to end with a placeholder, eg sAMAccountName={0}")) - def connect_to_ldap(self, base_dn, password): + def connect_to_ldap(self, base_dn, password, read_only=True): try: import ldap3 import ssl @@ -44,7 +44,7 @@ class LDAPSettings(Document): user=base_dn, password=password, auto_bind=bind_type, - read_only=True, + read_only=read_only, raise_exceptions=True) return conn @@ -170,6 +170,36 @@ class LDAPSettings(Document): else: frappe.throw(_("Invalid username or password")) + def reset_password(self, user, password, logout_sessions=False): + from ldap3 import HASHED_SALTED_SHA, MODIFY_REPLACE + from ldap3.utils.hashed import hashed + + search_filter = "({0}={1})".format(self.ldap_email_field, user) + + conn = self.connect_to_ldap(self.base_dn, self.get_password(raise_exception=False), + read_only=False) + + if conn.search( + search_base=self.organizational_unit, + search_filter=search_filter, + attributes=self.get_ldap_attributes() + ): + if conn.entries and conn.entries[0]: + entry_dn = conn.entries[0].entry_dn + hashed_password = hashed(HASHED_SALTED_SHA, safe_encode(password)) + changes = {'userPassword': [(MODIFY_REPLACE, [hashed_password])]} + if conn.modify(entry_dn, changes=changes): + if logout_sessions: + from frappe.sessions import clear_sessions + clear_sessions(user=user, force=True) + frappe.msgprint(_("Password changed successfully.")) + else: + frappe.throw(_("Failed to change password.")) + else: + frappe.throw(_("No Entry for the User {0} found within LDAP!").format(user)) + else: + frappe.throw(_("No LDAP User found for email: {0}").format(user)) + def convert_ldap_entry_to_dict(self, user_entry): # support multiple email values @@ -211,3 +241,11 @@ def login(): # because of a GET request! frappe.db.commit() + + +@frappe.whitelist() +def reset_password(user, password, logout): + ldap = frappe.get_doc("LDAP Settings") + if not ldap.enabled: + frappe.throw(_("LDAP is not enabled.")) + ldap.reset_password(user, password, logout_sessions=int(logout)) diff --git a/frappe/model/__init__.py b/frappe/model/__init__.py index 1fe92d7a67..7af987f4bc 100644 --- a/frappe/model/__init__.py +++ b/frappe/model/__init__.py @@ -48,6 +48,7 @@ table_fields = ('Table', 'Table MultiSelect') core_doctypes_list = ('DocType', 'DocField', 'DocPerm', 'DocType Action', 'DocType Link', 'User', 'Role', 'Has Role', 'Page', 'Module Def', 'Print Format', 'Report', 'Customize Form', 'Customize Form Field', 'Property Setter', 'Custom Field', 'Custom Script') +data_field_options = ('Email', 'Phone') def copytables(srctype, src, srcfield, tartype, tar, tarfield, srcfields, tarfields=[]): if not tarfields: diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 569cea9d5f..4af502f844 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -544,6 +544,23 @@ class BaseDocument(object): frappe.throw(_('{0} {1} cannot be "{2}". It should be one of "{3}"').format(prefix, label, value, comma_options)) + def _validate_data_fields(self): + from frappe.core.doctype.user.user import STANDARD_USERS + + # data_field options defined in frappe.model.data_field_options + for data_field in self.meta.get_data_fields(): + data = self.get(data_field.fieldname) + data_field_options = data_field.get("options") + + if data_field_options == "Email": + if (self.owner in STANDARD_USERS) and (data in STANDARD_USERS): + return + for email_address in frappe.utils.split_emails(data): + frappe.utils.validate_email_address(email_address, throw=True) + + if data_field_options == "Phone": + frappe.utils.validate_phone_number(data, throw=True) + def _validate_constants(self): if frappe.flags.in_import or self.is_new() or self.flags.ignore_validate_constants: return diff --git a/frappe/model/document.py b/frappe/model/document.py index 66dd7e3c58..03b21ea667 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -468,6 +468,7 @@ class Document(BaseDocument): def _validate(self): self._validate_mandatory() + self._validate_data_fields() self._validate_selects() self._validate_length() self._extract_images_from_text_editor() @@ -477,6 +478,7 @@ class Document(BaseDocument): children = self.get_all_children() for d in children: + d._validate_data_fields() d._validate_selects() d._validate_length() d._extract_images_from_text_editor() diff --git a/frappe/model/meta.py b/frappe/model/meta.py index 84c96d0566..9c71f8c0b1 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -128,6 +128,9 @@ class Meta(Document): def get_link_fields(self): return self.get("fields", {"fieldtype": "Link", "options":["!=", "[Select]"]}) + def get_data_fields(self): + return self.get("fields", {"fieldtype": "Data"}) + def get_dynamic_link_fields(self): if not hasattr(self, '_dynamic_link_fields'): self._dynamic_link_fields = self.get("fields", {"fieldtype": "Dynamic Link"}) diff --git a/frappe/model/workflow.py b/frappe/model/workflow.py index 3f3711af9d..4384e7c8f5 100644 --- a/frappe/model/workflow.py +++ b/frappe/model/workflow.py @@ -194,7 +194,7 @@ def bulk_workflow_approval(docnames, doctype, action): from collections import defaultdict # dictionaries for logging - errored_transactions = defaultdict(list) + failed_transactions = defaultdict(list) successful_transactions = defaultdict(list) # WARN: message log is cleared @@ -215,7 +215,7 @@ def bulk_workflow_approval(docnames, doctype, action): if e.args: message += " : {0}".format(e.args[0]) message_dict = {"docname": docname, "message": message} - errored_transactions[docname].append(message_dict) + failed_transactions[docname].append(message_dict) frappe.db.rollback() frappe.log_error(frappe.get_traceback(), "Workflow {0} threw an error for {1} {2}".format(action, doctype, docname)) @@ -228,20 +228,20 @@ def bulk_workflow_approval(docnames, doctype, action): message_dict = {"docname": docname, "message": message.get("message")} if message.get("raise_exception", False): - errored_transactions[docname].append(message_dict) + failed_transactions[docname].append(message_dict) else: successful_transactions[docname].append(message_dict) else: successful_transactions[docname].append({"docname": docname, "message": None}) - if errored_transactions and successful_transactions: + if failed_transactions and successful_transactions: indicator = "orange" - elif errored_transactions: + elif failed_transactions: indicator = "red" else: indicator = "green" - print_workflow_log(errored_transactions, _("Errored Transactions"), doctype, indicator) + print_workflow_log(failed_transactions, _("Failed Transactions"), doctype, indicator) print_workflow_log(successful_transactions, _("Successful Transactions"), doctype, indicator) def print_workflow_log(messages, title, doctype, indicator): diff --git a/frappe/permissions.py b/frappe/permissions.py index a0d1677fac..0d766aec8d 100644 --- a/frappe/permissions.py +++ b/frappe/permissions.py @@ -307,7 +307,7 @@ def has_controller_permissions(doc, ptype, user=None): return None def get_doctypes_with_read(): - return list(set([p.parent for p in get_valid_perms()])) + return list(set([p.parent if type(p.parent) == str else p.parent.encode('UTF8') for p in get_valid_perms()])) def get_valid_perms(doctype=None, user=None): '''Get valid permissions for the current user from DocPerm and Custom DocPerm''' diff --git a/frappe/public/js/frappe/form/controls/base_control.js b/frappe/public/js/frappe/form/controls/base_control.js index 20f78ec248..90cf784dd4 100644 --- a/frappe/public/js/frappe/form/controls/base_control.js +++ b/frappe/public/js/frappe/form/controls/base_control.js @@ -152,6 +152,7 @@ frappe.ui.form.Control = Class.extend({ () => me.set_model_value(value), () => { me.set_mandatory && me.set_mandatory(value); + me.set_invalid && me.set_invalid(); if(me.df.change || me.df.onchange) { // onchange event specified in df diff --git a/frappe/public/js/frappe/form/controls/base_input.js b/frappe/public/js/frappe/form/controls/base_input.js index 8a8ac271c7..0dbaaeb63c 100644 --- a/frappe/public/js/frappe/form/controls/base_input.js +++ b/frappe/public/js/frappe/form/controls/base_input.js @@ -179,6 +179,9 @@ frappe.ui.form.ControlInput = frappe.ui.form.Control.extend({ set_mandatory: function(value) { this.$wrapper.toggleClass("has-error", (this.df.reqd && is_null(value)) ? true : false); }, + set_invalid: function () { + this.$wrapper.toggleClass("has-error", (this.df.invalid ? true : false)); + }, set_bold: function() { if(this.$input) { this.$input.toggleClass("bold", !!(this.df.bold || this.df.reqd)); diff --git a/frappe/public/js/frappe/form/controls/data.js b/frappe/public/js/frappe/form/controls/data.js index 6dc8c3d387..a7f0050d65 100644 --- a/frappe/public/js/frappe/form/controls/data.js +++ b/frappe/public/js/frappe/form/controls/data.js @@ -87,56 +87,29 @@ frappe.ui.form.ControlData = frappe.ui.form.ControlInput.extend({ return val==null ? "" : val; }, validate: function(v) { + if (!v) { + return ''; + } if(this.df.is_filter) { return v; } if(this.df.options == 'Phone') { - if(v+''=='') { - return ''; - } - var v1 = ''; - // phone may start with + and must only have numbers later, '-' and ' ' are stripped - v = v.replace(/ /g, '').replace(/-/g, '').replace(/\(/g, '').replace(/\)/g, ''); - - // allow initial +,0,00 - if(v && v.substr(0,1)=='+') { - v1 = '+'; v = v.substr(1); - } - if(v && v.substr(0,2)=='00') { - v1 += '00'; v = v.substr(2); - } - if(v && v.substr(0,1)=='0') { - v1 += '0'; v = v.substr(1); - } - v1 += cint(v) + ''; - return v1; + this.df.invalid = !validate_phone(v); + return v; } else if(this.df.options == 'Email') { - if(v+''=='') { - return ''; - } - var email_list = frappe.utils.split_emails(v); if (!email_list) { - // invalid email return ''; } else { - var invalid_email = false; + let email_invalid = false; email_list.forEach(function(email) { if (!validate_email(email)) { - frappe.msgprint(__("Invalid Email: {0}", [email])); - invalid_email = true; + email_invalid = true; } }); - - if (invalid_email) { - // at least 1 invalid email - return ''; - } else { - // all good - return v; - } + this.df.invalid = email_invalid; + return v; } - } else { return v; } diff --git a/frappe/public/js/frappe/form/footer/timeline.js b/frappe/public/js/frappe/form/footer/timeline.js index d27c65548d..beec168dfd 100644 --- a/frappe/public/js/frappe/form/footer/timeline.js +++ b/frappe/public/js/frappe/form/footer/timeline.js @@ -561,10 +561,9 @@ frappe.ui.form.Timeline = class Timeline { } let updater_reference_link = null; - - if (!$.isEmptyObject(data.updater_reference)) { + let updater_reference = data.updater_reference; + if (!$.isEmptyObject(updater_reference)) { let label = updater_reference.label || __('via {0}', [updater_reference.doctype]); - let updater_reference = data.updater_reference; updater_reference_link = frappe.utils.get_form_link( updater_reference.doctype, updater_reference.docname, diff --git a/frappe/public/js/frappe/utils/dashboard_utils.js b/frappe/public/js/frappe/utils/dashboard_utils.js index 7e64f5c143..66f3d5e21f 100644 --- a/frappe/public/js/frappe/utils/dashboard_utils.js +++ b/frappe/public/js/frappe/utils/dashboard_utils.js @@ -54,5 +54,26 @@ frappe.dashboard_utils = { } else { return Promise.resolve(); } + }, + + get_dashboard_settings() { + return frappe.model.with_doc('Dashboard Settings', frappe.session.user).then(settings => { + if (!settings) { + return this.create_dashboard_settings().then(settings => { + return settings; + }); + } else { + return settings; + } + }); + }, + + create_dashboard_settings() { + return frappe.xcall( + 'frappe.desk.doctype.dashboard_settings.dashboard_settings.create_dashboard_settings', + {user: frappe.session.user} + ).then(settings => { + return settings; + }); } }; \ No newline at end of file diff --git a/frappe/public/js/frappe/utils/datatype.js b/frappe/public/js/frappe/utils/datatype.js index 0f73526e04..16f87b872f 100644 --- a/frappe/public/js/frappe/utils/datatype.js +++ b/frappe/public/js/frappe/utils/datatype.js @@ -44,6 +44,10 @@ window.validate_email = function(txt) { return frappe.utils.validate_type(txt, "email"); }; +window.validate_phone = function(txt) { + return frappe.utils.validate_type(txt, "phone"); +}; + window.nth = function(number) { number = cint(number); var s = 'th'; diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js index 278c80897e..1af37c1f60 100644 --- a/frappe/public/js/frappe/utils/utils.js +++ b/frappe/public/js/frappe/utils/utils.js @@ -232,6 +232,9 @@ Object.assign(frappe.utils, { var regExp; switch ( type ) { + case "phone": + regExp = /^([0-9\ \+\_\-\,\.\*\#\(\)]){1,20}$/; + break; case "number": regExp = /^-?(?:\d+|\d{1,3}(?:,\d{3})+)?(?:\.\d+)?$/; break; diff --git a/frappe/public/js/frappe/views/desktop/desktop.js b/frappe/public/js/frappe/views/desktop/desktop.js index 54a25c3771..d0993e2e8c 100644 --- a/frappe/public/js/frappe/views/desktop/desktop.js +++ b/frappe/public/js/frappe/views/desktop/desktop.js @@ -165,11 +165,18 @@ class DesktopPage { this.allow_customization = this.data.allow_customization || false; - !this.sections["onboarding"] && - this.data.charts.items.length && - this.make_charts(); - this.data.shortcuts.items.length && this.make_shortcuts(); - this.data.cards.items.length && this.make_cards(); + let create_shortcuts_and_cards = () => { + this.data.shortcuts.items.length && this.make_shortcuts(); + this.data.cards.items.length && this.make_cards(); + }; + + if (!this.sections["onboarding"] && this.data.charts.items.length) { + this.make_charts().then(() => { + create_shortcuts_and_cards(); + }); + } else { + create_shortcuts_and_cards(); + } }); } @@ -224,13 +231,22 @@ class DesktopPage { } make_charts() { - this.sections["charts"] = new frappe.widget.WidgetGroup({ - title: this.data.charts.label || `${this.page_name} Dashboard`, - container: this.page, - type: "chart", - columns: 1, - allow_sorting: false, - widgets: this.data.charts.items + return frappe.dashboard_utils.get_dashboard_settings().then(settings => { + let chart_config = settings.chart_config? JSON.parse(settings.chart_config): {}; + if (this.data.charts.items) { + this.data.charts.items.map(chart => { + chart.chart_settings = chart_config[chart.chart_name] || {}; + }); + } + + this.sections["charts"] = new frappe.widget.WidgetGroup({ + title: this.data.charts.label || `${this.page_name} Dashboard`, + container: this.page, + type: "chart", + columns: 1, + allow_sorting: false, + widgets: this.data.charts.items + }); }); } diff --git a/frappe/public/js/frappe/views/reports/query_report.js b/frappe/public/js/frappe/views/reports/query_report.js index 1d7065e70d..2276bc07d6 100644 --- a/frappe/public/js/frappe/views/reports/query_report.js +++ b/frappe/public/js/frappe/views/reports/query_report.js @@ -616,6 +616,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { prepare_report_data(data) { this.raw_data = data; this.columns = this.prepare_columns(data.columns); + this.custom_columns = []; this.data = this.prepare_data(data.result); this.linked_doctypes = this.get_linked_doctypes(); this.tree_report = this.data.some(d => 'indent' in d); @@ -1110,6 +1111,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { const args = { cmd: 'frappe.desk.query_report.export_query', report_name: this.report_name, + custom_columns: this.custom_columns.length? this.custom_columns: [], file_format_type: file_format, filters: filters, visible_idx, @@ -1275,16 +1277,20 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { primary_action: (values) => { const custom_columns = []; let df = frappe.meta.get_docfield(values.doctype, values.field); + const insert_after_index = this.columns + .findIndex(column => column.label === values.insert_after); custom_columns.push({ fieldname: df.fieldname, fieldtype: df.fieldtype, label: df.label, + insert_after_index: insert_after_index, link_field: this.doctype_field_map[values.doctype], doctype: values.doctype, options: df.fieldtype === "Link" ? df.options : undefined, width: 100 }); + this.custom_columns = this.custom_columns.concat(custom_columns); frappe.call({ method: 'frappe.desk.query_report.get_data_for_custom_field', args: { @@ -1294,7 +1300,8 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { callback: (r) => { const custom_data = r.message; const link_field = this.doctype_field_map[values.doctype]; - this.add_custom_column(custom_columns, custom_data, link_field, values.field, values.insert_after); + + this.add_custom_column(custom_columns, custom_data, link_field, values.field, insert_after_index); d.hide(); } }); @@ -1369,11 +1376,9 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList { } } - add_custom_column(custom_column, custom_data, link_field, column_field, insert_after) { + add_custom_column(custom_column, custom_data, link_field, column_field, insert_after_index) { const column = this.prepare_columns(custom_column); - const insert_after_index = this.columns - .findIndex(column => column.label === insert_after); this.columns.splice(insert_after_index + 1, 0, column[0]); this.data.forEach(row => { diff --git a/frappe/public/js/frappe/widgets/chart_widget.js b/frappe/public/js/frappe/widgets/chart_widget.js index 3388890776..174f0ae666 100644 --- a/frappe/public/js/frappe/widgets/chart_widget.js +++ b/frappe/public/js/frappe/widgets/chart_widget.js @@ -62,6 +62,9 @@ export default class ChartWidget extends Widget { make_chart() { this.get_settings().then(() => { + if (!this.chart_settings) { + this.chart_settings = {}; + } this.setup_container(); this.prepare_chart_object(); this.action_area.empty(); @@ -89,7 +92,7 @@ export default class ChartWidget extends Widget { render_time_series_filters() { let filters = [ { - label: this.chart_doc.timespan, + label: this.chart_settings.timespan || this.chart_doc.timespan, options: [ "Select Date Range", "Last Year", @@ -114,15 +117,22 @@ export default class ChartWidget extends Widget { this.head.css('flex-direction', "row"); } + this.save_chart_config_for_user({ + 'timespan': this.selected_timespan, + 'from_date': null, + 'to_date': null + + }); this.fetch_and_update_chart(); } } }, { - label: this.chart_doc.time_interval, + label: this.chart_settings.time_interval || this.chart_doc.time_interval, options: ["Yearly", "Quarterly", "Monthly", "Weekly", "Daily"], action: selected_item => { this.selected_time_interval = selected_item; + this.save_chart_config_for_user({'time_interval': this.selected_time_interval}); this.fetch_and_update_chart(); } } @@ -138,10 +148,10 @@ export default class ChartWidget extends Widget { fetch_and_update_chart() { this.args = { - timespan: this.selected_timespan, - time_interval: this.selected_time_interval, - from_date: this.selected_from_date, - to_date: this.selected_to_date + timespan: this.selected_timespan || this.chart_settings.timespan, + time_interval: this.selected_time_interval || this.chart_settings.time_interval, + from_date: this.selected_from_date || this.chart_settings.from_date, + to_date: this.selected_to_date || this.chart_settings.to_date }; this.fetch(this.filters, true, this.args).then(data => { @@ -176,16 +186,19 @@ export default class ChartWidget extends Widget { fieldname: "from_date", placeholder: "Date Range", input_class: "input-xs", + default: [this.chart_settings.from_date, this.chart_settings.to_date], reqd: 1, change: () => { let selected_date_range = this.date_range_field.get_value(); this.selected_from_date = selected_date_range[0]; this.selected_to_date = selected_date_range[1]; - if ( - selected_date_range && - selected_date_range.length == 2 - ) { + if (selected_date_range && selected_date_range.length == 2) { + this.save_chart_config_for_user({ + 'timespan': this.selected_timespan, + 'from_date': this.selected_from_date, + 'to_date': this.selected_to_date, + }); this.fetch_and_update_chart(); } } @@ -235,7 +248,7 @@ export default class ChartWidget extends Widget { } }, { - label: __("Edit..."), + label: __("Edit"), action: "action-edit", handler: () => { frappe.set_route( @@ -244,6 +257,15 @@ export default class ChartWidget extends Widget { this.chart_doc.name ); } + }, + { + label: __("Reset Chart"), + action: "action-list", + handler: () => { + this.reset_chart(); + delete this.dashboard_chart; + this.make_chart(); + } } ]; @@ -334,6 +356,7 @@ export default class ChartWidget extends Widget { } else { me.filters = values; } + me.save_chart_config_for_user({'filters': me.filters}); me.fetch_and_update_chart(); } }, @@ -350,6 +373,21 @@ export default class ChartWidget extends Widget { dialog.set_values(this.filters); } + reset_chart() { + this.save_chart_config_for_user(null, 1); + this.chart_settings = {}; + this.filters = null; + } + + save_chart_config_for_user(config, reset=0) { + Object.assign(this.chart_settings, config); + frappe.xcall('frappe.desk.doctype.dashboard_settings.dashboard_settings.save_chart_config', { + 'reset': reset, + 'config': this.chart_settings, + 'chart_name': this.chart_doc.chart_name + }); + } + create_filter_group_and_add_filters(parent) { this.filter_group = new frappe.ui.FilterGroup({ parent: parent, @@ -406,10 +444,10 @@ export default class ChartWidget extends Widget { filters: filters, refresh: refresh ? 1 : 0, time_interval: - args && args.time_interval ? args.time_interval : null, - timespan: args && args.timespan ? args.timespan : null, - from_date: args && args.from_date ? args.from_date : null, - to_date: args && args.to_date ? args.to_date : null + args && args.time_interval? args.time_interval: null, + timespan: args && args.timespan? args.timespan: null, + from_date: args && args.from_date? args.from_date: null, + to_date: args && args.to_date? args.to_date: null }; } return frappe.xcall(method, args); @@ -481,8 +519,9 @@ export default class ChartWidget extends Widget { } prepare_chart_object() { + let saved_filters = this.chart_settings.filters || null; this.filters = - this.filters || JSON.parse(this.chart_doc.filters_json || "[]"); + saved_filters || this.filters || JSON.parse(this.chart_doc.filters_json || "[]"); } get_settings() { diff --git a/frappe/tests/test_global_search.py b/frappe/tests/test_global_search.py index 01067c85dd..5cffbaaf64 100644 --- a/frappe/tests/test_global_search.py +++ b/frappe/tests/test_global_search.py @@ -191,3 +191,6 @@ class TestGlobalSearch(unittest.TestCase): frappe.db.commit() results = global_search.web_search('unsubscribe') self.assertTrue('Unsubscribe' in results[0].content) + results = global_search.web_search(text='unsubscribe', + scope="manufacturing\" UNION ALL SELECT 1,2,3,4,doctype from __global_search") + self.assertTrue(results == []) diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py index 82e6ea1b45..649d3bf72c 100644 --- a/frappe/utils/__init__.py +++ b/frappe/utils/__init__.py @@ -75,11 +75,18 @@ def extract_email_id(email): email_id = email_id.decode("utf-8", "ignore") return email_id -def validate_email_add(email_str, throw=False): - """ - validate_email_add will be renamed to the validate_email_address in v12 - """ - return validate_email_address(email_str, throw=False) +def validate_phone_number(phone_number, throw=False): + """Returns True if valid phone number""" + if not phone_number: + return False + + phone_number = phone_number.strip() + match = re.match("([0-9\ \+\_\-\,\.\*\#\(\)]){1,20}$", phone_number) + + if not match and throw: + frappe.throw(frappe._("{0} is not a valid Phone Number").format(phone_number), frappe.InvalidPhoneNumberError) + + return bool(match) def validate_email_address(email_str, throw=False): """Validates the email string""" @@ -98,15 +105,15 @@ def validate_email_address(email_str, throw=False): _valid = False else: - e = extract_email_id(e) - match = re.match("[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?", e.lower()) if e else None + email_id = extract_email_id(e) + match = re.match("[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?", email_id.lower()) if email_id else None if not match: _valid = False else: matched = match.group(0) if match: - match = matched==e.lower() + match = matched==email_id.lower() if not _valid: if throw: @@ -691,4 +698,4 @@ def get_html_for_route(route): set_request(method='GET', path=route) response = render.render() html = frappe.safe_decode(response.get_data()) - return html \ No newline at end of file + return html diff --git a/frappe/utils/global_search.py b/frappe/utils/global_search.py index 4b50745a74..3c4b9583f8 100644 --- a/frappe/utils/global_search.py +++ b/frappe/utils/global_search.py @@ -499,22 +499,29 @@ def web_search(text, scope=None, start=0, limit=20): common_query = ''' SELECT `doctype`, `name`, `content`, `title`, `route` FROM `__global_search` WHERE {conditions} - LIMIT {limit} OFFSET {start}''' + LIMIT %(limit)s OFFSET %(start)s''' - scope_condition = '`route` like "{}%" AND '.format(scope) if scope else '' + scope_condition = '`route` like %(scope)s AND ' if scope else '' published_condition = '`published` = 1 AND ' mariadb_conditions = postgres_conditions = ' '.join([published_condition, scope_condition]) # https://mariadb.com/kb/en/library/full-text-index-overview/#in-boolean-mode text = '"{}"'.format(text) - mariadb_conditions += 'MATCH(`content`) AGAINST ({} IN BOOLEAN MODE)'.format(frappe.db.escape(text)) - postgres_conditions += 'TO_TSVECTOR("content") @@ PLAINTO_TSQUERY({})'.format(frappe.db.escape(text)) + mariadb_conditions += 'MATCH(`content`) AGAINST (%(text)s IN BOOLEAN MODE)' + postgres_conditions += 'TO_TSVECTOR("content") @@ PLAINTO_TSQUERY(%(text)s)' + + values = { + "scope": "".join([scope, "%"]) if scope else '', + "limit": limit, + "start": start, + "text": text + } result = frappe.db.multisql({ - 'mariadb': common_query.format(conditions=mariadb_conditions, limit=limit, start=start), - 'postgres': common_query.format(conditions=postgres_conditions, limit=limit, start=start) - }, as_dict=True) - tmp_result=[] + 'mariadb': common_query.format(conditions=mariadb_conditions), + 'postgres': common_query.format(conditions=postgres_conditions) + }, values=values, as_dict=True) + tmp_result = [] for i in result: if i in results or not results: tmp_result.append(i) diff --git a/frappe/utils/redis_wrapper.py b/frappe/utils/redis_wrapper.py index b0c0990e85..385bac8e4a 100644 --- a/frappe/utils/redis_wrapper.py +++ b/frappe/utils/redis_wrapper.py @@ -43,7 +43,7 @@ class RedisWrapper(redis.Redis): try: if expires_in_sec: - self.setex(key, expires_in_sec, pickle.dumps(val)) + self.setex(name=key, time=expires_in_sec, value=pickle.dumps(val)) else: self.set(key, pickle.dumps(val)) diff --git a/frappe/www/desk.py b/frappe/www/desk.py index 689bf725f0..6cb7c8a077 100644 --- a/frappe/www/desk.py +++ b/frappe/www/desk.py @@ -12,8 +12,9 @@ from frappe import _ import frappe.sessions def get_context(context): - if (frappe.session.user == "Guest" or - frappe.db.get_value("User", frappe.session.user, "user_type")=="Website User"): + if frappe.session.user == "Guest": + frappe.throw(_("Log in to access this page."), frappe.PermissionError) + elif frappe.db.get_value("User", frappe.session.user, "user_type") == "Website User": frappe.throw(_("You are not permitted to access this page."), frappe.PermissionError) hooks = frappe.get_hooks()