diff --git a/cypress/integration/control_rating.js b/cypress/integration/control_rating.js index b98e1d0845..15c11b352b 100644 --- a/cypress/integration/control_rating.js +++ b/cypress/integration/control_rating.js @@ -20,12 +20,13 @@ context('Control Rating', () => { cy.get('div.rating') .children('svg') + .find('.right-half') .first() .click() .should('have.class', 'star-click'); cy.get('@dialog').then(dialog => { var value = dialog.get_value('rate'); - expect(value).to.equal(1); + expect(value).to.equal(1/7); dialog.hide(); }); }); @@ -35,6 +36,7 @@ context('Control Rating', () => { cy.get('div.rating') .children('svg') + .find('.right-half') .first() .invoke('trigger', 'mouseenter') .should('have.class', 'star-hover') diff --git a/cypress/integration/multi_select_dialog.js b/cypress/integration/multi_select_dialog.js index a45fba8d32..7752ad0f0b 100644 --- a/cypress/integration/multi_select_dialog.js +++ b/cypress/integration/multi_select_dialog.js @@ -2,32 +2,47 @@ context('MultiSelectDialog', () => { before(() => { cy.login(); cy.visit('/app'); + const contact_template = { + "doctype": "Contact", + "first_name": "Test", + "status": "Passive", + "email_ids": [ + { + "doctype": "Contact Email", + "email_id": "test@example.com", + "is_primary": 0 + } + ] + }; + const promises = Array.from({length: 25}) + .map(() => cy.insert_doc('Contact', contact_template, true)); + Promise.all(promises); }); function open_multi_select_dialog() { cy.window().its('frappe').then(frappe => { new frappe.ui.form.MultiSelectDialog({ - doctype: "Assignment Rule", + doctype: "Contact", target: {}, setters: { - document_type: null, - priority: null + status: null, + gender: null }, add_filters_group: 1, allow_child_item_selection: 1, - child_fieldname: "assignment_days", - child_columns: ["day"] + child_fieldname: "email_ids", + child_columns: ["email_id", "is_primary"] }); }); } - it('multi select dialog api works', () => { + it('checks multi select dialog api works', () => { open_multi_select_dialog(); - cy.get_open_dialog().should('contain', 'Select Assignment Rules'); + cy.get_open_dialog().should('contain', 'Select Contacts'); }); it('checks for filters', () => { - ['search_term', 'document_type', 'priority'].forEach(fieldname => { + ['search_term', 'status', 'gender'].forEach(fieldname => { cy.get_open_dialog().get(`.frappe-control[data-fieldname="${fieldname}"]`).should('exist'); }); @@ -42,17 +57,43 @@ context('MultiSelectDialog', () => { cy.get_open_dialog() .get(`.frappe-control[data-fieldname="allow_child_item_selection"]`) + .find('input[data-fieldname="allow_child_item_selection"]') .should('exist') - .click(); + .click({force: true}); cy.get_open_dialog() .get(`.frappe-control[data-fieldname="child_selection_area"]`) .should('exist'); cy.get_open_dialog() - .get(`.dt-row-header`).should('contain', 'Assignment Rule'); + .get(`.dt-row-header`).should('contain', 'Contact'); cy.get_open_dialog() - .get(`.dt-row-header`).should('contain', 'Day'); + .get(`.dt-row-header`).should('contain', 'Email Id'); + + cy.get_open_dialog() + .get(`.dt-row-header`).should('contain', 'Is Primary'); + }); + + it('tests more button', () => { + cy.get_open_dialog() + .get(`.frappe-control[data-fieldname="more_btn"]`) + .should('exist') + .as('more-btn'); + + cy.get_open_dialog().get('.list-item-container').should(($rows) => { + expect($rows).to.have.length(20); + }); + + cy.intercept('POST', 'api/method/frappe.client.get_list').as('get-more-records'); + cy.get('@more-btn').find('button').click({force: true}); + cy.wait('@get-more-records'); + + cy.get_open_dialog().get('.list-item-container').should(($rows) => { + if ($rows.length <= 20) { + throw new Error("More button doesn't work"); + } + }); + }); }); \ No newline at end of file diff --git a/frappe/__init__.py b/frappe/__init__.py index defa6e3336..08c0f794b3 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -740,17 +740,26 @@ def has_permission(doctype=None, ptype="read", doc=None, user=None, verbose=Fals :param doc: [optional] Checks User permissions for given doc. :param user: [optional] Check for given user. Default: current user. :param parent_doctype: Required when checking permission for a child DocType (unless doc is specified).""" + import frappe.permissions + if not doctype and doc: doctype = doc.doctype - import frappe.permissions out = frappe.permissions.has_permission(doctype, ptype, doc=doc, verbose=verbose, user=user, raise_exception=throw, parent_doctype=parent_doctype) + if throw and not out: - if doc: - frappe.throw(_("No permission for {0}").format(doc.doctype + " " + doc.name)) - else: - frappe.throw(_("No permission for {0}").format(doctype)) + # mimics frappe.throw + document_label = f"{doc.doctype} {doc.name}" if doc else doctype + msgprint( + _("No permission for {0}").format(document_label), + raise_exception=ValidationError, + title=None, + indicator='red', + is_minimizable=None, + wide=None, + as_list=False + ) return out @@ -1203,7 +1212,7 @@ def read_file(path, raise_not_found=False): def get_attr(method_string): """Get python method object from its name.""" app_name = method_string.split(".")[0] - if not local.flags.in_install and app_name not in get_installed_apps(): + if not local.flags.in_uninstall and not local.flags.in_install and app_name not in get_installed_apps(): throw(_("App {0} is not installed").format(app_name), AppNotInstalledError) modulename = '.'.join(method_string.split('.')[:-1]) diff --git a/frappe/client.py b/frappe/client.py index 6641e471af..e835e7fee7 100644 --- a/frappe/client.py +++ b/frappe/client.py @@ -32,6 +32,7 @@ def get_list(doctype, fields=None, filters=None, order_by=None, args = frappe._dict( doctype=doctype, + parent_doctype=parent, fields=fields, filters=filters, or_filters=or_filters, diff --git a/frappe/commands/site.py b/frappe/commands/site.py index 6d3ed1af16..677325e02d 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -698,8 +698,7 @@ def _drop_site(site, root_login='root', root_password=None, archived_sites_path= archived_sites_path = archived_sites_path or os.path.join(frappe.get_app_path('frappe'), '..', '..', '..', 'archived', 'sites') - if not os.path.exists(archived_sites_path): - os.mkdir(archived_sites_path) + os.makedirs(archived_sites_path, exist_ok=True) move(archived_sites_path, site) diff --git a/frappe/core/doctype/data_import/exporter.py b/frappe/core/doctype/data_import/exporter.py index 21faf98e49..c09bd58c25 100644 --- a/frappe/core/doctype/data_import/exporter.py +++ b/frappe/core/doctype/data_import/exporter.py @@ -5,6 +5,7 @@ import typing import frappe +from frappe import _ from frappe.model import ( display_fieldtypes, no_value_fields, @@ -215,9 +216,9 @@ class Exporter: for df in self.fields: is_parent = not df.is_child_table_field if is_parent: - label = df.label + label = _(df.label) else: - label = "{0} ({1})".format(df.label, df.child_table_df.label) + label = "{0} ({1})".format(_(df.label), _(df.child_table_df.label)) if label in header: # this label is already in the header, @@ -227,6 +228,7 @@ class Exporter: label = "{0}".format(df.fieldname) else: label = "{0}.{1}".format(df.child_table_df.fieldname, df.fieldname) + header.append(label) self.csv_array.append(header) @@ -253,10 +255,10 @@ class Exporter: self.build_xlsx_response() def build_csv_response(self): - build_csv_response(self.get_csv_array_for_export(), self.doctype) + build_csv_response(self.get_csv_array_for_export(), _(self.doctype)) def build_xlsx_response(self): - build_xlsx_response(self.get_csv_array_for_export(), self.doctype) + build_xlsx_response(self.get_csv_array_for_export(), _(self.doctype)) def group_children_data_by_parent(self, children_data: typing.Dict[str, list]): return groupby_metric(children_data, key='parent') diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py index 28880e7e38..b9b2050763 100644 --- a/frappe/core/doctype/data_import/importer.py +++ b/frappe/core/doctype/data_import/importer.py @@ -262,7 +262,7 @@ class Importer: rows = [header_row] rows += [row.data for row in self.import_file.data if row.row_number in row_indexes] - build_csv_response(rows, self.doctype) + build_csv_response(rows, _(self.doctype)) def print_import_log(self, import_log): failed_records = [log for log in import_log if not log.success] @@ -1009,18 +1009,14 @@ def build_fields_dict_for_column_matching(parent_doctype): out = {} # doctypes and fieldname if it is a child doctype - doctypes = [[parent_doctype, None]] + [ - [df.options, df] for df in parent_meta.get_table_fields() + doctypes = [(parent_doctype, None)] + [ + (df.options, df) for df in parent_meta.get_table_fields() ] for doctype, table_df in doctypes: + translated_table_label = _(table_df.label) if table_df else None + # name field - name_by_label = ( - "ID" if doctype == parent_doctype else "ID ({0})".format(table_df.label) - ) - name_by_fieldname = ( - "name" if doctype == parent_doctype else "{0}.name".format(table_df.fieldname) - ) name_df = frappe._dict( { "fieldtype": "Data", @@ -1031,63 +1027,90 @@ def build_fields_dict_for_column_matching(parent_doctype): } ) - if doctype != parent_doctype: + if doctype == parent_doctype: + name_headers = ( + "name", # fieldname + "ID", # label + _("ID"), # translated label + ) + else: + name_headers = ( + "{0}.name".format(table_df.fieldname), # fieldname + "ID ({0})".format(table_df.label), # label + "{0} ({1})".format(_("ID"), translated_table_label), # translated label + ) + name_df.is_child_table_field = True name_df.child_table_df = table_df - out[name_by_label] = name_df - out[name_by_fieldname] = name_df + for header in name_headers: + out[header] = name_df - # other fields fields = get_standard_fields(doctype) + frappe.get_meta(doctype).fields for df in fields: - label = (df.label or "").strip() fieldtype = df.fieldtype or "Data" + if fieldtype in no_value_fields: + continue + + label = (df.label or "").strip() + translated_label = _(label) parent = df.parent or parent_doctype - if fieldtype not in no_value_fields: - if parent_doctype == doctype: - # for parent doctypes keys will be - # Label - # label - # Label (label) - if not out.get(label): - # if Label is already set, don't set it again - # in case of duplicate column headers - out[label] = df - out[df.fieldname] = df - label_with_fieldname = "{0} ({1})".format(label, df.fieldname) - out[label_with_fieldname] = df + + if parent_doctype == doctype: + # for parent doctypes keys will be + # Label, fieldname, Label (fieldname) + + for header in (label, translated_label): + # if Label is already set, don't set it again + # in case of duplicate column headers + if header not in out: + out[header] = df + + for header in ( + df.fieldname, + f"{label} ({df.fieldname})", + f"{translated_label} ({df.fieldname})" + ): + out[header] = df + + else: + # for child doctypes keys will be + # Label (Table Field Label) + # table_field.fieldname + + # create a new df object to avoid mutation problems + if isinstance(df, dict): + new_df = frappe._dict(df.copy()) else: - # in case there are multiple table fields with the same doctype - # for child doctypes keys will be - # Label (Table Field Label) - # table_field.fieldname - table_fields = parent_meta.get( - "fields", {"fieldtype": ["in", table_fieldtypes], "options": parent} - ) - for table_field in table_fields: - by_label = "{0} ({1})".format(label, table_field.label) - by_fieldname = "{0}.{1}".format(table_field.fieldname, df.fieldname) + new_df = df.as_dict() - # create a new df object to avoid mutation problems - if isinstance(df, dict): - new_df = frappe._dict(df.copy()) - else: - new_df = df.as_dict() + new_df.is_child_table_field = True + new_df.child_table_df = table_df - new_df.is_child_table_field = True - new_df.child_table_df = table_field - out[by_label] = new_df - out[by_fieldname] = new_df + for header in ( + # fieldname + "{0}.{1}".format(table_df.fieldname, df.fieldname), + # label + "{0} ({1})".format(label, table_df.label), + # translated label + "{0} ({1})".format(translated_label, translated_table_label), + ): + out[header] = new_df # if autoname is based on field # add an entry for "ID (Autoname Field)" autoname_field = get_autoname_field(parent_doctype) if autoname_field: - out["ID ({})".format(autoname_field.label)] = autoname_field - # ID field should also map to the autoname field - out["ID"] = autoname_field - out["name"] = autoname_field + for header in ( + "ID ({})".format(autoname_field.label), # label + "{0} ({1})".format(_("ID"), _(autoname_field.label)), # translated label + + # ID field should also map to the autoname field + "ID", + _("ID"), + "name", + ): + out[header] = autoname_field return out diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 3b8dcc8277..ad0c3e8e6f 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -381,7 +381,7 @@ class DocType(Document): document_cls_tag = f"class {despaced_name}(Document)" document_import_tag = "from frappe.model.document import Document" website_generator_cls_tag = f"class {despaced_name}(WebsiteGenerator)" - website_generator_import_tag = "from frappe.website.generators.website_generator import WebsiteGenerator" + website_generator_import_tag = "from frappe.website.website_generator import WebsiteGenerator" with open(controller_path) as f: code = f.read() diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index 91090bdd77..adf10b9a03 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -29,6 +29,7 @@ from frappe import _, conf, safe_decode 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, optimize_image +from frappe.utils.file_manager import safe_b64decode class MaxFileSizeReachedError(frappe.ValidationError): pass @@ -436,7 +437,7 @@ class File(Document): if b"," in self.content: self.content = self.content.split(b",")[1] - self.content = base64.b64decode(self.content) + self.content = safe_b64decode(self.content) if not self.is_private: self.is_private = 0 @@ -852,7 +853,7 @@ def extract_images_from_html(doc, content, is_private=False): content = content.encode("utf-8") if b"," in content: content = content.split(b",")[1] - content = base64.b64decode(content) + content = safe_b64decode(content) content = optimize_image(content, mtype) diff --git a/frappe/core/doctype/report/boilerplate/controller.js b/frappe/core/doctype/report/boilerplate/controller.js index 5148f34462..9cf71a8c09 100644 --- a/frappe/core/doctype/report/boilerplate/controller.js +++ b/frappe/core/doctype/report/boilerplate/controller.js @@ -1,4 +1,4 @@ -// Copyright (c) 2016, {app_publisher} and contributors +// Copyright (c) {year}, {app_publisher} and contributors // For license information, please see license.txt /* eslint-disable */ diff --git a/frappe/core/doctype/report/boilerplate/controller.py b/frappe/core/doctype/report/boilerplate/controller.py index ccf732a405..72da0c7ce5 100644 --- a/frappe/core/doctype/report/boilerplate/controller.py +++ b/frappe/core/doctype/report/boilerplate/controller.py @@ -1,5 +1,5 @@ -# Copyright (c) 2013, {app_publisher} and contributors -# License: MIT. See LICENSE +# Copyright (c) {year}, {app_publisher} and contributors +# For license information, please see license.txt # import frappe diff --git a/frappe/core/doctype/server_script/server_script_utils.py b/frappe/core/doctype/server_script/server_script_utils.py index b4cfdf0a17..d8c945fb6d 100644 --- a/frappe/core/doctype/server_script/server_script_utils.py +++ b/frappe/core/doctype/server_script/server_script_utils.py @@ -19,13 +19,6 @@ EVENT_MAP = { 'on_update_after_submit': 'After Save (Submitted Document)' } -def run_server_script_api(method): - # called via handler, execute an API script - script_name = get_server_script_map().get('_api', {}).get(method) - if script_name: - frappe.get_doc('Server Script', script_name).execute_method() - return True - def run_server_script_for_doc_event(doc, event): # run document event method if not event in EVENT_MAP: diff --git a/frappe/custom/fixtures/temp_doctype.json b/frappe/custom/fixtures/temp_doctype.json new file mode 100644 index 0000000000..343aa2cb37 --- /dev/null +++ b/frappe/custom/fixtures/temp_doctype.json @@ -0,0 +1,168 @@ +{ + "docstatus": 0, + "doctype": "DocType", + "name": "new-doctype-2", + "__islocal": 1, + "__unsaved": 1, + "owner": "Administrator", + "is_submittable": 0, + "istable": 0, + "issingle": 0, + "is_tree": 0, + "editable_grid": 1, + "quick_entry": 1, + "track_changes": 1, + "track_seen": 0, + "track_views": 0, + "custom": 1, + "beta": 0, + "is_virtual": 0, + "naming_rule": "", + "name_case": "", + "allow_rename": 1, + "hide_toolbar": 0, + "allow_copy": 0, + "allow_import": 0, + "allow_events_in_timeline": 0, + "allow_auto_repeat": 0, + "sort_field": "modified", + "sort_order": "DESC", + "document_type": "", + "show_preview_popup": 0, + "show_name_in_global_search": 0, + "email_append_to": 0, + "read_only": 0, + "in_create": 0, + "has_web_view": 0, + "allow_guest_to_view": 0, + "index_web_pages_for_search": 1, + "engine": "InnoDB", + "permissions": [ + { + "docstatus": 0, + "doctype": "DocPerm", + "name": "new-docperm-2", + "__islocal": 1, + "__unsaved": 1, + "owner": "Administrator", + "if_owner": 0, + "permlevel": 0, + "select": 0, + "read": 1, + "write": 1, + "create": 1, + "delete": 1, + "submit": 0, + "cancel": 0, + "amend": 0, + "report": 1, + "export": 1, + "import": 0, + "set_user_permissions": 0, + "share": 1, + "print": 1, + "email": 1, + "parent": "new-doctype-2", + "parentfield": "permissions", + "parenttype": "DocType", + "idx": 1, + "role": "System Manager" + } + ], + "__newname": "temp_doctype", + "module": "Custom", + "fields": [ + { + "docstatus": 0, + "doctype": "DocField", + "name": "new-docfield-1", + "__islocal": 1, + "__unsaved": 1, + "owner": "Administrator", + "fieldtype": "Data", + "precision": "", + "non_negative": 0, + "hide_days": 0, + "hide_seconds": 0, + "reqd": 1, + "search_index": 0, + "fetch_if_empty": 0, + "hidden": 0, + "bold": 0, + "allow_in_quick_entry": 0, + "translatable": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "report_hide": 0, + "collapsible": 0, + "hide_border": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "in_preview": 0, + "in_filter": 0, + "in_global_search": 0, + "read_only": 0, + "allow_on_submit": 0, + "ignore_user_permissions": 0, + "allow_bulk_edit": 0, + "permlevel": 0, + "ignore_xss_filter": 0, + "unique": 0, + "no_copy": 0, + "set_only_once": 0, + "remember_last_selected_value": 0, + "parent": "new-doctype-2", + "parentfield": "fields", + "parenttype": "DocType", + "idx": 1, + "__unedited": false, + "label": "member_name" + }, + { + "docstatus": 0, + "doctype": "DocField", + "name": "new-docfield-2", + "__islocal": 1, + "__unsaved": 1, + "owner": "Administrator", + "fieldtype": "Data", + "precision": "", + "non_negative": 0, + "hide_days": 0, + "hide_seconds": 0, + "reqd": 0, + "search_index": 0, + "fetch_if_empty": 0, + "hidden": 0, + "bold": 0, + "allow_in_quick_entry": 0, + "translatable": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "report_hide": 0, + "collapsible": 0, + "hide_border": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "in_preview": 0, + "in_filter": 0, + "in_global_search": 0, + "read_only": 0, + "allow_on_submit": 0, + "ignore_user_permissions": 0, + "allow_bulk_edit": 0, + "permlevel": 0, + "ignore_xss_filter": 0, + "unique": 0, + "no_copy": 0, + "set_only_once": 0, + "remember_last_selected_value": 0, + "parent": "new-doctype-2", + "parentfield": "fields", + "parenttype": "DocType", + "idx": 2, + "__unedited": false, + "label": "email" + } + ] +} diff --git a/frappe/custom/fixtures/temp_singles.json b/frappe/custom/fixtures/temp_singles.json new file mode 100644 index 0000000000..b7e2536f25 --- /dev/null +++ b/frappe/custom/fixtures/temp_singles.json @@ -0,0 +1,168 @@ +{ + "docstatus": 0, + "doctype": "DocType", + "name": "new-doctype-1", + "__islocal": 1, + "__unsaved": 1, + "owner": "Administrator", + "is_submittable": 0, + "istable": 0, + "issingle": 1, + "is_tree": 0, + "editable_grid": 1, + "quick_entry": 0, + "track_changes": 1, + "track_seen": 0, + "track_views": 0, + "custom": 1, + "beta": 0, + "is_virtual": 0, + "naming_rule": "", + "name_case": "", + "allow_rename": 1, + "hide_toolbar": 0, + "allow_copy": 0, + "allow_import": 0, + "allow_events_in_timeline": 0, + "allow_auto_repeat": 0, + "sort_field": "modified", + "sort_order": "DESC", + "document_type": "", + "show_preview_popup": 0, + "show_name_in_global_search": 0, + "email_append_to": 0, + "read_only": 0, + "in_create": 0, + "has_web_view": 0, + "allow_guest_to_view": 0, + "index_web_pages_for_search": 1, + "engine": "InnoDB", + "permissions": [ + { + "docstatus": 0, + "doctype": "DocPerm", + "name": "new-docperm-1", + "__islocal": 1, + "__unsaved": 1, + "owner": "Administrator", + "if_owner": 0, + "permlevel": 0, + "select": 0, + "read": 1, + "write": 1, + "create": 1, + "delete": 1, + "submit": 0, + "cancel": 0, + "amend": 0, + "report": 1, + "export": 1, + "import": 0, + "set_user_permissions": 0, + "share": 1, + "print": 1, + "email": 1, + "parent": "new-doctype-1", + "parentfield": "permissions", + "parenttype": "DocType", + "idx": 1, + "role": "System Manager" + } + ], + "__newname": "temp_singles", + "module": "Custom", + "fields": [ + { + "docstatus": 0, + "doctype": "DocField", + "name": "new-docfield-1", + "__islocal": 1, + "__unsaved": 1, + "owner": "Administrator", + "fieldtype": "Data", + "precision": "", + "non_negative": 0, + "hide_days": 0, + "hide_seconds": 0, + "reqd": 0, + "search_index": 0, + "fetch_if_empty": 0, + "hidden": 0, + "bold": 0, + "allow_in_quick_entry": 0, + "translatable": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "report_hide": 0, + "collapsible": 0, + "hide_border": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "in_preview": 0, + "in_filter": 0, + "in_global_search": 0, + "read_only": 0, + "allow_on_submit": 0, + "ignore_user_permissions": 0, + "allow_bulk_edit": 0, + "permlevel": 0, + "ignore_xss_filter": 0, + "unique": 0, + "no_copy": 0, + "set_only_once": 0, + "remember_last_selected_value": 0, + "parent": "new-doctype-1", + "parentfield": "fields", + "parenttype": "DocType", + "idx": 1, + "__unedited": false, + "label": "member_name" + }, + { + "docstatus": 0, + "doctype": "DocField", + "name": "new-docfield-2", + "__islocal": 1, + "__unsaved": 1, + "owner": "Administrator", + "fieldtype": "Data", + "precision": "", + "non_negative": 0, + "hide_days": 0, + "hide_seconds": 0, + "reqd": 0, + "search_index": 0, + "fetch_if_empty": 0, + "hidden": 0, + "bold": 0, + "allow_in_quick_entry": 0, + "translatable": 0, + "print_hide": 0, + "print_hide_if_no_value": 0, + "report_hide": 0, + "collapsible": 0, + "hide_border": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "in_preview": 0, + "in_filter": 0, + "in_global_search": 0, + "read_only": 0, + "allow_on_submit": 0, + "ignore_user_permissions": 0, + "allow_bulk_edit": 0, + "permlevel": 0, + "ignore_xss_filter": 0, + "unique": 0, + "no_copy": 0, + "set_only_once": 0, + "remember_last_selected_value": 0, + "parent": "new-doctype-1", + "parentfield": "fields", + "parenttype": "DocType", + "idx": 2, + "__unedited": false, + "label": "email" + } + ] +} diff --git a/frappe/database/database.py b/frappe/database/database.py index e8c81c4bc1..7c147cd1d0 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -801,14 +801,27 @@ class Database(object): frappe.local.realtime_log = [] - def rollback(self): - """`ROLLBACK` current transaction.""" - self.sql("rollback") - self.begin() - for obj in frappe.local.rollback_observers: - if hasattr(obj, "on_rollback"): - obj.on_rollback() - frappe.local.rollback_observers = [] + def savepoint(self, save_point): + """Savepoints work as a nested transaction. + + Changes can be undone to a save point by doing frappe.db.rollback(save_point) + + Note: rollback watchers can not work with save points. + so only changes to database are undone when rolling back to a savepoint. + Avoid using savepoints when writing to filesystem.""" + self.sql(f"savepoint {save_point}") + + def rollback(self, *, save_point=None): + """`ROLLBACK` current transaction. Optionally rollback to a known save_point.""" + if save_point: + self.sql(f"rollback to savepoint {save_point}") + else: + self.sql("rollback") + self.begin() + for obj in frappe.local.rollback_observers: + if hasattr(obj, "on_rollback"): + obj.on_rollback() + frappe.local.rollback_observers = [] def field_exists(self, dt, fn): """Return true of field exists.""" @@ -824,9 +837,9 @@ class Database(object): def has_table(self, doctype): return self.table_exists(doctype) - def get_tables(self): + def get_tables(self, cached=True): tables = frappe.cache().get_value('db_tables') - if not tables: + if not tables or not cached: table_rows = self.sql(""" SELECT table_name FROM information_schema.tables diff --git a/frappe/database/mariadb/database.py b/frappe/database/mariadb/database.py index afd912bc6b..6b827a4e89 100644 --- a/frappe/database/mariadb/database.py +++ b/frappe/database/mariadb/database.py @@ -135,9 +135,10 @@ class MariaDBDatabase(Database): table_name = get_table_name(doctype) return self.sql(f"DESC `{table_name}`") - def change_column_type(self, doctype: str, column: str, type: str) -> Union[List, Tuple]: + def change_column_type(self, doctype: str, column: str, type: str, nullable: bool = False) -> Union[List, Tuple]: table_name = get_table_name(doctype) - return self.sql(f"ALTER TABLE `{table_name}` MODIFY `{column}` {type} NOT NULL") + null_constraint = "NOT NULL" if not nullable else "" + return self.sql(f"ALTER TABLE `{table_name}` MODIFY `{column}` {type} {null_constraint}") # exception types @staticmethod diff --git a/frappe/database/mariadb/setup_db.py b/frappe/database/mariadb/setup_db.py index 8088cc2331..1585e4537b 100644 --- a/frappe/database/mariadb/setup_db.py +++ b/frappe/database/mariadb/setup_db.py @@ -92,7 +92,7 @@ def bootstrap_database(db_name, verbose, source_sql=None): import_db_from_sql(source_sql, verbose) frappe.connect(db_name=db_name) - if 'tabDefaultValue' not in frappe.db.get_tables(): + if 'tabDefaultValue' not in frappe.db.get_tables(cached=False): from click import secho secho( diff --git a/frappe/database/postgres/database.py b/frappe/database/postgres/database.py index 3cea1440cf..008635b1b3 100644 --- a/frappe/database/postgres/database.py +++ b/frappe/database/postgres/database.py @@ -183,9 +183,12 @@ class PostgresDatabase(Database): table_name = get_table_name(doctype) return self.sql(f"SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_NAME = '{table_name}'") - def change_column_type(self, doctype: str, column: str, type: str) -> Union[List, Tuple]: + def change_column_type(self, doctype: str, column: str, type: str, nullable: bool = False) -> Union[List, Tuple]: table_name = get_table_name(doctype) - return self.sql(f'ALTER TABLE "{table_name}" ALTER COLUMN "{column}" TYPE {type}') + null_constraint = "SET NOT NULL" if not nullable else "DROP NOT NULL" + return self.sql(f"""ALTER TABLE "{table_name}" + ALTER COLUMN "{column}" TYPE {type}, + ALTER COLUMN "{column}" {null_constraint}""") def create_auth_table(self): self.sql_ddl("""create table if not exists "__Auth" ( diff --git a/frappe/desk/page/leaderboard/leaderboard.js b/frappe/desk/page/leaderboard/leaderboard.js index 076d672db5..aa1678af37 100644 --- a/frappe/desk/page/leaderboard/leaderboard.js +++ b/frappe/desk/page/leaderboard/leaderboard.js @@ -382,10 +382,10 @@ class Leaderboard { let timespan = this.options.selected_timespan.toLowerCase(); let current_date = frappe.datetime.now_date(); let date_range_map = { - "this week": [frappe.datetime.week_start(), current_date], - "this month": [frappe.datetime.month_start(), current_date], - "this quarter": [frappe.datetime.quarter_start(), current_date], - "this year": [frappe.datetime.year_start(), current_date], + "this week": [frappe.datetime.week_start(), frappe.datetime.week_end()], + "this month": [frappe.datetime.month_start(), frappe.datetime.month_end()], + "this quarter": [frappe.datetime.quarter_start(), frappe.datetime.quarter_end()], + "this year": [frappe.datetime.year_start(), frappe.datetime.year_end()], "last week": [frappe.datetime.add_days(current_date, -7), current_date], "last month": [frappe.datetime.add_months(current_date, -1), current_date], "last quarter": [frappe.datetime.add_months(current_date, -3), current_date], diff --git a/frappe/desk/page/setup_wizard/setup_wizard.js b/frappe/desk/page/setup_wizard/setup_wizard.js index 7e90bc01ad..32d5bf235b 100644 --- a/frappe/desk/page/setup_wizard/setup_wizard.js +++ b/frappe/desk/page/setup_wizard/setup_wizard.js @@ -392,17 +392,24 @@ frappe.setup.slides_settings = [ fields: [ { fieldname: "country", label: __("Your Country"), reqd: 1, - fieldtype: "Select" + fieldtype: "Autocomplete", + placeholder: __('Select Country') }, { fieldtype: "Section Break" }, { - fieldname: "timezone", label: __("Time Zone"), reqd: 1, - fieldtype: "Select" + fieldname: "timezone", + label: __("Time Zone"), + placeholder: __('Select Time Zone'), + reqd: 1, + fieldtype: "Select", }, { fieldtype: "Column Break" }, { - fieldname: "currency", label: __("Currency"), reqd: 1, - fieldtype: "Select" + fieldname: "currency", + label: __("Currency"), + placeholder: __('Select Currency'), + reqd: 1, + fieldtype: "Select", } ], @@ -512,7 +519,7 @@ frappe.setup.utils = { frappe.setup.data.email = r.message.email; callback(slide); } - }) + }); }, setup_language_field: function (slide) { @@ -529,16 +536,19 @@ frappe.setup.utils = { var country_field = slide.get_field('country'); - slide.get_input("country").empty() - .add_options([""].concat(Object.keys(data.country_info).sort())); - slide.get_input("currency").empty() - .add_options(frappe.utils.unique([""].concat( - $.map(data.country_info, opts => opts.currency) - )).sort()); + country_field.set_data(Object.keys(data.country_info).sort()); + + slide.get_input("currency") + .empty() + .add_options( + frappe.utils.unique( + $.map(data.country_info, opts => opts.currency).sort() + ) + ); slide.get_input("timezone").empty() - .add_options([""].concat(data.all_timezones)); + .add_options(data.all_timezones); // set values if present if (frappe.wizard.values.country) { @@ -547,13 +557,9 @@ frappe.setup.utils = { country_field.set_input(data.default_country); } - if (frappe.wizard.values.currency) { - slide.get_field("currency").set_input(frappe.wizard.values.currency); - } + slide.get_field("currency").set_input(frappe.wizard.values.currency); - if (frappe.wizard.values.timezone) { - slide.get_field("timezone").set_input(frappe.wizard.values.timezone); - } + slide.get_field("timezone").set_input(frappe.wizard.values.timezone); }, @@ -589,17 +595,15 @@ frappe.setup.utils = { $timezone.empty(); + if (!country) return; // add country specific timezones first - if (country) { - var timezone_list = data.country_info[country].timezones || []; - $timezone.add_options(timezone_list.sort()); - slide.get_field("currency").set_input(data.country_info[country].currency); - slide.get_field("currency").$input.trigger("change"); - } + const timezone_list = data.country_info[country].timezones || []; + $timezone.add_options(timezone_list.sort()); + slide.get_field("currency").set_input(data.country_info[country].currency); + slide.get_field("currency").$input.trigger("change"); // add all timezones at the end, so that user has the option to change it to any timezone - $timezone.add_options([""].concat(data.all_timezones)); - + $timezone.add_options(data.all_timezones); slide.get_field("timezone").set_input($timezone.val()); // temporarily set date format @@ -617,7 +621,7 @@ frappe.setup.utils = { if (number_format === "#.###") { number_format = "#.###,##"; } else if (number_format === "#,###") { - number_format = "#,###.##" + number_format = "#,###.##"; } frappe.boot.sysdefaults.number_format = number_format; diff --git a/frappe/desk/page/setup_wizard/setup_wizard.py b/frappe/desk/page/setup_wizard/setup_wizard.py index 83a5e16009..b5f0c5043c 100755 --- a/frappe/desk/page/setup_wizard/setup_wizard.py +++ b/frappe/desk/page/setup_wizard/setup_wizard.py @@ -151,7 +151,7 @@ def update_system_settings(args): system_settings = frappe.get_doc("System Settings", "System Settings") system_settings.update({ "country": args.get("country"), - "language": get_language_code(args.get("language")), + "language": get_language_code(args.get("language")) or 'en', "time_zone": args.get("timezone"), "float_precision": 3, 'date_format': frappe.db.get_value("Country", args.get("country"), "date_format"), diff --git a/frappe/handler.py b/frappe/handler.py index 35063ee9d6..3fd1c096e4 100755 --- a/frappe/handler.py +++ b/frappe/handler.py @@ -12,7 +12,7 @@ from frappe.utils.response import build_response from frappe.utils.csvutils import build_csv_response from frappe.utils.image import optimize_image from mimetypes import guess_type -from frappe.core.doctype.server_script.server_script_utils import run_server_script_api +from frappe.core.doctype.server_script.server_script_utils import get_server_script_map ALLOWED_MIMETYPES = ('image/png', 'image/jpeg', 'application/pdf', 'application/msword', @@ -49,8 +49,9 @@ def execute_cmd(cmd, from_async=False): break # via server script - if run_server_script_api(cmd): - return None + server_script = get_server_script_map().get('_api', {}).get(cmd) + if server_script: + return run_server_script(server_script) try: method = get_attr(cmd) @@ -66,7 +67,20 @@ def execute_cmd(cmd, from_async=False): return frappe.call(method, **frappe.form_dict) + +def run_server_script(server_script): + response = frappe.get_doc('Server Script', server_script).execute_method() + + # some server scripts return output using flags (empty dict by default), + # while others directly modify frappe.response + # return flags if not empty dict (this overwrites frappe.response.message) + if response != {}: + return response + def is_valid_http_method(method): + if frappe.flags.in_safe_exec: + return + http_method = frappe.local.request.method if http_method not in frappe.allowed_http_methods_for_whitelisted_func[method]: @@ -260,7 +274,7 @@ def run_doc_method(method, docs=None, dt=None, dn=None, arg=None, args=None): # build output as csv if cint(frappe.form_dict.get('as_csv')): - build_csv_response(response, doc.doctype.replace(' ', '')) + build_csv_response(response, _(doc.doctype).replace(' ', '')) return frappe.response['message'] = response diff --git a/frappe/installer.py b/frappe/installer.py index cd6526c788..b50fa4a3b5 100755 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -208,6 +208,7 @@ def remove_app(app_name, dry_run=False, yes=False, no_backup=False, force=False) import click site = frappe.local.site + app_hooks = frappe.get_hooks(app_name=app_name) # dont allow uninstall app if not installed unless forced if not force: @@ -233,6 +234,9 @@ def remove_app(app_name, dry_run=False, yes=False, no_backup=False, force=False) frappe.flags.in_uninstall = True + for before_uninstall in app_hooks.before_uninstall or []: + frappe.get_attr(before_uninstall)() + modules = frappe.get_all("Module Def", filters={"app_name": app_name}, pluck="name") drop_doctypes = _delete_modules(modules, dry_run=dry_run) @@ -243,6 +247,9 @@ def remove_app(app_name, dry_run=False, yes=False, no_backup=False, force=False) frappe.get_single('Installed Applications').update_versions() frappe.db.commit() + for after_uninstall in app_hooks.after_uninstall or []: + frappe.get_attr(after_uninstall)() + click.secho(f"Uninstalled App {app_name} from Site {site}", fg="green") frappe.flags.in_uninstall = False diff --git a/frappe/integrations/doctype/webhook/__init__.py b/frappe/integrations/doctype/webhook/__init__.py index 6dcc0218a3..cb7a9963b7 100644 --- a/frappe/integrations/doctype/webhook/__init__.py +++ b/frappe/integrations/doctype/webhook/__init__.py @@ -7,7 +7,7 @@ import frappe def run_webhooks(doc, method): '''Run webhooks for this method''' - if frappe.flags.in_import or frappe.flags.in_patch or frappe.flags.in_install: + if frappe.flags.in_import or frappe.flags.in_patch or frappe.flags.in_install or frappe.flags.in_migrate: return if frappe.flags.webhooks_executed is None: diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index 16c0d18d9f..cb2c2af898 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -36,10 +36,12 @@ 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, run=True, strict=True, pluck=None, ignore_ddl=False, parent_doctype=None) -> List: - if not ignore_permissions and \ - not frappe.has_permission(self.doctype, "select", user=user, parent_doctype=parent_doctype) and \ - not frappe.has_permission(self.doctype, "read", user=user, parent_doctype=parent_doctype): + if ( + not ignore_permissions + and not frappe.has_permission(self.doctype, "select", user=user, parent_doctype=parent_doctype) + and not frappe.has_permission(self.doctype, "read", user=user, parent_doctype=parent_doctype) + ): frappe.flags.error_message = _('Insufficient Permission for {0}').format(frappe.bold(self.doctype)) raise frappe.PermissionError(self.doctype) @@ -787,12 +789,15 @@ class DatabaseQuery(object): def check_parent_permission(parent, child_doctype): if parent: # User may pass fake parent and get the information from the child table - if child_doctype and not frappe.db.exists('DocField', - {'parent': parent, 'options': child_doctype}): + if child_doctype and not ( + frappe.db.exists('DocField', {'parent': parent, 'options': child_doctype}) + or frappe.db.exists('Custom Field', {'dt': parent, 'options': child_doctype}) + ): raise frappe.PermissionError if frappe.permissions.has_permission(parent): return + # Either parent not passed or the user doesn't have permission on parent doctype of child table! raise frappe.PermissionError diff --git a/frappe/model/document.py b/frappe/model/document.py index bbba9b1492..9b1f9f0cf3 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -220,13 +220,13 @@ class Document(BaseDocument): self.set("__islocal", True) - self.check_permission("create") self._set_defaults() self.set_user_and_timestamp() self.set_docstatus() self.check_if_latest() - self.run_method("before_insert") self._validate_links() + self.check_permission("create") + self.run_method("before_insert") self.set_new_name(set_name=set_name, set_child_names=set_child_names) self.set_parent_in_children() self.validate_higher_perm_levels() @@ -301,8 +301,7 @@ class Document(BaseDocument): self.flags.ignore_version = frappe.flags.in_test if ignore_version is None else ignore_version if self.get("__islocal") or not self.get("name"): - self.insert() - return + return self.insert() self.check_permission("write", "save") diff --git a/frappe/model/rename_doc.py b/frappe/model/rename_doc.py index 1db89493f2..651153876a 100644 --- a/frappe/model/rename_doc.py +++ b/frappe/model/rename_doc.py @@ -32,11 +32,18 @@ def update_document_title(doctype, docname, title_field=None, old_title=None, ne return docname -def rename_doc(doctype, old, new, force=False, merge=False, ignore_permissions=False, ignore_if_exists=False, show_alert=True): - """ - Renames a doc(dt, old) to doc(dt, new) and - updates all linked fields of type "Link" - """ +def rename_doc( + doctype, + old, + new, + force=False, + merge=False, + ignore_permissions=False, + ignore_if_exists=False, + show_alert=True, + rebuild_search=True +): + """Rename a doc(dt, old) to doc(dt, new) and update all linked fields of type "Link".""" if not frappe.db.exists(doctype, old): return @@ -104,7 +111,8 @@ def rename_doc(doctype, old, new, force=False, merge=False, ignore_permissions=F frappe.delete_doc(doctype, old) frappe.clear_cache() - frappe.enqueue('frappe.utils.global_search.rebuild_for_doctype', doctype=doctype) + if rebuild_search: + frappe.enqueue('frappe.utils.global_search.rebuild_for_doctype', doctype=doctype) if show_alert: frappe.msgprint(_('Document renamed from {0} to {1}').format(bold(old), bold(new)), alert=True, indicator='green') @@ -492,7 +500,7 @@ def bulk_rename(doctype, rows=None, via_console = False): if len(row) > 1 and row[0] and row[1]: merge = len(row) > 2 and (row[2] == "1" or row[2].lower() == "true") try: - if rename_doc(doctype, row[0], row[1], merge=merge): + if rename_doc(doctype, row[0], row[1], merge=merge, rebuild_search=False): msg = _("Successful: {0} to {1}").format(row[0], row[1]) frappe.db.commit() else: diff --git a/frappe/patches.txt b/frappe/patches.txt index 1760a04f35..27ba1a145d 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -188,5 +188,5 @@ frappe.patches.v14_0.copy_mail_data #08.03.21 frappe.patches.v14_0.update_workspace2 # 20.09.2021 frappe.patches.v14_0.update_github_endpoints #08-11-2021 frappe.patches.v14_0.remove_db_aggregation -frappe.patches.v14_0.save_ratings_in_fraction +frappe.patches.v14_0.save_ratings_in_fraction #23-12-2021 frappe.patches.v14_0.update_color_names_in_kanban_board_column diff --git a/frappe/patches/v14_0/remove_db_aggregation.py b/frappe/patches/v14_0/remove_db_aggregation.py index 25a170f362..a8fa5b2ba1 100644 --- a/frappe/patches/v14_0/remove_db_aggregation.py +++ b/frappe/patches/v14_0/remove_db_aggregation.py @@ -27,6 +27,6 @@ def execute(): name, script = server_script["name"], server_script["script"] for agg in ["avg", "max", "min", "sum"]: - script = re.sub(f"frappe.db.{agg}(", f"frappe.qb.{agg}(", script) + script = re.sub(f"frappe.db.{agg}\(", f"frappe.qb.{agg}(", script) frappe.db.update("Server Script", name, "script", script) diff --git a/frappe/patches/v14_0/save_ratings_in_fraction.py b/frappe/patches/v14_0/save_ratings_in_fraction.py index bdc5dfee3d..c933179b44 100644 --- a/frappe/patches/v14_0/save_ratings_in_fraction.py +++ b/frappe/patches/v14_0/save_ratings_in_fraction.py @@ -1,12 +1,39 @@ import frappe +from frappe.query_builder import DocType + def execute(): - rating_fields = frappe.get_all("DocField", fields=["parent", "fieldname"], filters={"fieldtype": "Rating"}) + RATING_FIELD_TYPE = "decimal(3,2)" + rating_fields = frappe.get_all( + "DocField", fields=["parent", "fieldname"], filters={"fieldtype": "Rating"} + ) + + custom_rating_fields = frappe.get_all( + "Custom Field", fields=["dt", "fieldname"], filters={"fieldtype": "Rating"} + ) + + for _field in rating_fields + custom_rating_fields: + doctype_name = _field.get("parent") or _field.get("dt") + doctype = DocType(doctype_name) + field = _field.fieldname + + # TODO: Add postgres support (for the check) + if ( + frappe.conf.db_type == "mariadb" + and frappe.db.get_column_type(doctype_name, field) == RATING_FIELD_TYPE + ): + continue + + # commit any changes so far for upcoming DDL + frappe.db.commit() + + # alter column types for rating fieldtype + frappe.db.change_column_type(doctype_name, column=field, type=RATING_FIELD_TYPE, nullable=True) - custom_rating_fields = frappe.get_all("Custom Field", fields=["dt", "fieldname"], filters={"fieldtype": "Rating"}) + # update data: int => decimal + frappe.qb.update(doctype).set( + doctype[field], doctype[field] / 5 + ).run() - for field in rating_fields + custom_rating_fields: - doctype_name = field.get("parent") or field.get("dt") - doctype = frappe.qb.DocType(doctype_name) - field = field.fieldname - (frappe.qb.update(doctype_name).set(doctype[field], doctype[field]/5)).run() + # commit to flush updated rows + frappe.db.commit() diff --git a/frappe/printing/doctype/print_format/print_format.py b/frappe/printing/doctype/print_format/print_format.py index f19c0af9bf..74dc5460d9 100644 --- a/frappe/printing/doctype/print_format/print_format.py +++ b/frappe/printing/doctype/print_format/print_format.py @@ -71,8 +71,19 @@ class PrintFormat(Document): self.export_doc() + def after_rename(self, old: str, new: str, *args, **kwargs): + if self.doc_type: + frappe.clear_cache(doctype=self.doc_type) + + # update property setter default_print_format if set + frappe.db.set_value("Property Setter", { + "doctype_or_field": "DocType", + "doc_type": self.doc_type, + "property": "default_print_format", + "value": old, + }, "value", new) + def export_doc(self): - # export from frappe.modules.utils import export_module_json export_module_json(self, self.standard == 'Yes', self.module) diff --git a/frappe/printing/page/print/print.js b/frappe/printing/page/print/print.js index f10c703589..5d04fbe982 100644 --- a/frappe/printing/page/print/print.js +++ b/frappe/printing/page/print/print.js @@ -52,8 +52,8 @@ frappe.ui.form.PrintView = class { ':Print Settings', 'Print Settings' ); - this.setup_toolbar(); this.setup_menu(); + this.setup_toolbar(); this.setup_sidebar(); this.setup_keyboard_shortcuts(); } @@ -81,8 +81,9 @@ frappe.ui.form.PrintView = class { ); this.page.add_button( - frappe.utils.icon('refresh'), - () => this.refresh_print_format() + __('Refresh'), + () => this.refresh_print_format(), + { icon: 'refresh' } ); } diff --git a/frappe/public/js/frappe/data_import/import_preview.js b/frappe/public/js/frappe/data_import/import_preview.js index 786692e552..75bfb90bde 100644 --- a/frappe/public/js/frappe/data_import/import_preview.js +++ b/frappe/public/js/frappe/data_import/import_preview.js @@ -343,11 +343,11 @@ function get_fields_as_options(doctype, column_map) { return [].concat( ...keys.map(key => { return column_map[key].map(df => { - let label = df.label; + let label = __(df.label); let value = df.fieldname; if (doctype !== key) { let table_field = frappe.meta.get_docfield(doctype, key); - label = `${df.label} (${table_field.label})`; + label = `${__(df.label)} (${__(table_field.label)})`; value = `${table_field.fieldname}.${df.fieldname}`; } return { diff --git a/frappe/public/js/frappe/form/controls/datetime.js b/frappe/public/js/frappe/form/controls/datetime.js index 5d0ecb9fe7..d1a06a6ac6 100644 --- a/frappe/public/js/frappe/form/controls/datetime.js +++ b/frappe/public/js/frappe/form/controls/datetime.js @@ -81,6 +81,9 @@ frappe.ui.form.ControlDatetime = class ControlDatetime extends frappe.ui.form.Co get_model_value() { let value = super.get_model_value(); + if (!value && !this.doc) { + value = this.last_value; + } return frappe.datetime.get_datetime_as_string(value); } }; diff --git a/frappe/public/js/frappe/form/controls/link.js b/frappe/public/js/frappe/form/controls/link.js index 95e53394b5..3f1a44805a 100644 --- a/frappe/public/js/frappe/form/controls/link.js +++ b/frappe/public/js/frappe/form/controls/link.js @@ -456,8 +456,7 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat this.docname, value); } validate_link_and_fetch(df, options, docname, value) { - if (!value) return; - + let field_value = ""; const fetch_map = this.fetch_map; const columns_to_fetch = Object.values(fetch_map); @@ -471,15 +470,16 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat docname: value, fields: columns_to_fetch, }).then((response) => { - if (!response || !response.name) return null; if (!docname || !columns_to_fetch.length) return response.name; for (const [target_field, source_field] of Object.entries(fetch_map)) { + if (value) field_value = response[source_field]; + frappe.model.set_value( df.parent, docname, target_field, - response[source_field], + field_value, df.fieldtype, ); } diff --git a/frappe/public/js/frappe/form/controls/rating.js b/frappe/public/js/frappe/form/controls/rating.js index fa7b65f67b..981168457a 100644 --- a/frappe/public/js/frappe/form/controls/rating.js +++ b/frappe/public/js/frappe/form/controls/rating.js @@ -4,8 +4,9 @@ frappe.ui.form.ControlRating = class ControlRating extends frappe.ui.form.Contro let stars = ''; let number_of_stars = this.df.options || 5; Array.from({length: cint(number_of_stars)}, (_, i) => i + 1).forEach(i => { - stars += ` - + stars += ` + + `; }); @@ -17,45 +18,56 @@ frappe.ui.form.ControlRating = class ControlRating extends frappe.ui.form.Contro $(this.input_area).html(star_template); - $(this.input_area).find('svg').hover((ev) => { - const el = $(ev.currentTarget); - let star_value = el.data('rating'); - el.parent().children('svg').each( function(e) { - if (e < star_value) { - $(this).addClass('star-hover'); - } else { - $(this).removeClass('star-hover'); - } - }); - }, (ev) => { + let me = this; + $(this.input_area).find('svg').on('mousemove', function(ev) { + me.update_rating(ev); + }).on('mouseout', function(ev) { const el = $(ev.currentTarget); el.parent().children('svg').each( function() { - $(this).removeClass('star-hover'); + $(this).find('.left-half, .right-half').removeClass('star-hover'); }); }); $(this.input_area).find('svg').click((ev) => { - const el = $(ev.currentTarget); - let star_value = el.data('rating'); - el.parent().children('svg').each( function(e) { - if (e < star_value) { - $(this).addClass('star-click'); - } else { - $(this).removeClass('star-click'); - } - }); - let out_of_ratings = this.df.options || 5; + this.update_rating(ev, true); + }); + } + update_rating(ev, click) { + const el = $(ev.currentTarget); + let star_value = el.data('rating'); + let left_half = false; + let cls = 'star-click'; + if (!click) cls = 'star-hover'; + + if ((ev.pageX - el.offset().left) < el.width() / 2) { + left_half = true; + star_value--; + } + el.parent().children('svg').each( function(e) { + if (e < star_value) { + $(this).find('.left-half, .right-half').addClass(cls); + } else if (e == star_value && left_half) { + $(this).find('.left-half').addClass(cls); + $(this).find('.right-half').removeClass(cls); + if (click) star_value += 0.5; + } else { + $(this).find('.left-half, .right-half').removeClass(cls); + } + }); + if (click) { + let out_of_ratings = this.df.options || 5; star_value = star_value/out_of_ratings; + this.validate_and_set_in_model(star_value, ev); if (this.doctype && this.docname) { this.set_input(star_value); } - }); + } } + get_value() { - let out_of_ratings = this.df.options || 5; - return cint(this.value*out_of_ratings, null); + return this.value; } set_formatted_input(value) { let out_of_ratings = this.df.options || 5; @@ -63,9 +75,12 @@ frappe.ui.form.ControlRating = class ControlRating extends frappe.ui.form.Contro let el = $(this.input_area).find('svg'); el.children('svg').prevObject.each( function(e) { if (e < value) { - $(this).addClass('star-click'); + $(this).find('.left-half, .right-half').addClass('star-click'); + + let is_half = e == Math.floor(value) && value % 1 == 0.5; + is_half && $(this).find('.right-half').removeClass('star-click'); } else { - $(this).removeClass('star-click'); + $(this).find('.left-half, .right-half').removeClass('star-click'); } }); } diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index 8af1631b48..9a75e510da 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -190,7 +190,7 @@ frappe.ui.form.Form = class FrappeForm { setup_std_layout() { this.form_wrapper = $('
').appendTo(this.layout_main); - this.body = $('
').appendTo(this.form_wrapper); + this.body = $('
').appendTo(this.form_wrapper); // only tray this.meta.section_style='Simple'; // always simple! @@ -211,12 +211,24 @@ frappe.ui.form.Form = class FrappeForm { this.fields = this.layout.fields_list; let dashboard_parent = $('
'); + let dashboard_added = false; if (this.layout.tabs.length) { - this.layout.tabs[0].wrapper.prepend(dashboard_parent); + this.layout.tabs.every(tab => { + if (tab.df.options === 'Dashboard') { + tab.wrapper.prepend(dashboard_parent); + dashboard_added = true; + return false; + } + return true; + }); + if (!dashboard_added) { + this.layout.tabs[0].wrapper.prepend(dashboard_parent); + } } else { - dashboard_parent.insertAfter(this.layout.wrapper.find('.form-message')); + this.layout.wrapper.find('.form-page').prepend(dashboard_parent); } + this.dashboard = new frappe.ui.form.Dashboard(dashboard_parent, this); this.tour = new frappe.ui.form.FormTour({ diff --git a/frappe/public/js/frappe/form/formatters.js b/frappe/public/js/frappe/form/formatters.js index 851fbe3423..fd3fcb1bc7 100644 --- a/frappe/public/js/frappe/form/formatters.js +++ b/frappe/public/js/frappe/form/formatters.js @@ -63,12 +63,16 @@ frappe.form.formatters = { ); return frappe.form.formatters._right(flt(value, precision) + "%", options); }, - Rating: function(value) { - const rating_html = `${[1, 2, 3, 4, 5].map(i => - ` - - ` - ).join('')}`; + Rating: function(value, docfield) { + let rating_html = ''; + let number_of_stars = docfield.options || 5; + value = value * number_of_stars; + Array.from({length: cint(number_of_stars)}, (_, i) => i + 1).forEach(i => { + rating_html += ` + + + `; + }); return `
${rating_html}
`; diff --git a/frappe/public/js/frappe/form/multi_select_dialog.js b/frappe/public/js/frappe/form/multi_select_dialog.js index 161e4196b0..bc0286e62d 100644 --- a/frappe/public/js/frappe/form/multi_select_dialog.js +++ b/frappe/public/js/frappe/form/multi_select_dialog.js @@ -12,7 +12,7 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { init() { this.page_length = 20; - this.start = 0; + this.child_page_length = 20; this.fields = this.get_fields(); this.make(); @@ -29,7 +29,7 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { get_result_fields() { const show_next_page = () => { - this.start += 20; + this.page_length += 20; this.get_results(); }; return [ @@ -58,7 +58,15 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { get_child_selection_fields() { const fields = []; if (this.allow_child_item_selection && this.child_fieldname) { + const show_more_child_results = () => { + this.child_page_length += 20; + this.show_child_results(); + }; fields.push({ fieldtype: "HTML", fieldname: "child_selection_area" }); + fields.push({ + fieldtype: "Button", fieldname: "more_child_btn", hidden: 1, + label: __("More"), click: show_more_child_results.bind(this) + }); } return fields; } @@ -124,23 +132,27 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { setup_results() { this.$parent = $(this.dialog.body); - this.$wrapper = this.dialog.fields_dict.results_area.$wrapper.append(`
`); this.$results = this.$wrapper.find('.results'); this.$results.append(this.make_list_row()); } + show_child_results() { + this.get_child_result().then(r => { + this.child_results = r.message || []; + this.render_child_datatable(); + + this.$wrapper.addClass('hidden'); + this.$child_wrapper.removeClass('hidden'); + this.dialog.fields_dict.more_btn.$wrapper.hide(); + }); + } + toggle_child_selection() { if (this.dialog.fields_dict['allow_child_item_selection'].get_value()) { - this.get_child_result().then(r => { - this.child_results = r.message || []; - this.render_child_datatable(); - - this.$wrapper.addClass('hidden'); - this.$child_wrapper.removeClass('hidden'); - this.dialog.fields_dict.more_btn.$wrapper.hide(); - }); + this.show_child_results(); } else { this.child_results = []; this.get_results(); @@ -157,6 +169,7 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { this.child_datatable.rowmanager.checkMap = []; this.child_datatable.refresh(this.get_child_datatable_rows()); this.$child_wrapper.find('.dt-scrollable').css('height', '300px'); + this.$child_wrapper.find('.dt-scrollable').css('overflow-y', 'scroll'); }, 500); } } @@ -167,14 +180,21 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { } get_child_datatable_rows() { - return this.child_results.map(d => Object.values(d).slice(1)); // slice name field + if (this.child_results.length > this.child_page_length) { + this.dialog.fields_dict.more_child_btn.toggle(true); + } else { + this.dialog.fields_dict.more_child_btn.toggle(false); + } + return this.child_results + .slice(0, this.child_page_length) + .map(d => Object.values(d).slice(1)); // slice name field } setup_child_datatable() { const header_columns = this.get_child_datatable_columns(); const rows = this.get_child_datatable_rows(); this.$child_wrapper = this.dialog.fields_dict.child_selection_area.$wrapper; - this.$child_wrapper.addClass('mt-3'); + this.$child_wrapper.addClass('my-3'); this.child_datatable = new frappe.DataTable(this.$child_wrapper.get(0), { columns: header_columns, @@ -412,7 +432,7 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { this.empty_list(); } more_btn.hide(); - $(".modal-dialog .list-item--head").css("z-index", 0); + $(".modal-dialog .list-item--head").css("z-index", 1); if (results.length === 0) return; if (more) more_btn.show(); @@ -425,7 +445,7 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { me.$results.append(me.make_list_row(result)); }); - this.$results.find(".list-item--head").css("z-index", 0); + this.$results.find(".list-item--head").css("z-index", 1); if (frappe.flags.auto_scroll) { this.$results.animate({ scrollTop: me.$results.prop('scrollHeight') }, 500); @@ -486,8 +506,7 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { txt: this.dialog.fields_dict["search_term"].get_value(), filters: filters, filter_fields: filter_fields, - start: this.start, - page_length: this.page_length + 1, + page_length: this.page_length + 5, query: this.get_query ? this.get_query().query : '', as_dict: 1 }; @@ -501,9 +520,6 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { args: args, }); const more = res.values.length && res.values.length > this.page_length ? 1 : 0; - if (more) { - res.values.pop(); - } return [res, more]; } @@ -512,6 +528,10 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { const args = this.get_args_for_search(); const [res, more] = await this.perform_search(args); + if (more) { + res.values = res.values.splice(0, this.page_length); + } + this.results = []; if (res.values.length) { res.values.forEach(result => { @@ -565,6 +585,7 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { filters: filters, fields: ['name', 'parent', ...this.child_columns], parent: this.doctype, + limit_page_length: this.child_page_length + 5, order_by: 'parent' } }); diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index b5bacd753b..938531865d 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -760,6 +760,11 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { : value; } + if (df.fieldtype === "Rating") { + let out_of_ratings = df.options || 5; + _value = _value * out_of_ratings; + } + if (df.fieldtype === "Image") { html = df.options ? `` @@ -1967,12 +1972,6 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { if (!doctype) return; frappe.provide("frappe.views.trees"); - // refresh tree view - if (frappe.views.trees[doctype]) { - frappe.views.trees[doctype].tree.refresh(); - return; - } - // refresh list view const page_name = frappe.get_route_str(); const list_view = frappe.views.list_view[page_name]; diff --git a/frappe/public/js/frappe/ui/page.js b/frappe/public/js/frappe/ui/page.js index c299edb7db..91a2390cdb 100644 --- a/frappe/public/js/frappe/ui/page.js +++ b/frappe/public/js/frappe/ui/page.js @@ -714,6 +714,10 @@ frappe.ui.Page = class Page { ${opts.icon ? frappe.utils.icon(opts.icon): ''} ${label} `); + // Add actions as menu item in Mobile View (similar to "add_custom_button" in forms.js) + let menu_item = this.add_menu_item(label, click, false); + menu_item.parent().addClass("hidden-xl"); + button.appendTo(this.custom_actions); button.on('click', click); this.custom_actions.removeClass('hide'); diff --git a/frappe/public/js/frappe/ui/tree.js b/frappe/public/js/frappe/ui/tree.js index 4b11b092eb..c32d92aa32 100644 --- a/frappe/public/js/frappe/ui/tree.js +++ b/frappe/public/js/frappe/ui/tree.js @@ -299,7 +299,6 @@ frappe.ui.Tree = class { .appendTo($toolbar); $link.on('click', () => { obj.click(node); - this.refresh(); }); }); diff --git a/frappe/public/js/frappe/utils/address_and_contact.js b/frappe/public/js/frappe/utils/address_and_contact.js index 3b05e5f0bb..61339d4e24 100644 --- a/frappe/public/js/frappe/utils/address_and_contact.js +++ b/frappe/public/js/frappe/utils/address_and_contact.js @@ -29,7 +29,7 @@ $.extend(frappe.contacts, { } }, get_last_doc: function(frm) { - const reverse_routes = frappe.route_history.reverse(); + const reverse_routes = frappe.route_history.slice().reverse(); const last_route = reverse_routes.find(route => { return route[0] === 'Form' && route[1] !== frm.doctype }) diff --git a/frappe/public/js/frappe/utils/user.js b/frappe/public/js/frappe/utils/user.js index 3eb73b21e5..f22611b515 100644 --- a/frappe/public/js/frappe/utils/user.js +++ b/frappe/public/js/frappe/utils/user.js @@ -11,9 +11,7 @@ frappe.user_info = function(uid) { } if(!(frappe.boot.user_info && frappe.boot.user_info[uid])) { - var user_info = { - fullname: frappe.utils.to_title_case(uid.split("@")[0]) || "Unknown" - }; + var user_info = {fullname: uid || "Unknown"}; } else { var user_info = frappe.boot.user_info[uid]; } @@ -157,4 +155,4 @@ $(document).bind('mousemove', function() { if(frappe.session_alive_timeout) clearTimeout(frappe.session_alive_timeout); frappe.session_alive_timeout = setTimeout('frappe.session_alive=false;', 30000); -}); \ No newline at end of file +}); diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js index 8b374c44fd..a3a8f96b11 100644 --- a/frappe/public/js/frappe/utils/utils.js +++ b/frappe/public/js/frappe/utils/utils.js @@ -28,7 +28,8 @@ Object.defineProperty(Object.prototype, "setDefault", { value: function(key, default_value) { if (!(key in this)) this[key] = default_value; return this[key]; - } + }, + writable: true }); // Pluralize diff --git a/frappe/public/js/frappe/views/container.js b/frappe/public/js/frappe/views/container.js index cf1d6c9466..f8cea81fa9 100644 --- a/frappe/public/js/frappe/views/container.js +++ b/frappe/public/js/frappe/views/container.js @@ -40,11 +40,6 @@ frappe.views.Container = class Container { } change_to(label) { cur_page = this; - if(this.page && this.page.label === label) { - $(this.page).trigger('show'); - } - - var me = this; if(label.tagName) { // if sent the div, get the table var page = label; diff --git a/frappe/public/js/frappe/views/treeview.js b/frappe/public/js/frappe/views/treeview.js index 30da212f0a..cc0a233003 100644 --- a/frappe/public/js/frappe/views/treeview.js +++ b/frappe/public/js/frappe/views/treeview.js @@ -242,6 +242,7 @@ frappe.views.TreeView = class TreeView { frappe.model.rename_doc(me.doctype, node.label, function(new_name) { node.$tree_link.find('a').text(new_name); node.label = new_name; + me.tree.refresh(); }); }, btnClass: "hidden-xs" @@ -317,10 +318,7 @@ frappe.views.TreeView = class TreeView { args: args, callback: function(r) { if(!r.exc) { - if(node.expanded) { - me.tree.toggle_node(node); - } - me.tree.load_children(node, true); + me.tree.load_children(node); } }, always: function() { diff --git a/frappe/public/scss/common/form.scss b/frappe/public/scss/common/form.scss index 2fa6ab619e..43ba477624 100644 --- a/frappe/public/scss/common/form.scss +++ b/frappe/public/scss/common/form.scss @@ -1,4 +1,5 @@ .form-control { + height: inherit; border: none; font-size: var(--text-md); position: relative; @@ -13,10 +14,9 @@ font-weight: normal; font-size: var(--text-sm); } - min-height: var(--input-height); border-radius: $border-radius; font-weight: 400; - padding: 8px 12px; + padding: 6px 12px; cursor: default; color: var(--disabled-text-color); background-color: var(--disabled-control-bg); diff --git a/frappe/public/scss/common/grid.scss b/frappe/public/scss/common/grid.scss index 3014211222..1903413fbb 100644 --- a/frappe/public/scss/common/grid.scss +++ b/frappe/public/scss/common/grid.scss @@ -79,10 +79,9 @@ .grid-static-col, .row-index { - height: 39px; - padding: var(--padding-sm) var(--padding-md); + height: 34px; + padding: 8px; max-height: 200px; - // border-right: 1px solid var(--border-color); } .grid-row-check { @@ -108,6 +107,7 @@ .grid-row > .row { .col:last-child { margin-right: calc(-1 * var(--margin-sm)); + border-right: none; } .col { @@ -149,7 +149,7 @@ } textarea { - height: 40px !important; + height: 37px !important; } .form-control { @@ -157,7 +157,7 @@ border: 0px; padding-top: 8px; padding-bottom: 9px; - height: 40px; + height: 34px; } .link-btn { @@ -196,6 +196,10 @@ } } + .grid-static-col[data-fieldtype="Check"] .static-area { + padding-top: 2px; + } + .grid-static-col[data-fieldtype="Rating"] .field-area { margin-top: 1rem; margin-left: 1rem; diff --git a/frappe/public/scss/desk/form.scss b/frappe/public/scss/desk/form.scss index d48987b500..7e7d6170c9 100644 --- a/frappe/public/scss/desk/form.scss +++ b/frappe/public/scss/desk/form.scss @@ -1,6 +1,12 @@ @import "../common/form.scss"; @import '~cropperjs/dist/cropper.min'; +.std-form-layout > .form-layout > .form-page { + border-radius: var(--border-radius-md); + box-shadow: var(--card-shadow); + background-color: var(--card-bg); +} + .form-section, .form-dashboard-section { margin: 0px; @@ -12,6 +18,7 @@ .section-head { @extend .head-title; + font-size: var(--text-base); width: 100%; padding: var(--padding-sm) var(--padding-md); margin: 0; @@ -47,8 +54,12 @@ .form-section.card-section, .form-dashboard-section { - margin-bottom: var(--margin-lg); - @extend .frappe-card; + border-bottom: 1px solid var(--gray-200); + padding: var(--padding-xs); +} + +.row.form-section.card-section.visible-section:last-child { + border-bottom: none; } .form-dashboard-section { @@ -57,9 +68,8 @@ } .section-body { display: block; - padding-left: var(--padding-md); - padding-right: var(--padding-md); - padding-bottom: var(--padding-md); + padding: var(--padding-md); + padding-top: 0; } } @@ -85,7 +95,8 @@ .comment-box { @include card(); - padding: 25px var(--padding-xl); + margin-top: var(--margin-lg); + padding: var(--padding-lg); .comment-input-header { @extend .head-title; margin-bottom: var(--margin-sm); @@ -304,19 +315,18 @@ } .form-tabs-list { - margin-bottom: var(--margin-lg); + padding-left: var(--padding-xs); + border-bottom: 1px solid var(--gray-200); .form-tabs { .nav-item { .nav-link { - padding-bottom: var(--padding-md); color: var(--gray-700); - padding-left: 0; - padding-right: 0; - margin-right: var(--margin-xl); + padding: var(--padding-md) 0; + margin: 0 var(--margin-md); &.active { - font-weight: 500; + font-weight: 600; border-bottom: 1px solid var(--primary); color: var(--text-color); } diff --git a/frappe/public/scss/desk/page.scss b/frappe/public/scss/desk/page.scss index 339dd755d0..91bd942889 100644 --- a/frappe/public/scss/desk/page.scss +++ b/frappe/public/scss/desk/page.scss @@ -156,26 +156,29 @@ .result, .no-result, .freeze { min-height: #{"calc(100vh - 284px)"}; } + } - .msg-box { - margin-bottom: 4em; - font-size: var(--text-sm); + .msg-box { + margin-bottom: 4em; + font-size: var(--text-sm); - // To compensate for perceived centering - .null-state { - height: 85px; - width: auto; - margin-bottom: var(--margin-md); - img { - fill: var(--fg-color); - } + // To compensate for perceived centering + .null-state { + height: 85px; + width: auto; + margin-bottom: var(--margin-md); + img { + fill: var(--fg-color); } + } + p { + font-size: var(--text-md); + } - .meta-description { - width: 45%; - margin-right: auto; - margin-left: auto; - } + .meta-description { + width: 45%; + margin-right: auto; + margin-left: auto; } } } diff --git a/frappe/public/scss/print.bundle.scss b/frappe/public/scss/print.bundle.scss index 6ef31c06c8..7dbaf5b496 100644 --- a/frappe/public/scss/print.bundle.scss +++ b/frappe/public/scss/print.bundle.scss @@ -1,13 +1,14 @@ @import "frappe/public/css/bootstrap.css"; -@import './common/quill'; +@import "./common/quill"; @import "./desk/css_variables"; - -// .print-format { -// .ql-snow .ql-editor { -// height: auto; -// min-height: 0; -// // max-height: 0; -// } -// } - +// !! PDF Barcode hack !! +// Workaround for rendering barcodes prior to https://github.com/frappe/frappe/pull/15307 +@media print { + svg[data-barcode-value] > rect { + fill: white !important; + } + svg[data-barcode-value] > g { + fill: black !important; + } +} diff --git a/frappe/templates/signup.html b/frappe/templates/signup.html new file mode 100644 index 0000000000..abc1328e25 --- /dev/null +++ b/frappe/templates/signup.html @@ -0,0 +1,22 @@ +
+
+
+ + +
+
+ + +
+
+
+ + + +
+
diff --git a/frappe/tests/test_db.py b/frappe/tests/test_db.py index 5db56e5046..dec55b4714 100644 --- a/frappe/tests/test_db.py +++ b/frappe/tests/test_db.py @@ -246,6 +246,28 @@ class TestDB(unittest.TestCase): clear_custom_fields(test_doctype) + def test_savepoints(self): + frappe.db.rollback() + save_point = "todonope" + + created_docs = [] + failed_docs = [] + + for _ in range(5): + frappe.db.savepoint(save_point) + doc_gone = frappe.get_doc(doctype="ToDo", description="nope").save() + failed_docs.append(doc_gone.name) + frappe.db.rollback(save_point=save_point) + doc_kept = frappe.get_doc(doctype="ToDo", description="nope").save() + created_docs.append(doc_kept.name) + frappe.db.commit() + + for d in failed_docs: + self.assertFalse(frappe.db.exists("ToDo", d)) + for d in created_docs: + self.assertTrue(frappe.db.exists("ToDo", d)) + + @run_only_if(db_type_is.MARIADB) class TestDDLCommandsMaria(unittest.TestCase): test_table_name = "TestNotes" @@ -368,4 +390,4 @@ class TestDDLCommandsPost(unittest.TestCase): AND indexname = '{index_name}' ; """, ) - self.assertEquals(len(indexs_in_table), 1) \ No newline at end of file + self.assertEquals(len(indexs_in_table), 1) diff --git a/frappe/tests/test_fixture_import.py b/frappe/tests/test_fixture_import.py new file mode 100644 index 0000000000..2fe7e40d0d --- /dev/null +++ b/frappe/tests/test_fixture_import.py @@ -0,0 +1,81 @@ +import os +import unittest +from typing import List + +import frappe +from frappe.core.doctype.data_import.data_import import export_json, import_doc +from frappe.desk.form.save import savedocs +from frappe.model.delete_doc import delete_doc + + +class TestFixtureImport(unittest.TestCase): + def create_new_doctype(self, DocType: str) -> None: + file = frappe.get_app_path("frappe", "custom", "fixtures", f"{DocType}.json") + + file = open(file, "r") + doc = file.read() + file.close() + + savedocs(doc, "Save") + + def insert_dummy_data_and_export(self, DocType: str, dummy_name_list: List[str]) -> str: + for name in dummy_name_list: + doc = frappe.get_doc({"doctype": DocType, "member_name": name}) + doc.insert() + + path_to_exported_fixtures = os.path.join(os.getcwd(), f"{DocType}_data.json") + + export_json(DocType, path_to_exported_fixtures) + + return path_to_exported_fixtures + + def test_fixtures_import(self): + self.assertFalse(frappe.db.exists("DocType", "temp_doctype")) + + self.create_new_doctype("temp_doctype") + + dummy_name_list = ["jhon", "jane"] + path_to_exported_fixtures = self.insert_dummy_data_and_export("temp_doctype", dummy_name_list) + frappe.db.truncate("temp_doctype") + + import_doc(path_to_exported_fixtures) + + delete_doc("DocType", "temp_doctype", delete_permanently=True) + os.remove(path_to_exported_fixtures) + + self.assertEqual(frappe.db.count("temp_doctype"), len(dummy_name_list)) + + data = frappe.get_all("temp_doctype", "member_name") + frappe.db.truncate("temp_doctype") + + imported_data = set() + for item in data: + imported_data.add(item["member_name"]) + + self.assertEqual(set(dummy_name_list), imported_data) + + def test_singles_fixtures_import(self): + self.assertFalse(frappe.db.exists("DocType", "temp_singles")) + + self.create_new_doctype("temp_singles") + + dummy_name_list = ["Phoebe"] + path_to_exported_fixtures = self.insert_dummy_data_and_export("temp_singles", dummy_name_list) + + singles_doctype = frappe.qb.DocType("Singles") + truncate_query = ( + frappe.qb.from_(singles_doctype) + .delete() + .where(singles_doctype.doctype == "temp_singles") + ) + truncate_query.run() + + import_doc(path_to_exported_fixtures) + + delete_doc("DocType", "temp_singles", delete_permanently=True) + os.remove(path_to_exported_fixtures) + + data = frappe.db.get_single_value("temp_singles", "member_name") + truncate_query.run() + + self.assertEqual(data, dummy_name_list[0]) diff --git a/frappe/tests/test_safe_exec.py b/frappe/tests/test_safe_exec.py index 783d05ff37..7fec292c49 100644 --- a/frappe/tests/test_safe_exec.py +++ b/frappe/tests/test_safe_exec.py @@ -31,4 +31,27 @@ class TestSafeExec(unittest.TestCase): self.assertEqual(frappe.db.sql("SELECT Max(name) FROM tabUser"), _locals["out"]) def test_safe_query_builder(self): - self.assertRaises(frappe.PermissionError, safe_exec, '''frappe.qb.from_("User").delete().run()''') \ No newline at end of file + self.assertRaises(frappe.PermissionError, safe_exec, '''frappe.qb.from_("User").delete().run()''') + + def test_call(self): + # call non whitelisted method + self.assertRaises( + frappe.PermissionError, + safe_exec, + """frappe.call("frappe.get_user")""" + ) + + # call whitelisted method + safe_exec("""frappe.call("ping")""") + + + def test_enqueue(self): + # enqueue non whitelisted method + self.assertRaises( + frappe.PermissionError, + safe_exec, + """frappe.enqueue("frappe.get_user", now=True)""" + ) + + # enqueue whitelisted method + safe_exec("""frappe.enqueue("ping", now=True)""") diff --git a/frappe/tests/test_translate.py b/frappe/tests/test_translate.py index 949e4f9d77..1b96fb62c3 100644 --- a/frappe/tests/test_translate.py +++ b/frappe/tests/test_translate.py @@ -14,7 +14,9 @@ from frappe.utils import set_request dirname = os.path.dirname(__file__) translation_string_file = os.path.join(dirname, 'translation_test_file.txt') first_lang, second_lang, third_lang, fourth_lang, fifth_lang = choices( - frappe.get_all("Language", pluck="name"), k=5 + # skip "en*" since it is a default language + frappe.get_all("Language", pluck="name", filters=[["name", "not like", "en%"]]), + k=5 ) class TestTranslate(unittest.TestCase): diff --git a/frappe/utils/boilerplate.py b/frappe/utils/boilerplate.py index 91f7dbb2f8..6c405ce467 100755 --- a/frappe/utils/boilerplate.py +++ b/frappe/utils/boilerplate.py @@ -203,6 +203,12 @@ app_license = "{app_license}" # before_install = "{app_name}.install.before_install" # after_install = "{app_name}.install.after_install" +# Uninstallation +# ------------ + +# before_uninstall = "{app_name}.uninstall.before_uninstall" +# after_uninstall = "{app_name}.uninstall.after_uninstall" + # Desk Notifications # ------------------ # See frappe.core.notifications.get_notification_config diff --git a/frappe/utils/data.py b/frappe/utils/data.py index de0242df07..206f0eac64 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -507,10 +507,10 @@ def get_timespan_date_range(timespan): "yesterday": lambda: (add_to_date(today, days=-1),) * 2, "today": lambda: (today, today), "tomorrow": lambda: (add_to_date(today, days=1),) * 2, - "this week": lambda: (get_first_day_of_week(today), today), - "this month": lambda: (get_first_day(today), today), - "this quarter": lambda: (get_quarter_start(today), today), - "this year": lambda: (get_year_start(today), today), + "this week": lambda: (get_first_day_of_week(today), get_last_day_of_week(today)), + "this month": lambda: (get_first_day(today), get_last_day(today)), + "this quarter": lambda: (get_quarter_start(today), get_quarter_ending(today)), + "this year": lambda: (get_year_start(today), get_year_ending(today)), "next week": lambda: (get_first_day_of_week(add_to_date(today, days=7)), get_last_day_of_week(add_to_date(today, days=7))), "next month": lambda: (get_first_day(add_to_date(today, months=1)), get_last_day(add_to_date(today, months=1))), "next quarter": lambda: (get_quarter_start(add_to_date(today, months=3)), get_quarter_ending(add_to_date(today, months=3))), diff --git a/frappe/utils/file_manager.py b/frappe/utils/file_manager.py index 56394442f3..1e654d7881 100644 --- a/frappe/utils/file_manager.py +++ b/frappe/utils/file_manager.py @@ -1,4 +1,4 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE import frappe @@ -17,6 +17,20 @@ class MaxFileSizeReachedError(frappe.ValidationError): pass +def safe_b64decode(binary: bytes) -> bytes: + """Adds padding if doesn't already exist before decoding. + + This attempts to avoid the `binascii.Error: Incorrect padding` error raised + when the number of trailing = is simply not enough :crie:. Although, it may + be an indication of corrupted data. + + Refs: + * https://en.wikipedia.org/wiki/Base64 + * https://stackoverflow.com/questions/2941995/python-ignore-incorrect-padding-error-when-base64-decoding + """ + return base64.b64decode(binary + b"===") + + def get_file_url(file_data_name): data = frappe.db.get_value("File", file_data_name, ["file_name", "file_url"], as_dict=True) return data.file_url or data.file_name @@ -112,7 +126,7 @@ def get_uploaded_content(): if 'filedata' in frappe.form_dict: if "," in frappe.form_dict.filedata: frappe.form_dict.filedata = frappe.form_dict.filedata.rsplit(",", 1)[1] - frappe.uploaded_content = base64.b64decode(frappe.form_dict.filedata) + frappe.uploaded_content = safe_b64decode(frappe.form_dict.filedata) frappe.uploaded_filename = frappe.form_dict.filename return frappe.uploaded_filename, frappe.uploaded_content else: @@ -126,7 +140,7 @@ def save_file(fname, content, dt, dn, folder=None, decode=False, is_private=0, d if b"," in content: content = content.split(b",")[1] - content = base64.b64decode(content) + content = safe_b64decode(content) file_size = check_max_file_size(content) content_hash = get_content_hash(content) diff --git a/frappe/utils/safe_exec.py b/frappe/utils/safe_exec.py index f51efefc85..2042c1f2ce 100644 --- a/frappe/utils/safe_exec.py +++ b/frappe/utils/safe_exec.py @@ -14,11 +14,12 @@ import frappe.integrations.utils import frappe.utils import frappe.utils.data from frappe import _ +from frappe.handler import execute_cmd from frappe.frappeclient import FrappeClient from frappe.modules import scrub from frappe.website.utils import get_next_link, get_shade, get_toc from frappe.www.printview import get_visible_columns - +from frappe.utils.background_jobs import enqueue, get_jobs class ServerScriptNotEnabled(frappe.PermissionError): pass @@ -74,7 +75,9 @@ def get_safe_globals(): add_data_utils(datautils) - if "_" in getattr(frappe.local, 'form_dict', {}): + form_dict = getattr(frappe.local, 'form_dict', frappe._dict()) + + if "_" in form_dict: del frappe.local.form_dict["_"] user = getattr(frappe.local, "session", None) and frappe.local.session.user or "Guest" @@ -89,14 +92,16 @@ def get_safe_globals(): dict=dict, log=frappe.log, _dict=frappe._dict, + args=form_dict, frappe=NamespaceDict( + call=call_whitelisted_function, flags=frappe._dict(), format=frappe.format_value, format_value=frappe.format_value, date_format=date_format, time_format=time_format, format_date=frappe.utils.data.global_date_format, - form_dict=getattr(frappe.local, 'form_dict', {}), + form_dict=form_dict, bold=frappe.bold, copy_doc=frappe.copy_doc, errprint=frappe.errprint, @@ -132,6 +137,7 @@ def get_safe_globals(): make_post_request=frappe.integrations.utils.make_post_request, socketio_port=frappe.conf.socketio_port, get_hooks=get_hooks, + enqueue=safe_enqueue, sanitize_html=frappe.utils.sanitize_html, log_error=frappe.log_error ), @@ -147,7 +153,8 @@ def get_safe_globals(): guess_mimetype=mimetypes.guess_type, html2text=html2text, dev_server=1 if frappe._dev_server else 0, - run_script=run_script + run_script=run_script, + is_job_queued=is_job_queued, ) add_module_properties(frappe.exceptions, out.frappe, lambda obj: inspect.isclass(obj) and issubclass(obj, Exception)) @@ -190,6 +197,55 @@ def get_safe_globals(): return out +def is_job_queued(job_name, queue="default"): + ''' + :param job_name: used to identify a queued job, usually dotted path to function + :param queue: should be either long, default or short + ''' + + site = frappe.local.site + queued_jobs = get_jobs(site=site, queue=queue, key='job_name').get(site) + return queued_jobs and job_name in queued_jobs + +def safe_enqueue(function, **kwargs): + ''' + Enqueue function to be executed using a background worker + Accepts frappe.enqueue params like job_name, queue, timeout, etc. + in addition to params to be passed to function + + :param function: whitelised function or API Method set in Server Script + ''' + + return enqueue( + 'frappe.utils.safe_exec.call_whitelisted_function', + function=function, + **kwargs + ) + +def call_whitelisted_function(function, **kwargs): + '''Executes a whitelisted function or Server Script of type API''' + + return call_with_form_dict(lambda: execute_cmd(function), kwargs) + +def run_script(script, **kwargs): + '''run another server script''' + + return call_with_form_dict( + lambda: frappe.get_doc('Server Script', script).execute_method(), + kwargs + ) + +def call_with_form_dict(function, kwargs): + # temporarily update form_dict, to use inside below call + form_dict = getattr(frappe.local, 'form_dict', frappe._dict()) + if kwargs: + frappe.local.form_dict = form_dict.copy().update(kwargs) + + try: + return function() + finally: + frappe.local.form_dict = form_dict + def get_python_builtins(): return { 'abs': abs, @@ -221,9 +277,6 @@ def read_sql(query, *args, **kwargs): raise frappe.PermissionError('Only SELECT SQL allowed in scripting') return frappe.db.sql(query, *args, **kwargs) -def run_script(script): - '''run another server script''' - return frappe.get_doc('Server Script', script).execute_method() def _getitem(obj, key): # guard function for RestrictedPython diff --git a/frappe/www/404.html b/frappe/www/404.html index c03b5d3e96..2596741c7a 100644 --- a/frappe/www/404.html +++ b/frappe/www/404.html @@ -15,10 +15,10 @@ {{ _("There's nothing here") }}
- {{ _("The page you are looking for have gone missing.") }} + {{ _("The page you are looking for has gone missing.") }}
{{ _("Back to Home") }}
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/frappe/www/login.html b/frappe/www/login.html index 85e5ae9296..927f451965 100644 --- a/frappe/www/login.html +++ b/frappe/www/login.html @@ -81,7 +81,7 @@
{{ email_login_body() }} -
+