diff --git a/.mergify.yml b/.mergify.yml index 0bd9641d5b..63fe1a0086 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -48,3 +48,7 @@ pull_request_rules: actions: merge: method: squash + commit_message_template: | + {{ title }} (#{{ number }}) + + {{ body }} diff --git a/cypress/integration/control_link.js b/cypress/integration/control_link.js index bfa70ad338..7a7e94d2f5 100644 --- a/cypress/integration/control_link.js +++ b/cypress/integration/control_link.js @@ -95,6 +95,51 @@ context('Control Link', () => { }); }); + it('show title field in link', () => { + get_dialog_with_link().as('dialog'); + + cy.insert_doc("Property Setter", { + "doctype": "Property Setter", + "doc_type": "ToDo", + "property": "show_title_field_in_link", + "property_type": "Check", + "doctype_or_field": "DocType", + "value": "1" + }, true); + + cy.window().its('frappe').then(frappe => { + if (!frappe.boot) { + frappe.boot = { + link_title_doctypes: ['ToDo'] + }; + } else { + frappe.boot.link_title_doctypes = ['ToDo']; + } + }); + + cy.intercept('POST', '/api/method/frappe.desk.search.search_link').as('search_link'); + + cy.get('.frappe-control[data-fieldname=link] input').focus().as('input'); + cy.wait('@search_link'); + cy.get('@input').type('todo for link'); + cy.wait('@search_link'); + cy.get('.frappe-control[data-fieldname=link] ul').should('be.visible'); + cy.get('.frappe-control[data-fieldname=link] input').type('{enter}', { delay: 100 }); + cy.get('.frappe-control[data-fieldname=link] input').blur(); + cy.get('@dialog').then(dialog => { + cy.get('@todos').then(todos => { + let field = dialog.get_field('link'); + let value = field.get_value(); + let label = field.get_label_value(); + + expect(value).to.eq(todos[0]); + expect(label).to.eq('this is a test todo for link'); + + cy.remove_doc("Property Setter", "ToDo-main-show_title_field_in_link"); + }); + }); + }); + it('should update dependant fields (via fetch_from)', () => { cy.get('@todos').then(todos => { cy.visit(`/app/todo/${todos[0]}`); diff --git a/cypress/integration/report_view.js b/cypress/integration/report_view.js index 0253e8fd43..5a0b13a3a0 100644 --- a/cypress/integration/report_view.js +++ b/cypress/integration/report_view.js @@ -11,30 +11,60 @@ context('Report View', () => { 'title': 'Doc 1', 'description': 'Random Text', 'enabled': 0, - // submit document - 'docstatus': 1 - }, true).as('doc'); + 'docstatus': 1 // submit document + }, true); + return cy.window().its('frappe').then(frappe => { + return frappe.call("frappe.tests.ui_test_helpers.create_multiple_contact_records"); + }); }); + it('Field with enabled allow_on_submit should be editable.', () => { cy.intercept('POST', 'api/method/frappe.client.set_value').as('value-update'); cy.visit(`/app/List/${doctype_name}/Report`); + // check status column added from docstatus cy.get('.dt-row-0 > .dt-cell--col-3').should('contain', 'Submitted'); let cell = cy.get('.dt-row-0 > .dt-cell--col-4'); + // select the cell cell.dblclick(); cell.get('.dt-cell__edit--col-4').findByRole('checkbox').check({ force: true }); + cy.wait('@value-update'); - cy.get('@doc').then(doc => { - cy.call('frappe.client.get_value', { - doctype: doc.doctype, - filters: { - name: doc.name, - }, - fieldname: 'enabled' - }).then(r => { - expect(r.message.enabled).to.equals(1); - }); + + cy.call('frappe.client.get_value', { + doctype: doctype_name, + filters: { + title: 'Doc 1', + }, + fieldname: 'enabled' + }).then(r => { + expect(r.message.enabled).to.equals(1); }); }); + + it('test load more with count selection buttons', () => { + cy.visit('/app/contact/view/report'); + + cy.clear_filters(); + cy.get('.list-paging-area .list-count').should('contain.text', '20 of'); + cy.get('.list-paging-area .btn-more').click(); + cy.get('.list-paging-area .list-count').should('contain.text', '40 of'); + cy.get('.list-paging-area .btn-more').click(); + cy.get('.list-paging-area .list-count').should('contain.text', '60 of'); + + cy.get('.list-paging-area .btn-group .btn-paging[data-value="100"]').click(); + + cy.get('.list-paging-area .list-count').should('contain.text', '100 of'); + cy.get('.list-paging-area .btn-more').click(); + cy.get('.list-paging-area .list-count').should('contain.text', '200 of'); + cy.get('.list-paging-area .btn-more').click(); + cy.get('.list-paging-area .list-count').should('contain.text', '300 of'); + + cy.get('.list-paging-area .btn-group .btn-paging[data-value="500"]').click(); + + cy.get('.list-paging-area .list-count').should('contain.text', '500 of'); + cy.get('.list-paging-area .btn-more').click(); + cy.get('.list-paging-area .list-count').should('contain.text', '1000 of'); + }); }); \ No newline at end of file diff --git a/frappe/api.py b/frappe/api.py index e7f7bf5a04..226853c47b 100644 --- a/frappe/api.py +++ b/frappe/api.py @@ -159,7 +159,10 @@ def get_request_form_data(): else: data = frappe.local.form_dict.data - return frappe.parse_json(data) + try: + return frappe.parse_json(data) + except ValueError: + return frappe.local.form_dict def validate_auth(): @@ -208,7 +211,6 @@ def validate_oauth(authorization_header): pass - def validate_auth_via_api_keys(authorization_header): """ Authenticate request using API keys and set session user diff --git a/frappe/boot.py b/frappe/boot.py index 7f62d96cae..524913059c 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -89,6 +89,7 @@ def get_bootinfo(): bootinfo.additional_filters_config = get_additional_filters_from_hooks() bootinfo.desk_settings = get_desk_settings() bootinfo.app_logo_url = get_app_logo() + bootinfo.link_title_doctypes = get_link_title_doctypes() return bootinfo @@ -324,6 +325,15 @@ def get_desk_settings(): def get_notification_settings(): return frappe.get_cached_doc('Notification Settings', frappe.session.user) +def get_link_title_doctypes(): + dts = frappe.get_all("DocType", {"show_title_field_in_link": 1}) + custom_dts = frappe.get_all( + "Property Setter", + {"property": "show_title_field_in_link", "value": "1"}, + ["doc_type as name"], + ) + return [d.name for d in dts + custom_dts if d] + def set_time_zone(bootinfo): bootinfo.time_zone = { "system": get_time_zone(), diff --git a/frappe/core/doctype/docfield/docfield.json b/frappe/core/doctype/docfield/docfield.json index 26ddce7d35..6eb8cf347f 100644 --- a/frappe/core/doctype/docfield/docfield.json +++ b/frappe/core/doctype/docfield/docfield.json @@ -17,6 +17,7 @@ "hide_days", "hide_seconds", "reqd", + "is_virtual", "search_index", "column_break_18", "options", @@ -534,13 +535,19 @@ "fieldname": "show_dashboard", "fieldtype": "Check", "label": "Show Dashboard" + }, + { + "default": "0", + "fieldname": "is_virtual", + "fieldtype": "Check", + "label": "Virtual" } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-01-03 11:56:19.812863", + "modified": "2022-01-27 21:22:20.529072", "modified_by": "Administrator", "module": "Core", "name": "DocField", diff --git a/frappe/core/doctype/doctype/doctype.json b/frappe/core/doctype/doctype/doctype.json index 03e3b65ea1..2bba4127bb 100644 --- a/frappe/core/doctype/doctype/doctype.json +++ b/frappe/core/doctype/doctype/doctype.json @@ -46,6 +46,7 @@ "allow_auto_repeat", "view_settings", "title_field", + "show_title_field_in_link", "search_fields", "default_print_format", "sort_field", @@ -582,6 +583,12 @@ "fieldname": "document_states_section", "fieldtype": "Section Break", "label": "Document States" + }, + { + "default": "0", + "fieldname": "show_title_field_in_link", + "fieldtype": "Check", + "label": "Show Title in Link Fields" } ], "icon": "fa fa-bolt", @@ -663,7 +670,7 @@ "link_fieldname": "reference_doctype" } ], - "modified": "2021-12-09 14:53:10.717788", + "modified": "2022-01-07 16:07:06.196534", "modified_by": "Administrator", "module": "Core", "name": "DocType", diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index aa731a686b..6d0409521e 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -1078,6 +1078,9 @@ def validate_fields(meta): field.fetch_from = field.fetch_from.strip('\n').strip() def validate_data_field_type(docfield): + if docfield.get("is_virtual"): + return + if docfield.fieldtype == "Data" and not (docfield.oldfieldtype and docfield.oldfieldtype != "Data"): if docfield.options and (docfield.options not in data_field_options): df_str = frappe.bold(_(docfield.label)) @@ -1323,10 +1326,9 @@ def make_module_and_roles(doc, perm_fieldname="permissions"): else: raise -def check_fieldname_conflicts(doctype, fieldname): +def check_fieldname_conflicts(docfield): """Checks if fieldname conflicts with methods or properties""" - - doc = frappe.get_doc({"doctype": doctype}) + doc = frappe.get_doc({"doctype": docfield.dt}) available_objects = [x for x in dir(doc) if isinstance(x, str)] property_list = [ x for x in available_objects if isinstance(getattr(type(doc), x, None), property) @@ -1334,9 +1336,10 @@ def check_fieldname_conflicts(doctype, fieldname): method_list = [ x for x in available_objects if x not in property_list and callable(getattr(doc, x)) ] + msg = _("Fieldname {0} conflicting with meta object").format(docfield.fieldname) - if fieldname in method_list + property_list: - frappe.throw(_("Fieldname {0} conflicting with meta object").format(fieldname)) + if docfield.fieldname in method_list + property_list: + frappe.msgprint(msg, raise_exception=not docfield.is_virtual) def clear_linked_doctype_cache(): frappe.cache().delete_value('linked_doctypes_without_ignore_user_permissions_enabled') diff --git a/frappe/custom/doctype/custom_field/custom_field.json b/frappe/custom/doctype/custom_field/custom_field.json index 235f11aad8..e51dfda14b 100644 --- a/frappe/custom/doctype/custom_field/custom_field.json +++ b/frappe/custom/doctype/custom_field/custom_field.json @@ -1,458 +1,468 @@ { - "actions": [], - "allow_import": 1, - "creation": "2013-01-10 16:34:01", - "description": "Adds a custom field to a DocType", - "doctype": "DocType", - "document_type": "Setup", - "engine": "InnoDB", - "field_order": [ - "dt", - "module", - "label", - "label_help", - "fieldname", - "insert_after", - "length", - "column_break_6", - "fieldtype", - "precision", - "hide_seconds", - "hide_days", - "options", - "fetch_from", - "fetch_if_empty", - "options_help", - "section_break_11", - "collapsible", - "collapsible_depends_on", - "default", - "depends_on", - "mandatory_depends_on", - "read_only_depends_on", - "properties", - "non_negative", - "reqd", - "unique", - "read_only", - "ignore_user_permissions", - "hidden", - "print_hide", - "print_hide_if_no_value", - "print_width", - "no_copy", - "allow_on_submit", - "in_list_view", - "in_standard_filter", - "in_global_search", - "in_preview", - "bold", - "report_hide", - "search_index", - "allow_in_quick_entry", - "ignore_xss_filter", - "translatable", - "hide_border", - "description", - "permlevel", - "width", - "columns" - ], - "fields": [{ - "bold": 1, - "fieldname": "dt", - "fieldtype": "Link", - "in_filter": 1, - "in_list_view": 1, - "label": "Document", - "oldfieldname": "dt", - "oldfieldtype": "Link", - "options": "DocType", - "reqd": 1, - "search_index": 1 - }, - { - "bold": 1, - "fieldname": "label", - "fieldtype": "Data", - "in_filter": 1, - "label": "Label", - "no_copy": 1, - "oldfieldname": "label", - "oldfieldtype": "Data" - }, - { - "fieldname": "label_help", - "fieldtype": "HTML", - "label": "Label Help", - "oldfieldtype": "HTML" - }, - { - "fieldname": "fieldname", - "fieldtype": "Data", - "in_list_view": 1, - "label": "Fieldname", - "no_copy": 1, - "oldfieldname": "fieldname", - "oldfieldtype": "Data", - "read_only": 1 - }, - { - "description": "Select the label after which you want to insert new field.", - "fieldname": "insert_after", - "fieldtype": "Select", - "label": "Insert After", - "no_copy": 1, - "oldfieldname": "insert_after", - "oldfieldtype": "Select" - }, - { - "fieldname": "column_break_6", - "fieldtype": "Column Break" - }, - { - "bold": 1, - "default": "Data", - "fieldname": "fieldtype", - "fieldtype": "Select", - "in_filter": 1, - "in_list_view": 1, - "label": "Field Type", - "oldfieldname": "fieldtype", - "oldfieldtype": "Select", - "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRead Only\nRating\nSection Break\nSelect\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nSignature\nTab Break", - "reqd": 1 - }, - { - "depends_on": "eval:in_list([\"Float\", \"Currency\", \"Percent\"], doc.fieldtype)", - "description": "Set non-standard precision for a Float or Currency field", - "fieldname": "precision", - "fieldtype": "Select", - "label": "Precision", - "options": "\n0\n1\n2\n3\n4\n5\n6\n7\n8\n9" - }, - { - "fieldname": "options", - "fieldtype": "Small Text", - "in_list_view": 1, - "label": "Options", - "oldfieldname": "options", - "oldfieldtype": "Text" - }, - { - "fieldname": "fetch_from", - "fieldtype": "Small Text", - "label": "Fetch From" - }, - { - "default": "0", - "description": "If checked, this field will be not overwritten based on Fetch From if a value already exists.", - "fieldname": "fetch_if_empty", - "fieldtype": "Check", - "label": "Fetch If Empty" - }, - { - "fieldname": "options_help", - "fieldtype": "HTML", - "label": "Options Help", - "oldfieldtype": "HTML" - }, - { - "fieldname": "section_break_11", - "fieldtype": "Section Break" - }, - { - "default": "0", - "depends_on": "eval:doc.fieldtype==\"Section Break\"", - "fieldname": "collapsible", - "fieldtype": "Check", - "label": "Collapsible" - }, - { - "depends_on": "eval:doc.fieldtype==\"Section Break\"", - "fieldname": "collapsible_depends_on", - "fieldtype": "Code", - "label": "Collapsible Depends On" - }, - { - "fieldname": "default", - "fieldtype": "Text", - "label": "Default Value", - "oldfieldname": "default", - "oldfieldtype": "Text" - }, - { - "fieldname": "depends_on", - "fieldtype": "Code", - "label": "Depends On", - "length": 255 - }, - { - "fieldname": "description", - "fieldtype": "Text", - "label": "Field Description", - "oldfieldname": "description", - "oldfieldtype": "Text", - "print_width": "300px", - "width": "300px" - }, - { - "default": "0", - "fieldname": "permlevel", - "fieldtype": "Int", - "label": "Permission Level", - "oldfieldname": "permlevel", - "oldfieldtype": "Int" - }, - { - "fieldname": "width", - "fieldtype": "Data", - "label": "Width", - "oldfieldname": "width", - "oldfieldtype": "Data" - }, - { - "description": "Number of columns for a field in a List View or a Grid (Total Columns should be less than 11)", - "fieldname": "columns", - "fieldtype": "Int", - "label": "Columns" - }, - { - "fieldname": "properties", - "fieldtype": "Column Break", - "oldfieldtype": "Column Break", - "print_width": "50%", - "width": "50%" - }, - { - "default": "0", - "fieldname": "reqd", - "fieldtype": "Check", - "in_list_view": 1, - "label": "Is Mandatory Field", - "oldfieldname": "reqd", - "oldfieldtype": "Check" - }, - { - "default": "0", - "fieldname": "unique", - "fieldtype": "Check", - "label": "Unique" - }, - { - "default": "0", - "fieldname": "read_only", - "fieldtype": "Check", - "label": "Read Only" - }, - { - "default": "0", - "depends_on": "eval:doc.fieldtype===\"Link\"", - "fieldname": "ignore_user_permissions", - "fieldtype": "Check", - "label": "Ignore User Permissions" - }, - { - "default": "0", - "fieldname": "hidden", - "fieldtype": "Check", - "label": "Hidden" - }, - { - "default": "0", - "fieldname": "print_hide", - "fieldtype": "Check", - "label": "Print Hide", - "oldfieldname": "print_hide", - "oldfieldtype": "Check" - }, - { - "default": "0", - "depends_on": "eval:[\"Int\", \"Float\", \"Currency\", \"Percent\"].indexOf(doc.fieldtype)!==-1", - "fieldname": "print_hide_if_no_value", - "fieldtype": "Check", - "label": "Print Hide If No Value" - }, - { - "fieldname": "print_width", - "fieldtype": "Data", - "hidden": 1, - "label": "Print Width", - "no_copy": 1, - "print_hide": 1 - }, - { - "default": "0", - "fieldname": "no_copy", - "fieldtype": "Check", - "label": "No Copy", - "oldfieldname": "no_copy", - "oldfieldtype": "Check" - }, - { - "default": "0", - "fieldname": "allow_on_submit", - "fieldtype": "Check", - "label": "Allow on Submit", - "oldfieldname": "allow_on_submit", - "oldfieldtype": "Check" - }, - { - "default": "0", - "fieldname": "in_list_view", - "fieldtype": "Check", - "label": "In List View" - }, - { - "default": "0", - "fieldname": "in_standard_filter", - "fieldtype": "Check", - "label": "In Standard Filter" - }, - { - "default": "0", - "depends_on": "eval:([\"Data\", \"Select\", \"Table\", \"Text\", \"Text Editor\", \"Link\", \"Small Text\", \"Long Text\", \"Read Only\", \"Heading\", \"Dynamic Link\"].indexOf(doc.fieldtype) !== -1)", - "fieldname": "in_global_search", - "fieldtype": "Check", - "label": "In Global Search" - }, - { - "default": "0", - "fieldname": "bold", - "fieldtype": "Check", - "label": "Bold" - }, - { - "default": "0", - "fieldname": "report_hide", - "fieldtype": "Check", - "label": "Report Hide", - "oldfieldname": "report_hide", - "oldfieldtype": "Check" - }, - { - "default": "0", - "fieldname": "search_index", - "fieldtype": "Check", - "hidden": 1, - "label": "Index", - "no_copy": 1, - "print_hide": 1 - }, - { - "default": "0", - "description": "Don't HTML Encode HTML tags like <script> or just characters like < or >, as they could be intentionally used in this field", - "fieldname": "ignore_xss_filter", - "fieldtype": "Check", - "label": "Ignore XSS Filter" - }, - { - "default": "1", - "depends_on": "eval:['Data', 'Select', 'Text', 'Small Text', 'Text Editor'].includes(doc.fieldtype)", - "fieldname": "translatable", - "fieldtype": "Check", - "label": "Translatable" - }, - { - "depends_on": "eval:in_list(['Data', 'Link', 'Dynamic Link', 'Password', 'Select', 'Read Only', 'Attach', 'Attach Image', 'Int'], doc.fieldtype)", - "fieldname": "length", - "fieldtype": "Int", - "label": "Length" - }, - { - "fieldname": "mandatory_depends_on", - "fieldtype": "Code", - "label": "Mandatory Depends On", - "length": 255 - }, - { - "fieldname": "read_only_depends_on", - "fieldtype": "Code", - "label": "Read Only Depends On", - "length": 255 - }, - { - "default": "0", - "fieldname": "allow_in_quick_entry", - "fieldtype": "Check", - "label": "Allow in Quick Entry" - }, - { - "default": "0", - "depends_on": "eval:!in_list(['Table', 'Table MultiSelect'], doc.fieldtype);", - "fieldname": "in_preview", - "fieldtype": "Check", - "label": "In Preview" - }, - { - "default": "0", - "depends_on": "eval:doc.fieldtype=='Duration'", - "fieldname": "hide_seconds", - "fieldtype": "Check", - "label": "Hide Seconds" - }, - { - "default": "0", - "depends_on": "eval:doc.fieldtype=='Duration'", - "fieldname": "hide_days", - "fieldtype": "Check", - "label": "Hide Days" - }, - { - "default": "0", - "depends_on": "eval:doc.fieldtype=='Section Break'", - "fieldname": "hide_border", - "fieldtype": "Check", - "label": "Hide Border" - }, - { - "default": "0", - "depends_on": "eval:in_list([\"Int\", \"Float\", \"Currency\"], doc.fieldtype)", - "fieldname": "non_negative", - "fieldtype": "Check", - "label": "Non Negative" - }, - { - "fieldname": "module", - "fieldtype": "Link", - "label": "Module (for export)", - "options": "Module Def" - } - ], - "icon": "fa fa-glass", - "idx": 1, - "index_web_pages_for_search": 1, - "links": [], - "modified": "2021-09-04 12:45:23.810120", - "modified_by": "Administrator", - "module": "Custom", - "name": "Custom Field", - "owner": "Administrator", - "permissions": [{ - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "Administrator", - "share": 1, - "write": 1 - }, - { - "create": 1, - "delete": 1, - "email": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "search_fields": "dt,label,fieldtype,options", - "sort_field": "modified", - "sort_order": "ASC", - "track_changes": 1 + "actions": [], + "allow_import": 1, + "creation": "2013-01-10 16:34:01", + "description": "Adds a custom field to a DocType", + "doctype": "DocType", + "document_type": "Setup", + "engine": "InnoDB", + "field_order": [ + "dt", + "module", + "label", + "label_help", + "fieldname", + "insert_after", + "length", + "column_break_6", + "fieldtype", + "precision", + "hide_seconds", + "hide_days", + "options", + "fetch_from", + "fetch_if_empty", + "options_help", + "section_break_11", + "collapsible", + "collapsible_depends_on", + "default", + "depends_on", + "mandatory_depends_on", + "read_only_depends_on", + "properties", + "non_negative", + "reqd", + "unique", + "is_virtual", + "read_only", + "ignore_user_permissions", + "hidden", + "print_hide", + "print_hide_if_no_value", + "print_width", + "no_copy", + "allow_on_submit", + "in_list_view", + "in_standard_filter", + "in_global_search", + "in_preview", + "bold", + "report_hide", + "search_index", + "allow_in_quick_entry", + "ignore_xss_filter", + "translatable", + "hide_border", + "description", + "permlevel", + "width", + "columns" + ], + "fields": [ + { + "bold": 1, + "fieldname": "dt", + "fieldtype": "Link", + "in_filter": 1, + "in_list_view": 1, + "label": "Document", + "oldfieldname": "dt", + "oldfieldtype": "Link", + "options": "DocType", + "reqd": 1, + "search_index": 1 + }, + { + "bold": 1, + "fieldname": "label", + "fieldtype": "Data", + "in_filter": 1, + "label": "Label", + "no_copy": 1, + "oldfieldname": "label", + "oldfieldtype": "Data" + }, + { + "fieldname": "label_help", + "fieldtype": "HTML", + "label": "Label Help", + "oldfieldtype": "HTML" + }, + { + "fieldname": "fieldname", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Fieldname", + "no_copy": 1, + "oldfieldname": "fieldname", + "oldfieldtype": "Data", + "read_only": 1 + }, + { + "description": "Select the label after which you want to insert new field.", + "fieldname": "insert_after", + "fieldtype": "Select", + "label": "Insert After", + "no_copy": 1, + "oldfieldname": "insert_after", + "oldfieldtype": "Select" + }, + { + "fieldname": "column_break_6", + "fieldtype": "Column Break" + }, + { + "bold": 1, + "default": "Data", + "fieldname": "fieldtype", + "fieldtype": "Select", + "in_filter": 1, + "in_list_view": 1, + "label": "Field Type", + "oldfieldname": "fieldtype", + "oldfieldtype": "Select", + "options": "Attach\nAttach Image\nBarcode\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDuration\nDynamic Link\nFloat\nFold\nGeolocation\nHeading\nHTML\nHTML Editor\nIcon\nImage\nInt\nLink\nLong Text\nMarkdown Editor\nPassword\nPercent\nRead Only\nRating\nSection Break\nSelect\nSmall Text\nTable\nTable MultiSelect\nText\nText Editor\nTime\nSignature\nTab Break", + "reqd": 1 + }, + { + "depends_on": "eval:in_list([\"Float\", \"Currency\", \"Percent\"], doc.fieldtype)", + "description": "Set non-standard precision for a Float or Currency field", + "fieldname": "precision", + "fieldtype": "Select", + "label": "Precision", + "options": "\n0\n1\n2\n3\n4\n5\n6\n7\n8\n9" + }, + { + "fieldname": "options", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "Options", + "oldfieldname": "options", + "oldfieldtype": "Text" + }, + { + "fieldname": "fetch_from", + "fieldtype": "Small Text", + "label": "Fetch From" + }, + { + "default": "0", + "description": "If checked, this field will be not overwritten based on Fetch From if a value already exists.", + "fieldname": "fetch_if_empty", + "fieldtype": "Check", + "label": "Fetch If Empty" + }, + { + "fieldname": "options_help", + "fieldtype": "HTML", + "label": "Options Help", + "oldfieldtype": "HTML" + }, + { + "fieldname": "section_break_11", + "fieldtype": "Section Break" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype==\"Section Break\"", + "fieldname": "collapsible", + "fieldtype": "Check", + "label": "Collapsible" + }, + { + "depends_on": "eval:doc.fieldtype==\"Section Break\"", + "fieldname": "collapsible_depends_on", + "fieldtype": "Code", + "label": "Collapsible Depends On" + }, + { + "fieldname": "default", + "fieldtype": "Text", + "label": "Default Value", + "oldfieldname": "default", + "oldfieldtype": "Text" + }, + { + "fieldname": "depends_on", + "fieldtype": "Code", + "label": "Depends On", + "length": 255 + }, + { + "fieldname": "description", + "fieldtype": "Text", + "label": "Field Description", + "oldfieldname": "description", + "oldfieldtype": "Text", + "print_width": "300px", + "width": "300px" + }, + { + "default": "0", + "fieldname": "permlevel", + "fieldtype": "Int", + "label": "Permission Level", + "oldfieldname": "permlevel", + "oldfieldtype": "Int" + }, + { + "fieldname": "width", + "fieldtype": "Data", + "label": "Width", + "oldfieldname": "width", + "oldfieldtype": "Data" + }, + { + "description": "Number of columns for a field in a List View or a Grid (Total Columns should be less than 11)", + "fieldname": "columns", + "fieldtype": "Int", + "label": "Columns" + }, + { + "fieldname": "properties", + "fieldtype": "Column Break", + "oldfieldtype": "Column Break", + "print_width": "50%", + "width": "50%" + }, + { + "default": "0", + "fieldname": "reqd", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Is Mandatory Field", + "oldfieldname": "reqd", + "oldfieldtype": "Check" + }, + { + "default": "0", + "fieldname": "unique", + "fieldtype": "Check", + "label": "Unique" + }, + { + "default": "0", + "fieldname": "is_virtual", + "fieldtype": "Check", + "label": "Is Virtual" + }, + { + "default": "0", + "fieldname": "read_only", + "fieldtype": "Check", + "label": "Read Only" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype===\"Link\"", + "fieldname": "ignore_user_permissions", + "fieldtype": "Check", + "label": "Ignore User Permissions" + }, + { + "default": "0", + "fieldname": "hidden", + "fieldtype": "Check", + "label": "Hidden" + }, + { + "default": "0", + "fieldname": "print_hide", + "fieldtype": "Check", + "label": "Print Hide", + "oldfieldname": "print_hide", + "oldfieldtype": "Check" + }, + { + "default": "0", + "depends_on": "eval:[\"Int\", \"Float\", \"Currency\", \"Percent\"].indexOf(doc.fieldtype)!==-1", + "fieldname": "print_hide_if_no_value", + "fieldtype": "Check", + "label": "Print Hide If No Value" + }, + { + "fieldname": "print_width", + "fieldtype": "Data", + "hidden": 1, + "label": "Print Width", + "no_copy": 1, + "print_hide": 1 + }, + { + "default": "0", + "fieldname": "no_copy", + "fieldtype": "Check", + "label": "No Copy", + "oldfieldname": "no_copy", + "oldfieldtype": "Check" + }, + { + "default": "0", + "fieldname": "allow_on_submit", + "fieldtype": "Check", + "label": "Allow on Submit", + "oldfieldname": "allow_on_submit", + "oldfieldtype": "Check" + }, + { + "default": "0", + "fieldname": "in_list_view", + "fieldtype": "Check", + "label": "In List View" + }, + { + "default": "0", + "fieldname": "in_standard_filter", + "fieldtype": "Check", + "label": "In Standard Filter" + }, + { + "default": "0", + "depends_on": "eval:([\"Data\", \"Select\", \"Table\", \"Text\", \"Text Editor\", \"Link\", \"Small Text\", \"Long Text\", \"Read Only\", \"Heading\", \"Dynamic Link\"].indexOf(doc.fieldtype) !== -1)", + "fieldname": "in_global_search", + "fieldtype": "Check", + "label": "In Global Search" + }, + { + "default": "0", + "fieldname": "bold", + "fieldtype": "Check", + "label": "Bold" + }, + { + "default": "0", + "fieldname": "report_hide", + "fieldtype": "Check", + "label": "Report Hide", + "oldfieldname": "report_hide", + "oldfieldtype": "Check" + }, + { + "default": "0", + "fieldname": "search_index", + "fieldtype": "Check", + "hidden": 1, + "label": "Index", + "no_copy": 1, + "print_hide": 1 + }, + { + "default": "0", + "description": "Don't HTML Encode HTML tags like <script> or just characters like < or >, as they could be intentionally used in this field", + "fieldname": "ignore_xss_filter", + "fieldtype": "Check", + "label": "Ignore XSS Filter" + }, + { + "default": "1", + "depends_on": "eval:['Data', 'Select', 'Text', 'Small Text', 'Text Editor'].includes(doc.fieldtype)", + "fieldname": "translatable", + "fieldtype": "Check", + "label": "Translatable" + }, + { + "depends_on": "eval:in_list(['Data', 'Link', 'Dynamic Link', 'Password', 'Select', 'Read Only', 'Attach', 'Attach Image', 'Int'], doc.fieldtype)", + "fieldname": "length", + "fieldtype": "Int", + "label": "Length" + }, + { + "fieldname": "mandatory_depends_on", + "fieldtype": "Code", + "label": "Mandatory Depends On", + "length": 255 + }, + { + "fieldname": "read_only_depends_on", + "fieldtype": "Code", + "label": "Read Only Depends On", + "length": 255 + }, + { + "default": "0", + "fieldname": "allow_in_quick_entry", + "fieldtype": "Check", + "label": "Allow in Quick Entry" + }, + { + "default": "0", + "depends_on": "eval:!in_list(['Table', 'Table MultiSelect'], doc.fieldtype);", + "fieldname": "in_preview", + "fieldtype": "Check", + "label": "In Preview" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype=='Duration'", + "fieldname": "hide_seconds", + "fieldtype": "Check", + "label": "Hide Seconds" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype=='Duration'", + "fieldname": "hide_days", + "fieldtype": "Check", + "label": "Hide Days" + }, + { + "default": "0", + "depends_on": "eval:doc.fieldtype=='Section Break'", + "fieldname": "hide_border", + "fieldtype": "Check", + "label": "Hide Border" + }, + { + "default": "0", + "depends_on": "eval:in_list([\"Int\", \"Float\", \"Currency\"], doc.fieldtype)", + "fieldname": "non_negative", + "fieldtype": "Check", + "label": "Non Negative" + }, + { + "fieldname": "module", + "fieldtype": "Link", + "label": "Module (for export)", + "options": "Module Def" + } + ], + "icon": "fa fa-glass", + "idx": 1, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2022-01-27 21:47:01.065556", + "modified_by": "Administrator", + "module": "Custom", + "name": "Custom Field", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Administrator", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "search_fields": "dt,label,fieldtype,options", + "sort_field": "modified", + "sort_order": "ASC", + "states": [], + "track_changes": 1 } \ No newline at end of file diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py index 8f7b21dd24..cb1ea2c54d 100644 --- a/frappe/custom/doctype/custom_field/custom_field.py +++ b/frappe/custom/doctype/custom_field/custom_field.py @@ -54,7 +54,7 @@ class CustomField(Document): old_fieldtype = self.db_get('fieldtype') is_fieldtype_changed = (not self.is_new()) and (old_fieldtype != self.fieldtype) - if is_fieldtype_changed and not CustomizeForm.allow_fieldtype_change(old_fieldtype, self.fieldtype): + if not self.is_virtual and is_fieldtype_changed and not CustomizeForm.allow_fieldtype_change(old_fieldtype, self.fieldtype): frappe.throw(_("Fieldtype cannot be changed from {0} to {1}").format(old_fieldtype, self.fieldtype)) if not self.fieldname: @@ -65,7 +65,7 @@ class CustomField(Document): if not self.flags.ignore_validate: from frappe.core.doctype.doctype.doctype import check_fieldname_conflicts - check_fieldname_conflicts(self.dt, self.fieldname) + check_fieldname_conflicts(self) def on_update(self): if not frappe.flags.in_setup_wizard: diff --git a/frappe/custom/doctype/customize_form/customize_form.json b/frappe/custom/doctype/customize_form/customize_form.json index bdf95ad351..1ee9d4a02a 100644 --- a/frappe/custom/doctype/customize_form/customize_form.json +++ b/frappe/custom/doctype/customize_form/customize_form.json @@ -27,6 +27,7 @@ "autoname", "view_settings_section", "title_field", + "show_title_field_in_link", "image_field", "default_print_format", "column_break_29", @@ -296,6 +297,12 @@ "fieldtype": "Table", "label": "States", "options": "DocType State" + }, + { + "default": "0", + "fieldname": "show_title_field_in_link", + "fieldtype": "Check", + "label": "Show Title in Link Fields" } ], "hide_toolbar": 1, @@ -304,7 +311,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-12-14 16:45:04.308690", + "modified": "2022-01-07 16:07:06.196534", "modified_by": "Administrator", "module": "Custom", "name": "Customize Form", diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py index 1593ed49a5..92a540447f 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -418,6 +418,9 @@ class CustomizeForm(Document): return property_value def validate_fieldtype_change(self, df, old_value, new_value): + if df.is_virtual: + return + allowed = self.allow_fieldtype_change(old_value, new_value) if allowed: old_value_length = cint(frappe.db.type_map.get(old_value)[1]) @@ -430,7 +433,8 @@ class CustomizeForm(Document): self.validate_fieldtype_length() else: self.flags.update_db = True - if not allowed: + + else: frappe.throw(_("Fieldtype cannot be changed from {0} to {1} in row {2}").format(old_value, new_value, df.idx)) def validate_fieldtype_length(self): @@ -512,7 +516,8 @@ doctype_properties = { 'email_append_to': 'Check', 'subject_field': 'Data', 'sender_field': 'Data', - 'autoname': 'Data' + 'autoname': 'Data', + 'show_title_field_in_link': 'Check' } docfield_properties = { @@ -558,7 +563,8 @@ docfield_properties = { 'allow_in_quick_entry': 'Check', 'hide_border': 'Check', 'hide_days': 'Check', - 'hide_seconds': 'Check' + 'hide_seconds': 'Check', + 'is_virtual': 'Check', } doctype_link_properties = { diff --git a/frappe/custom/doctype/customize_form_field/customize_form_field.json b/frappe/custom/doctype/customize_form_field/customize_form_field.json index a545cd9fe1..4351e76609 100644 --- a/frappe/custom/doctype/customize_form_field/customize_form_field.json +++ b/frappe/custom/doctype/customize_form_field/customize_form_field.json @@ -14,6 +14,7 @@ "non_negative", "reqd", "unique", + "is_virtual", "in_list_view", "in_standard_filter", "in_global_search", @@ -115,6 +116,12 @@ "fieldtype": "Check", "label": "Unique" }, + { + "default": "0", + "fieldname": "is_virtual", + "fieldtype": "Check", + "label": "Is Virtual" + }, { "default": "0", "fieldname": "in_list_view", @@ -436,7 +443,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-01-03 14:50:32.035768", + "modified": "2022-01-27 21:45:22.349776", "modified_by": "Administrator", "module": "Custom", "name": "Customize Form Field", diff --git a/frappe/custom/doctype/property_setter/property_setter.py b/frappe/custom/doctype/property_setter/property_setter.py index 0a65aa6f5d..a86cf5efd6 100644 --- a/frappe/custom/doctype/property_setter/property_setter.py +++ b/frappe/custom/doctype/property_setter/property_setter.py @@ -1,4 +1,4 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE import frappe @@ -18,53 +18,19 @@ class PropertySetter(Document): def validate(self): self.validate_fieldtype_change() + if self.is_new(): delete_property_setter(self.doc_type, self.property, self.field_name, self.row_name) - - # clear cache frappe.clear_cache(doctype = self.doc_type) def validate_fieldtype_change(self): - if self.field_name in not_allowed_fieldtype_change and \ - self.property == 'fieldtype': - frappe.throw(_("Field type cannot be changed for {0}").format(self.field_name)) - - def get_property_list(self, dt): - return frappe.db.get_all('DocField', - fields=['fieldname', 'label', 'fieldtype'], - filters={ - 'parent': dt, - 'fieldtype': ['not in', ('Section Break', 'Column Break', 'Tab Break', 'HTML', 'Read Only', 'Fold') + frappe.model.table_fields], - 'fieldname': ['!=', ''] - }, - order_by='label asc', - as_dict=1 - ) - - def get_setup_data(self): - return { - 'doctypes': frappe.get_all("DocType", pluck="name"), - 'dt_properties': self.get_property_list('DocType'), - 'df_properties': self.get_property_list('DocField') - } - - def get_field_ids(self): - return frappe.db.get_values( - "DocField", - filters={"parent": self.doc_type}, - fieldname=["name", "fieldtype", "label", "fieldname"], - as_dict=True, - ) - - def get_defaults(self): - if not self.field_name: - return frappe.get_all("DocType", filters={"name": self.doc_type}, fields="*")[0] - else: - return frappe.db.get_values( - "DocField", - filters={"fieldname": self.field_name, "parent": self.doc_type}, - fieldname="*", - )[0] + if ( + self.property == 'fieldtype' + and self.field_name in not_allowed_fieldtype_change + ): + frappe.throw( + _("Field type cannot be changed for {0}").format(self.field_name) + ) def on_update(self): if frappe.flags.in_patch: @@ -74,6 +40,7 @@ class PropertySetter(Document): from frappe.core.doctype.doctype.doctype import validate_fields_for_doctype validate_fields_for_doctype(self.doc_type) + def make_property_setter(doctype, fieldname, property, value, property_type, for_doctype = False, validate_fields_for_doctype=True): # WARNING: Ignores Permissions @@ -91,6 +58,7 @@ def make_property_setter(doctype, fieldname, property, value, property_type, for property_setter.insert() return property_setter + def delete_property_setter(doc_type, property, field_name=None, row_name=None): """delete other property setters on this, if this is new""" filters = dict(doc_type=doc_type, property=property) @@ -100,4 +68,3 @@ def delete_property_setter(doc_type, property, field_name=None, row_name=None): filters["row_name"] = row_name frappe.db.delete('Property Setter', filters) - diff --git a/frappe/database/database.py b/frappe/database/database.py index 9fa1ff161c..c833bdeed3 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -177,6 +177,8 @@ class Database(object): raise frappe.QueryTimeoutError(e) elif frappe.conf.db_type == 'postgres': + # TODO: added temporarily + print(e) raise if ignore_ddl and (self.is_missing_column(e) or self.is_missing_table(e) or self.cant_drop_field_or_key(e)): diff --git a/frappe/database/mariadb/framework_mariadb.sql b/frappe/database/mariadb/framework_mariadb.sql index 7c9309ee9f..f2a1206c7c 100644 --- a/frappe/database/mariadb/framework_mariadb.sql +++ b/frappe/database/mariadb/framework_mariadb.sql @@ -224,6 +224,7 @@ CREATE TABLE `tabDocType` ( `email_append_to` int(1) NOT NULL DEFAULT 0, `subject_field` varchar(255) DEFAULT NULL, `sender_field` varchar(255) DEFAULT NULL, + `show_title_field_in_link` int(1) NOT NULL DEFAULT 0, `migration_hash` varchar(255) DEFAULT NULL, PRIMARY KEY (`name`) ) ENGINE=InnoDB ROW_FORMAT=DYNAMIC CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/frappe/database/postgres/framework_postgres.sql b/frappe/database/postgres/framework_postgres.sql index 1662b7b93e..1e79bf67d8 100644 --- a/frappe/database/postgres/framework_postgres.sql +++ b/frappe/database/postgres/framework_postgres.sql @@ -229,6 +229,7 @@ CREATE TABLE "tabDocType" ( "email_append_to" smallint NOT NULL DEFAULT 0, "subject_field" varchar(255) DEFAULT NULL, "sender_field" varchar(255) DEFAULT NULL, + "show_title_field_in_link" smallint NOT NULL DEFAULT 0, "migration_hash" varchar(255) DEFAULT NULL, PRIMARY KEY ("name") ) ; diff --git a/frappe/database/schema.py b/frappe/database/schema.py index dd54385c83..7cab8d42b2 100644 --- a/frappe/database/schema.py +++ b/frappe/database/schema.py @@ -67,7 +67,7 @@ class DBTable: """ get columns from docfields and custom fields """ - fields = self.meta.get_fieldnames_with_value(True) + fields = self.meta.get_fieldnames_with_value(with_field_meta=True) # optional fields like _comments if not self.meta.get('istable'): @@ -85,6 +85,9 @@ class DBTable: }) for field in fields: + if field.get("is_virtual"): + continue + self.columns[field.get('fieldname')] = DbColumn( self, field.get('fieldname'), diff --git a/frappe/desk/doctype/dashboard/dashboard_list.js b/frappe/desk/doctype/dashboard/dashboard_list.js new file mode 100644 index 0000000000..d60a324048 --- /dev/null +++ b/frappe/desk/doctype/dashboard/dashboard_list.js @@ -0,0 +1,16 @@ +frappe.listview_settings['Dashboard'] = { + button: { + show(doc) { + return doc.name; + }, + get_label() { + return frappe.utils.icon("dashboard-list", "sm"); + }, + get_description(doc) { + return __('View {0}', [`${doc.name}`]); + }, + action(doc) { + frappe.set_route('dashboard-view', doc.name); + } + }, +}; \ No newline at end of file diff --git a/frappe/desk/form/load.py b/frappe/desk/form/load.py index 58d5b30103..4d35ebf5e8 100644 --- a/frappe/desk/form/load.py +++ b/frappe/desk/form/load.py @@ -49,7 +49,7 @@ def getdoc(doctype, name, user=None): raise doc.add_seen() - + set_link_titles(doc) frappe.response.docs.append(doc) @frappe.whitelist() @@ -367,6 +367,60 @@ def get_additional_timeline_content(doctype, docname): return contents +def set_link_titles(doc): + link_titles = {} + link_titles.update(get_title_values_for_link_and_dynamic_link_fields(doc)) + link_titles.update(get_title_values_for_table_and_multiselect_fields(doc)) + + send_link_titles(link_titles) + +def get_title_values_for_link_and_dynamic_link_fields(doc, link_fields=None): + link_titles = {} + + if not link_fields: + meta = frappe.get_meta(doc.doctype) + link_fields = meta.get_link_fields() + meta.get_dynamic_link_fields() + + for field in link_fields: + if not doc.get(field.fieldname): + continue + + doctype = field.options if field.fieldtype == "Link" else doc.get(field.options) + + meta = frappe.get_meta(doctype) + if not meta or not (meta.title_field and meta.show_title_field_in_link): + continue + + link_title = frappe.db.get_value( + doctype, doc.get(field.fieldname), meta.title_field, cache=True + ) + link_titles.update({doctype + "::" + doc.get(field.fieldname): link_title}) + + return link_titles + +def get_title_values_for_table_and_multiselect_fields(doc, table_fields=None): + link_titles = {} + + if not table_fields: + meta = frappe.get_meta(doc.doctype) + table_fields = meta.get_table_fields() + + for field in table_fields: + if not doc.get(field.fieldname): + continue + + for value in doc.get(field.fieldname): + link_titles.update(get_title_values_for_link_and_dynamic_link_fields(value)) + + return link_titles + +def send_link_titles(link_titles): + """Append link titles dict in `frappe.local.response`.""" + if "_link_titles" not in frappe.local.response: + frappe.local.response["_link_titles"] = {} + + frappe.local.response["_link_titles"].update(link_titles) + def update_user_info(docinfo): for d in docinfo.communications: frappe.utils.add_user_info(d.sender, docinfo.user_info) @@ -387,3 +441,4 @@ def get_user_info_for_viewers(users): frappe.utils.add_user_info(user, user_info) return user_info + diff --git a/frappe/desk/search.py b/frappe/desk/search.py index 95397070ae..3b76953ed1 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -49,8 +49,10 @@ def sanitize_searchfield(searchfield): # this is called by the Link Field @frappe.whitelist() def search_link(doctype, txt, query=None, filters=None, page_length=20, searchfield=None, reference_doctype=None, ignore_user_permissions=False): - search_widget(doctype, txt.strip(), query, searchfield=searchfield, page_length=page_length, filters=filters, reference_doctype=reference_doctype, ignore_user_permissions=ignore_user_permissions) - frappe.response['results'] = build_for_autosuggest(frappe.response["values"]) + search_widget(doctype, txt.strip(), query, searchfield=searchfield, page_length=page_length, filters=filters, + reference_doctype=reference_doctype, ignore_user_permissions=ignore_user_permissions) + + frappe.response["results"] = build_for_autosuggest(frappe.response["values"], doctype=doctype) del frappe.response["values"] # this is called by the search box @@ -138,6 +140,12 @@ def search_widget(doctype, txt, query=None, searchfield=None, start=0, fields = list(set(fields + json.loads(filter_fields))) formatted_fields = ['`tab%s`.`%s`' % (meta.name, f.strip()) for f in fields] + title_field_query = get_title_field_query(meta) + + # Insert title field query after name + if title_field_query: + formatted_fields.insert(1, title_field_query) + # find relevance as location of search term from the beginning of string `name`. used for sorting results. formatted_fields.append("""locate({_txt}, `tab{doctype}`.`name`) as `_relevance`""".format( _txt=frappe.db.escape((txt or "").replace("%", "").replace("@", "")), doctype=doctype)) @@ -205,11 +213,38 @@ def get_std_fields_list(meta, key): return sflist -def build_for_autosuggest(res): +def get_title_field_query(meta): + title_field = meta.title_field if meta.title_field else None + show_title_field_in_link = meta.show_title_field_in_link if meta.show_title_field_in_link else None + field = None + + if title_field and show_title_field_in_link: + field = "`tab{0}`.{1} as `label`".format(meta.name, title_field) + + return field + +def build_for_autosuggest(res, doctype): results = [] - for r in res: - out = {"value": r[0], "description": ", ".join(unique(cstr(d) for d in r if d)[1:])} - results.append(out) + meta = frappe.get_meta(doctype) + if not (meta.title_field and meta.show_title_field_in_link): + for r in res: + r = list(r) + results.append({ + "value": r[0], + "description": ", ".join(unique(cstr(d) for d in r[1:] if d)) + }) + + else: + title_field_exists = meta.title_field and meta.show_title_field_in_link + _from = 2 if title_field_exists else 1 # to exclude title from description if title_field_exists + for r in res: + r = list(r) + results.append({ + "value": r[0], + "label": r[1] if title_field_exists else None, + "description": ", ".join(unique(cstr(d) for d in r[_from:] if d)) + }) + return results def scrub_custom_query(query, key, txt): @@ -272,3 +307,12 @@ def get_user_groups(): return frappe.get_all('User Group', fields=['name as id', 'name as value'], update={ 'is_group': True }) + +@frappe.whitelist() +def get_link_title(doctype, docname): + meta = frappe.get_meta(doctype) + + if meta.title_field and meta.show_title_field_in_link: + return frappe.db.get_value(doctype, docname, meta.title_field) + + return docname \ No newline at end of file diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 307d95e84b..83d3d70eea 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -1,16 +1,14 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE +import datetime import frappe -import datetime from frappe import _ -from frappe.model import default_fields, table_fields, child_table_fields +from frappe.model import child_table_fields, default_fields, display_fieldtypes, table_fields from frappe.model.naming import set_new_name from frappe.model.utils.link_count import notify_link_count from frappe.modules import load_doctype_module -from frappe.model import display_fieldtypes -from frappe.utils import (cint, flt, now, cstr, strip_html, - sanitize_html, sanitize_email, cast_fieldtype) +from frappe.utils import cast_fieldtype, cint, cstr, flt, now, sanitize_html, strip_html from frappe.utils.html_utils import unescape_html from frappe.model.docstatus import DocStatus @@ -75,9 +73,12 @@ def get_controller(doctype): return site_controllers[doctype] class BaseDocument(object): - ignore_in_getter = ("doctype", "_meta", "meta", "_table_fields", "_valid_columns") + ignore_in_setter = ("doctype", "_meta", "meta", "_table_fields", "_valid_columns") def __init__(self, d): + if d.get("doctype"): + self.doctype = d["doctype"] + self.update(d) self.dont_update_if_missing = [] @@ -146,10 +147,14 @@ class BaseDocument(object): else: value = self.__dict__.get(key, default) - if value is None and key not in self.ignore_in_getter \ - and key in (d.fieldname for d in self.meta.get_table_fields()): - self.set(key, []) - value = self.__dict__.get(key) + if value is None and key in ( + d.fieldname for d in self.meta.get_table_fields() + ): + value = [] + self.set(key, value) + + if limit and isinstance(value, (list, tuple)) and len(value) > limit: + value = value[:limit] return value else: @@ -159,6 +164,9 @@ class BaseDocument(object): return self.get(key, filters=filters, limit=1)[0] def set(self, key, value, as_value=False): + if key in self.ignore_in_setter: + return + if isinstance(value, list) and not as_value: self.__dict__[key] = [] self.extend(key, value) @@ -244,7 +252,7 @@ class BaseDocument(object): return value - def get_valid_dict(self, sanitize=True, convert_dates_to_str=False, ignore_nulls = False): + def get_valid_dict(self, sanitize=True, convert_dates_to_str=False, ignore_nulls=False, ignore_virtual=False): d = frappe._dict() for fieldname in self.meta.get_valid_columns(): d[fieldname] = self.get(fieldname) @@ -254,7 +262,26 @@ class BaseDocument(object): continue df = self.meta.get_field(fieldname) - if df: + + if df and df.get("is_virtual"): + if ignore_virtual: + del d[fieldname] + continue + + from frappe.utils.safe_exec import get_safe_globals + + if d[fieldname] is None: + if df.get("options"): + d[fieldname] = frappe.safe_eval( + code=df.get("options"), + eval_globals=get_safe_globals(), + eval_locals={"doc": self}, + ) + else: + _val = getattr(self, fieldname, None) + if _val and not callable(_val): + d[fieldname] = _val + elif df: if df.fieldtype=="Check": d[fieldname] = 1 if cint(d[fieldname]) else 0 @@ -328,6 +355,7 @@ class BaseDocument(object): def as_dict(self, no_nulls=False, no_default_fields=False, convert_dates_to_str=False, no_child_table_fields=False): doc = self.get_valid_dict(convert_dates_to_str=convert_dates_to_str) doc["doctype"] = self.doctype + for df in self.meta.get_table_fields(): children = self.get(df.fieldname) or [] doc[df.fieldname] = [ @@ -375,26 +403,43 @@ class BaseDocument(object): fieldname = [df.fieldname for df in self.meta.get_table_fields() if df.options==doctype] return fieldname[0] if fieldname else None - def db_insert(self): - """INSERT the document (with valid columns) in the database.""" + def db_insert(self, ignore_if_duplicate=False): + """INSERT the document (with valid columns) in the database. + + args: + ignore_if_duplicate: ignore primary key collision + at database level (postgres) + in python (mariadb) + """ if not self.name: # name will be set by document class in most cases set_new_name(self) + conflict_handler = "" + # On postgres we can't implcitly ignore PK collision + # So instruct pg to ignore `name` field conflicts + if ignore_if_duplicate and frappe.db.db_type == "postgres": + conflict_handler = "on conflict (name) do nothing" + if not self.creation: self.creation = self.modified = now() self.created_by = self.modified_by = frappe.session.user # if doctype is "DocType", don't insert null values as we don't know who is valid yet - d = self.get_valid_dict(convert_dates_to_str=True, ignore_nulls = self.doctype in DOCTYPES_FOR_DOCTYPE) + d = self.get_valid_dict( + convert_dates_to_str=True, + ignore_nulls=self.doctype in DOCTYPES_FOR_DOCTYPE, + ignore_virtual=True, + ) columns = list(d) try: frappe.db.sql("""INSERT INTO `tab{doctype}` ({columns}) - VALUES ({values})""".format( - doctype = self.doctype, - columns = ", ".join("`"+c+"`" for c in columns), - values = ", ".join(["%s"] * len(columns)) + VALUES ({values}) {conflict_handler}""".format( + doctype=self.doctype, + columns=", ".join("`"+c+"`" for c in columns), + values=", ".join(["%s"] * len(columns)), + conflict_handler=conflict_handler ), list(d.values())) except Exception as e: if frappe.db.is_primary_key_violation(e): @@ -407,8 +452,11 @@ class BaseDocument(object): self.db_insert() return - frappe.msgprint(_("{0} {1} already exists").format(self.doctype, frappe.bold(self.name)), title=_("Duplicate Name"), indicator="red") - raise frappe.DuplicateEntryError(self.doctype, self.name, e) + if not ignore_if_duplicate: + frappe.msgprint(_("{0} {1} already exists") + .format(self.doctype, frappe.bold(self.name)), + title=_("Duplicate Name"), indicator="red") + raise frappe.DuplicateEntryError(self.doctype, self.name, e) elif frappe.db.is_unique_key_violation(e): # unique constraint @@ -736,7 +784,7 @@ class BaseDocument(object): type_map = frappe.db.type_map - for fieldname, value in self.get_valid_dict().items(): + for fieldname, value in self.get_valid_dict(ignore_virtual=True).items(): df = self.meta.get_field(fieldname) if not df or df.fieldtype == 'Check': @@ -814,7 +862,7 @@ class BaseDocument(object): if frappe.flags.in_install: return - for fieldname, value in self.get_valid_dict().items(): + for fieldname, value in self.get_valid_dict(ignore_virtual=True).items(): if not value or not isinstance(value, str): continue diff --git a/frappe/model/document.py b/frappe/model/document.py index 66a0cef7dd..cb36c18b47 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -249,11 +249,7 @@ class Document(BaseDocument): if getattr(self.meta, "issingle", 0): self.update_single(self.get_valid_dict()) else: - try: - self.db_insert() - except frappe.DuplicateEntryError as e: - if not ignore_if_duplicate: - raise e + self.db_insert(ignore_if_duplicate=ignore_if_duplicate) # children for d in self.get_all_children(): diff --git a/frappe/model/meta.py b/frappe/model/meta.py index 372392f689..77d4de466f 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -444,9 +444,16 @@ class Meta(Document): self.permissions = [Document(d) for d in custom_perms] def get_fieldnames_with_value(self, with_field_meta=False): - return [df if with_field_meta else df.fieldname \ - for df in self.fields if df.fieldtype not in no_value_fields] + def is_value_field(docfield): + return not ( + docfield.get("is_virtual") + or docfield.fieldtype in no_value_fields + ) + if with_field_meta: + return [df for df in self.fields if is_value_field(df)] + + return [df.fieldname for df in self.fields if is_value_field(df)] def get_fields_to_check_permissions(self, user_permission_doctypes): fields = self.get("fields", { diff --git a/frappe/patches.txt b/frappe/patches.txt index db9610a767..0d2a6162c2 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -184,6 +184,7 @@ frappe.patches.v13_0.queryreport_columns frappe.patches.v13_0.jinja_hook frappe.patches.v13_0.update_notification_channel_if_empty frappe.patches.v13_0.set_first_day_of_the_week +execute:frappe.reload_doc('custom', 'doctype', 'custom_field') frappe.patches.v14_0.update_workspace2 # 20.09.2021 frappe.patches.v14_0.save_ratings_in_fraction #23-12-2021 frappe.patches.v14_0.transform_todo_schema diff --git a/frappe/public/icons/timeless/symbol-defs.svg b/frappe/public/icons/timeless/symbol-defs.svg index f2977e3016..bf4e02a7af 100644 --- a/frappe/public/icons/timeless/symbol-defs.svg +++ b/frappe/public/icons/timeless/symbol-defs.svg @@ -814,6 +814,13 @@ + + + + + + + diff --git a/frappe/public/js/frappe/form/controls/base_control.js b/frappe/public/js/frappe/form/controls/base_control.js index ce871c50cb..4ee52d16b8 100644 --- a/frappe/public/js/frappe/form/controls/base_control.js +++ b/frappe/public/js/frappe/form/controls/base_control.js @@ -39,6 +39,9 @@ frappe.ui.form.Control = class BaseControl { if (this.df.get_status) { return this.df.get_status(this); } + if (this.df.is_virtual) { + return "Read"; + } if ((!this.doctype && !this.docname) || this.df.parenttype === 'Web Form' || this.df.is_web_form) { // like in case of a dialog box @@ -52,7 +55,7 @@ frappe.ui.form.Control = class BaseControl { if(explain) console.log("By Hidden Dependency: None"); // eslint-disable-line no-console return "None"; - } else if (cint(this.df.read_only)) { + } else if (cint(this.df.read_only || this.df.is_virtual)) { // eslint-disable-next-line if (explain) console.log("By Read Only: Read"); // eslint-disable-line no-console return "Read"; diff --git a/frappe/public/js/frappe/form/controls/link.js b/frappe/public/js/frappe/form/controls/link.js index 9f02485a9e..2295cad41a 100644 --- a/frappe/public/js/frappe/form/controls/link.js +++ b/frappe/public/js/frappe/form/controls/link.js @@ -29,7 +29,7 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat setTimeout(function() { if(me.$input.val() && me.get_options()) { let doctype = me.get_options(); - let name = me.$input.val(); + let name = me.get_input_value(); me.$link.toggle(true); me.$link_open.attr('href', frappe.utils.get_form_link(doctype, name)); } @@ -69,6 +69,59 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat this.$input_area.find(".link-btn").remove(); } } + set_formatted_input(value) { + super.set_formatted_input(); + if (!value) return; + + if (!this.title_value_map) { + this.title_value_map = {}; + } + this.set_link_title(value); + } + set_link_title(value) { + let doctype = this.get_options(); + + if (!doctype) return; + + if (in_list(frappe.boot.link_title_doctypes, doctype)) { + let link_title = frappe.utils.get_link_title(doctype, value); + if (!link_title) { + link_title = frappe.utils + .fetch_link_title(doctype, value) + .then(link_title => { + this.set_input_value(link_title); + this.title_value_map[link_title] = value; + }); + } else { + this.set_input_value(link_title); + this.title_value_map[link_title] = value; + } + } else { + this.set_input_value(value); + } + } + parse_validate_and_set_in_model(value, e, label) { + if (this.parse) value = this.parse(value, label); + if (label) { + this.label = label; + frappe.utils.add_link_title(this.df.options, value, label); + } + + return this.validate_and_set_in_model(value, e); + } + get_input_value() { + if (this.$input) { + const input_value = this.$input.val(); + return this.title_value_map?.[input_value] || input_value; + } + return null; + } + get_label_value() { + return this.$input ? this.$input.val() : ""; + } + set_input_value(value) { + this.$input && this.$input.val(value); + } open_advanced_search() { var doctype = this.get_options(); if(!doctype) return; @@ -98,7 +151,7 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat } // partially entered name field - frappe.route_options.name_field = this.get_value(); + frappe.route_options.name_field = this.get_label_value(); // reference to calling link frappe._from_link = this; @@ -120,6 +173,11 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat maxItems: 99, autoFirst: true, list: [], + replace: function (suggestion) { + // Override Awesomeplete replace function as it is used to set the input value + // https://github.com/LeaVerou/awesomplete/issues/17104#issuecomment-359185403 + this.input.value = suggestion.label || suggestion.value; + }, data: function (item) { return { label: item.label || item.value, @@ -236,9 +294,11 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat me.selected = false; return; } - var value = me.get_input_value(); - if(value!==me.last_value) { - me.parse_validate_and_set_in_model(value); + let value = me.get_input_value(); + let label = me.get_label_value(); + + if (value !== me.last_value || me.label !== label) { + me.parse_validate_and_set_in_model(value, null, label); } }); @@ -258,14 +318,15 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat // prevent selection on tab var TABKEY = 9; - if(e.keyCode === TABKEY) { + if (e.keyCode === TABKEY) { e.preventDefault(); me.awesomplete.close(); return false; } - if(item.action) { + if (item.action) { item.value = ""; + item.label = ""; item.action.apply(me); } @@ -277,12 +338,12 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat frappe.boot.user.last_selected_values[me.df.options] = item.value; } - me.parse_validate_and_set_in_model(item.value); + me.parse_validate_and_set_in_model(item.value, null, item.label); }); this.$input.on("awesomplete-selectcomplete", function(e) { - var o = e.originalEvent; - if(o.text.value.indexOf("__link_option") !== -1) { + let o = e.originalEvent; + if (o.text.value.indexOf("__link_option") !== -1) { me.$input.val(""); } }); diff --git a/frappe/public/js/frappe/form/controls/multiselect_pills.js b/frappe/public/js/frappe/form/controls/multiselect_pills.js index b4a1ecf30d..bf93ac0dd8 100644 --- a/frappe/public/js/frappe/form/controls/multiselect_pills.js +++ b/frappe/public/js/frappe/form/controls/multiselect_pills.js @@ -83,15 +83,21 @@ frappe.ui.form.ControlMultiSelectPills = class ControlMultiSelectPills extends f } get_pill_html(value) { + const label = this.get_label(value); const encoded_value = encodeURIComponent(value); return ` `; } + get_label(value) { + const item = this._data?.find(d => d.value === value); + return item ? item.label || item.value : null; + } + get_awesomplete_settings() { const settings = super.get_awesomplete_settings(); diff --git a/frappe/public/js/frappe/form/controls/table_multiselect.js b/frappe/public/js/frappe/form/controls/table_multiselect.js index 15dfd9649e..477679bc92 100644 --- a/frappe/public/js/frappe/form/controls/table_multiselect.js +++ b/frappe/public/js/frappe/form/controls/table_multiselect.js @@ -49,7 +49,7 @@ frappe.ui.form.ControlTableMultiSelect = class ControlTableMultiSelect extends f setup_buttons() { this.$input_area.find('.link-btn').remove(); } - parse(value) { + parse(value, label) { const link_field = this.get_link_field(); if (value) { @@ -62,6 +62,7 @@ frappe.ui.form.ControlTableMultiSelect = class ControlTableMultiSelect extends f [link_field.fieldname]: value }); } + frappe.utils.add_link_title(link_field.options, value, label); } this._rows_list = this.rows.map(row => row[link_field.fieldname]); return this.rows; @@ -126,10 +127,12 @@ frappe.ui.form.ControlTableMultiSelect = class ControlTableMultiSelect extends f this.$input_area.prepend(html); } get_pill_html(value) { + const link_field = this.get_link_field(); const encoded_value = encodeURIComponent(value); + const pill_name = frappe.utils.get_link_title(link_field.options, value) || value; return ` `; diff --git a/frappe/public/js/frappe/form/formatters.js b/frappe/public/js/frappe/form/formatters.js index fd3fcb1bc7..c39c4046b4 100644 --- a/frappe/public/js/frappe/form/formatters.js +++ b/frappe/public/js/frappe/form/formatters.js @@ -110,12 +110,14 @@ frappe.form.formatters = { Link: function(value, docfield, options, doc) { var doctype = docfield._options || docfield.options; var original_value = value; + let link_title = frappe.utils.get_link_title(doctype, value); + if(value && value.match && value.match(/^['"].*['"]$/)) { value.replace(/^.(.*).$/, "$1"); } if(options && (options.for_print || options.only_value)) { - return value; + return link_title || value; } if(frappe.form.link_formatters[doctype]) { @@ -139,13 +141,14 @@ frappe.form.formatters = { return ` - ${__(options && options.label || value)}`; + data-name="${original_value}" + data-value="${original_value}"> + ${__(options && options.label || link_title || value)}`; } else { - return value; + return link_title || value; } } else { - return value; + return link_title || value; } }, Date: function(value) { diff --git a/frappe/public/js/frappe/form/quick_entry.js b/frappe/public/js/frappe/form/quick_entry.js index e412b1dec8..86523d7088 100644 --- a/frappe/public/js/frappe/form/quick_entry.js +++ b/frappe/public/js/frappe/form/quick_entry.js @@ -55,7 +55,7 @@ frappe.ui.form.QuickEntryForm = class QuickEntryForm { // prepare a list of mandatory, bold and allow in quick entry fields this.mandatory = fields.filter(df => { - return ((df.reqd || df.bold || df.allow_in_quick_entry) && !df.read_only); + return ((df.reqd || df.bold || df.allow_in_quick_entry) && !df.read_only && !df.is_virtual); }); } diff --git a/frappe/public/js/frappe/form/save.js b/frappe/public/js/frappe/form/save.js index 934c90f017..da642b7ca5 100644 --- a/frappe/public/js/frappe/form/save.js +++ b/frappe/public/js/frappe/form/save.js @@ -249,30 +249,39 @@ frappe.ui.form.update_calling_link = (newdoc) => { }; if (is_valid_doctype()) { - // set value - if (doc && doc.parentfield) { - //update values for child table - $.each(frappe._from_link.frm.fields_dict[doc.parentfield].grid.grid_rows, function (index, field) { - if (field.doc && field.doc.name === frappe._from_link.docname) { - frappe._from_link.set_value(newdoc.name); - } - }); - } else { - frappe._from_link.set_value(newdoc.name); - } - - // refresh field - frappe._from_link.refresh(); - - // if from form, switch - if (frappe._from_link.frm) { - frappe.set_route("Form", - frappe._from_link.frm.doctype, frappe._from_link.frm.docname) - .then(() => { - frappe.utils.scroll_to(frappe._from_link_scrollY); + frappe.model.with_doctype(newdoc.doctype, () => { + let meta = frappe.get_meta(newdoc.doctype); + // set value + if (doc && doc.parentfield) { + //update values for child table + $.each(frappe._from_link.frm.fields_dict[doc.parentfield].grid.grid_rows, function (index, field) { + if (field.doc && field.doc.name === frappe._from_link.docname) { + if (meta.title_field && meta.show_title_field_in_link) { + frappe.utils.add_link_title(newdoc.doctype, newdoc.name, newdoc[meta.title_field]); + } + frappe._from_link.set_value(newdoc.name); + } }); - } + } else { + if (meta.title_field && meta.show_title_field_in_link) { + frappe.utils.add_link_title(newdoc.doctype, newdoc.name, newdoc[meta.title_field]); + } + frappe._from_link.set_value(newdoc.name); + } - frappe._from_link = null; + // refresh field + frappe._from_link.refresh(); + + // if from form, switch + if (frappe._from_link.frm) { + frappe.set_route("Form", + frappe._from_link.frm.doctype, frappe._from_link.frm.docname) + .then(() => { + frappe.utils.scroll_to(frappe._from_link_scrollY); + }); + } + + frappe._from_link = null; + }); } } diff --git a/frappe/public/js/frappe/form/script_manager.js b/frappe/public/js/frappe/form/script_manager.js index 6169fa75b8..29f1c86d17 100644 --- a/frappe/public/js/frappe/form/script_manager.js +++ b/frappe/public/js/frappe/form/script_manager.js @@ -192,9 +192,18 @@ frappe.ui.form.ScriptManager = class ScriptManager { } function setup_add_fetch(df) { - if ((['Data', 'Read Only', 'Text', 'Small Text', 'Currency', 'Check', 'Attach Image', - 'Text Editor', 'Code', 'Link', 'Float', 'Int', 'Date', 'Select', 'Duration'].includes(df.fieldtype) || df.read_only==1) - && df.fetch_from && df.fetch_from.indexOf(".")!=-1) { + let is_read_only_field = ( + ['Data', 'Read Only', 'Text', 'Small Text', 'Currency', 'Check', 'Text Editor', 'Attach Image', + 'Code', 'Link', 'Float', 'Int', 'Date', 'Select', 'Duration'].includes(df.fieldtype) + || df.read_only == 1 + || df.is_virtual == 1 + ) + + if ( + is_read_only_field + && df.fetch_from + && df.fetch_from.indexOf(".") != -1 + ) { var parts = df.fetch_from.split("."); me.frm.add_fetch(parts[0], parts[1], df.fieldname, df.parent); } diff --git a/frappe/public/js/frappe/list/base_list.js b/frappe/public/js/frappe/list/base_list.js index 8fa512bfc0..ed27bdb29e 100644 --- a/frappe/public/js/frappe/list/base_list.js +++ b/frappe/public/js/frappe/list/base_list.js @@ -394,7 +394,6 @@ frappe.views.BaseList = class BaseList { this.page_length = $this.data().value; } else if ($this.is(".btn-more")) { this.start = this.start + this.page_length; - this.page_length = 20; } this.refresh(); }); @@ -475,7 +474,6 @@ frappe.views.BaseList = class BaseList { this.render(); this.after_render(); this.freeze(false); - this.reset_defaults(); if (this.settings.refresh) { this.settings.refresh(this); } @@ -502,11 +500,6 @@ frappe.views.BaseList = class BaseList { this.data = this.data.uniqBy((d) => d.name); } - reset_defaults() { - this.page_length = this.page_length + this.start; - this.start = 0; - } - freeze() { // show a freeze message while data is loading } diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index 3cde04313f..64960e0b09 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -1672,7 +1672,8 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { frappe.model.is_value_type(field_doc) && field_doc.fieldtype !== "Read Only" && !field_doc.hidden && - !field_doc.read_only + !field_doc.read_only && + !field_doc.is_virtual ); }; diff --git a/frappe/public/js/frappe/request.js b/frappe/public/js/frappe/request.js index 35f946df65..5766619d59 100644 --- a/frappe/public/js/frappe/request.js +++ b/frappe/public/js/frappe/request.js @@ -260,6 +260,14 @@ frappe.request.call = function(opts) { $.extend(frappe._messages, data.__messages); } + // sync link titles + if (data._link_titles) { + if (!frappe._link_titles) { + frappe._link_titles = {}; + } + $.extend(frappe._link_titles, data._link_titles); + } + // callbacks var status_code_handler = statusCode[xhr.statusCode().status]; if (status_code_handler) { diff --git a/frappe/public/js/frappe/ui/filters/filter.js b/frappe/public/js/frappe/ui/filters/filter.js index f5726d3a29..91922b0bc1 100644 --- a/frappe/public/js/frappe/ui/filters/filter.js +++ b/frappe/public/js/frappe/ui/filters/filter.js @@ -314,6 +314,10 @@ frappe.ui.Filter = class { return this.utils.get_selected_value(this.field, this.get_condition()); } + get_selected_label() { + return this.utils.get_selected_label(this.field); + } + get_condition() { return this.filter_edit_area.find('.condition').val(); } @@ -361,7 +365,7 @@ frappe.ui.Filter = class { get_filter_button_text() { let value = this.utils.get_formatted_value( this.field, - this.get_selected_value() + this.get_selected_label() || this.get_selected_value() ); return `${__(this.field.df.label)} ${__(this.get_condition())} ${__( value @@ -449,6 +453,12 @@ frappe.ui.filter_utils = { return val; }, + get_selected_label(field) { + if (in_list(["Link", "Dynamic Link"], field.df.fieldtype)) { + return field.get_label_value(); + } + }, + get_default_condition(df) { if (df.fieldtype == 'Data') { return 'like'; diff --git a/frappe/public/js/frappe/utils/common.js b/frappe/public/js/frappe/utils/common.js index b324cecd39..1f3558b367 100644 --- a/frappe/public/js/frappe/utils/common.js +++ b/frappe/public/js/frappe/utils/common.js @@ -259,8 +259,16 @@ frappe.utils.xss_sanitise = function (string, options) { '/': '/' }; const REGEX_SCRIPT = /)<[^<]*)*<\/script>/gi; // used in jQuery 1.7.2 src/ajax.js Line 14 + const REGEX_ALERT = /confirm\(.*\)|alert\(.*\)|prompt\(.*\)/gi; // captures alert, confirm, prompt options = Object.assign({}, DEFAULT_OPTIONS, options); // don't deep copy, immutable beauty. + // Rule 3 - TODO: Check event handlers? + // script and alert should be checked first or else it will be escaped + if (options.strategies.includes('js')) { + sanitised = sanitised.replace(REGEX_SCRIPT, ""); + sanitised = sanitised.replace(REGEX_ALERT, ""); + } + // Rule 1 if (options.strategies.includes('html')) { for (let char in HTML_ESCAPE_MAP) { @@ -270,11 +278,6 @@ frappe.utils.xss_sanitise = function (string, options) { } } - // Rule 3 - TODO: Check event handlers? - if (options.strategies.includes('js')) { - sanitised = sanitised.replace(REGEX_SCRIPT, ""); - } - return sanitised; } diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js index 725296a121..dc75239ed5 100644 --- a/frappe/public/js/frappe/utils/utils.js +++ b/frappe/public/js/frappe/utils/utils.js @@ -1416,5 +1416,42 @@ Object.assign(frappe.utils, { arr.push(i); } return arr; + }, + + get_link_title(doctype, name) { + if (!doctype || !name || !frappe._link_titles) { + return; + } + + return frappe._link_titles[doctype + "::" + name]; + }, + + add_link_title(doctype, name, value) { + if (!doctype || !name) { + return; + } + + if (!frappe._link_titles) { + // for link titles + frappe._link_titles = {}; + } + + frappe._link_titles[doctype + "::" + name] = value; + }, + + fetch_link_title(doctype, name) { + try { + return frappe.xcall("frappe.desk.search.get_link_title", { + "doctype": doctype, + "docname": name + }).then(title => { + frappe.utils.add_link_title(doctype, name, title); + return title; + }); + } catch (error) { + console.log('Error while fetching link title.'); // eslint-disable-line + console.log(error); // eslint-disable-line + return Promise.resolve(name); + } } }); diff --git a/frappe/public/js/frappe/views/reports/report_view.js b/frappe/public/js/frappe/views/reports/report_view.js index 1291e63543..25c0c512ff 100644 --- a/frappe/public/js/frappe/views/reports/report_view.js +++ b/frappe/public/js/frappe/views/reports/report_view.js @@ -648,6 +648,7 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView { // not a cancelled doc && data.docstatus !== 2 && !df.read_only + && !df.is_virtual && !df.hidden // not a standard field i.e., owner, modified_by, etc. && !frappe.model.std_fields_list.includes(df.fieldname)) @@ -1029,7 +1030,7 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView { title += ` (${__(doctype)})`; } - const editable = frappe.model.is_non_std_field(fieldname) && !docfield.read_only; + const editable = frappe.model.is_non_std_field(fieldname) && !docfield.read_only && !docfield.is_virtual; const align = (() => { const is_numeric = frappe.model.is_numeric_field(docfield); diff --git a/frappe/public/js/frappe/views/treeview.js b/frappe/public/js/frappe/views/treeview.js index 7179e4ab56..d5c6fb5e80 100644 --- a/frappe/public/js/frappe/views/treeview.js +++ b/frappe/public/js/frappe/views/treeview.js @@ -343,7 +343,7 @@ frappe.views.TreeView = class TreeView { this.ignore_fields = this.opts.ignore_fields || []; var mandatory_fields = $.map(me.opts.meta.fields, function(d) { - return (d.reqd || d.bold && !d.read_only) ? d : null }); + return (d.reqd || d.bold && !d.read_only && !!d.is_virtual) ? d : null }); var opts_field_names = this.fields.map(function(d) { return d.fieldname diff --git a/frappe/public/scss/common/css_variables.scss b/frappe/public/scss/common/css_variables.scss index b8b7f869fa..5871ac3626 100644 --- a/frappe/public/scss/common/css_variables.scss +++ b/frappe/public/scss/common/css_variables.scss @@ -136,6 +136,8 @@ --shadow-md: 0px 8px 14px rgba(25, 39, 52, 0.08), 0px 2px 6px rgba(25, 39, 52, 0.04); --shadow-lg: 0px 18px 22px rgba(25, 39, 52, 0.1), 0px 1px 10px rgba(0, 0, 0, 0.06), 0px 0.5px 5px rgba(25, 39, 52, 0.04); + --drop-shadow: 0px 0.5px 0px rgba(0, 0, 0, 0.05), 0px 0px 0px rgba(0, 0, 0, 0), 0px 2px 4px rgba(0, 0, 0, 0.05); + --modal-shadow: var(--shadow-md); --card-shadow: var(--shadow-sm); --btn-shadow: var(--shadow-xs); diff --git a/frappe/public/scss/desk/list.scss b/frappe/public/scss/desk/list.scss index a49b5a463e..b6cf6d7711 100644 --- a/frappe/public/scss/desk/list.scss +++ b/frappe/public/scss/desk/list.scss @@ -187,7 +187,31 @@ $level-margin-right: 8px; } .list-paging-area, .footnote-area { - border-top: 1px sol var(--border-color); + border-top: 1px solid var(--border-color); + + .btn-group { + box-shadow: var(--drop-shadow); + border-radius: var(--border-radius-md); + + &> .btn:nth-child(2) { + border-left: none; + border-right: none; + } + + .btn-paging { + box-shadow: none; + margin-left: 0px !important; + border: 1px solid var(--dark-border-color); + + &.btn-info { + background-color: var(--gray-400); + border-color: var(--gray-400); + color: var(--white); + font-weight: var(--text-bold); + } + } + } + } .frappe-card { diff --git a/frappe/public/scss/desk/sidebar.scss b/frappe/public/scss/desk/sidebar.scss index 82bd1cdf13..0bb6ba5f40 100644 --- a/frappe/public/scss/desk/sidebar.scss +++ b/frappe/public/scss/desk/sidebar.scss @@ -104,10 +104,10 @@ body[data-route^="Module"] .main-menu { } .sidebar-image-section { + width: min(100%, 170px); cursor: pointer; .sidebar-image { - width: min(100%, 170px); height: auto; max-height: 170px; object-fit: cover; diff --git a/frappe/tests/test_api.py b/frappe/tests/test_api.py index 32a5ebbd72..93875f7784 100644 --- a/frappe/tests/test_api.py +++ b/frappe/tests/test_api.py @@ -1,77 +1,126 @@ +import sys import unittest +from contextlib import contextmanager from random import choice +from threading import Thread +from typing import Dict, Optional, Tuple +from unittest.mock import patch import requests from semantic_version import Version +from werkzeug.test import TestResponse import frappe -from frappe.utils import get_site_url +from frappe.utils import get_site_url, get_test_client + +try: + _site = frappe.local.site +except Exception: + _site = None + +authorization_token = None + +@contextmanager +def suppress_stdout(): + """Supress stdout for tests which expectedly make noise + but that you don't need in tests""" + sys.stdout = None + try: + yield + finally: + sys.stdout = sys.__stdout__ -def maintain_state(f): - def wrapper(*args, **kwargs): - frappe.db.rollback() - r = f(*args, **kwargs) - frappe.db.commit() - return r - - return wrapper +def make_request(target: str, args: Optional[Tuple] = None, kwargs: Optional[Dict] = None) -> TestResponse: + t = ThreadWithReturnValue(target=target, args=args, kwargs=kwargs) + t.start() + t.join() + return t._return -class TestResourceAPI(unittest.TestCase): - SITE_URL = get_site_url(frappe.local.site) +def patch_request_header(key, *args, **kwargs): + if key == "Authorization": + return f"token {authorization_token}" + + +class ThreadWithReturnValue(Thread): + def __init__(self, group=None, target=None, name=None, args=(), kwargs={}): + Thread.__init__(self, group, target, name, args, kwargs) + self._return = None + + def run(self): + if self._target is not None: + with patch("frappe.app.get_site_name", return_value=_site): + header_patch = patch("frappe.get_request_header", new=patch_request_header) + if authorization_token: + header_patch.start() + self._return = self._target(*self._args, **self._kwargs) + if authorization_token: + header_patch.stop() + + def join(self, *args): + Thread.join(self, *args) + return self._return + + +class FrappeAPITestCase(unittest.TestCase): + SITE = frappe.local.site + SITE_URL = get_site_url(SITE) RESOURCE_URL = f"{SITE_URL}/api/resource" + TEST_CLIENT = get_test_client() + + @property + def sid(self) -> str: + if not getattr(self, "_sid", None): + r = self.post("/api/method/login", { + "usr": "Administrator", + "pwd": frappe.conf.admin_password or "admin", + }) + self._sid = r.headers[2][1].split(";")[0].lstrip("sid=") + + return self._sid + + def get(self, path: str, params: Optional[Dict] = None) -> TestResponse: + return make_request(target=self.TEST_CLIENT.get, args=(path, ), kwargs={"data": params}) + + def post(self, path, data) -> TestResponse: + return make_request(target=self.TEST_CLIENT.post, args=(path, ), kwargs={"data": data}) + + def put(self, path, data) -> TestResponse: + return make_request(target=self.TEST_CLIENT.put, args=(path, ), kwargs={"data": data}) + + def delete(self, path) -> TestResponse: + return make_request(target=self.TEST_CLIENT.delete, args=(path, )) + + +class TestResourceAPI(FrappeAPITestCase): DOCTYPE = "ToDo" GENERATED_DOCUMENTS = [] @classmethod - @maintain_state - def setUpClass(self): + def setUpClass(cls): for _ in range(10): doc = frappe.get_doc( {"doctype": "ToDo", "description": frappe.mock("paragraph")} ).insert() - self.GENERATED_DOCUMENTS.append(doc.name) + cls.GENERATED_DOCUMENTS.append(doc.name) + frappe.db.commit() @classmethod - @maintain_state - def tearDownClass(self): - for name in self.GENERATED_DOCUMENTS: - frappe.delete_doc_if_exists(self.DOCTYPE, name) + def tearDownClass(cls): + for name in cls.GENERATED_DOCUMENTS: + frappe.delete_doc_if_exists(cls.DOCTYPE, name) + frappe.db.commit() def setUp(self): # commit to ensure consistency in session (postgres CI randomly fails) if frappe.conf.db_type == "postgres": frappe.db.commit() - @property - def sid(self): - if not getattr(self, "_sid", None): - self._sid = requests.post( - f"{self.SITE_URL}/api/method/login", - data={ - "usr": "Administrator", - "pwd": frappe.conf.admin_password or "admin", - }, - ).cookies.get("sid") - - return self._sid - - def get(self, path, params=""): - return requests.get(f"{self.RESOURCE_URL}/{path}?sid={self.sid}{params}") - - def post(self, path, data): - return requests.post( - f"{self.RESOURCE_URL}/{path}?sid={self.sid}", data=frappe.as_json(data) - ) - - def put(self, path, data): - return requests.put( - f"{self.RESOURCE_URL}/{path}?sid={self.sid}", data=frappe.as_json(data) - ) - - def delete(self, path): - return requests.delete(f"{self.RESOURCE_URL}/{path}?sid={self.sid}") + if self._testMethodName == "test_auth_cycle": + from frappe.core.doctype.user.user import generate_keys + generate_keys("Administrator") + frappe.db.commit() def test_unauthorized_call(self): # test 1: fetch documents without auth @@ -80,88 +129,107 @@ class TestResourceAPI(unittest.TestCase): def test_get_list(self): # test 2: fetch documents without params - response = self.get(self.DOCTYPE) + response = self.get(f"/api/resource/{self.DOCTYPE}", {"sid": self.sid}) self.assertEqual(response.status_code, 200) - self.assertIsInstance(response.json(), dict) - self.assertIn("data", response.json()) + self.assertIsInstance(response.json, dict) + self.assertIn("data", response.json) def test_get_list_limit(self): # test 3: fetch data with limit - response = self.get(self.DOCTYPE, "&limit=2") + response = self.get(f"/api/resource/{self.DOCTYPE}", {"sid": self.sid, "limit": 2}) self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.json()["data"]), 2) + self.assertEqual(len(response.json["data"]), 2) def test_get_list_dict(self): # test 4: fetch response as (not) dict - response = self.get(self.DOCTYPE, "&as_dict=True") - json = frappe._dict(response.json()) + response = self.get(f"/api/resource/{self.DOCTYPE}", {"sid": self.sid, "as_dict": True}) + json = frappe._dict(response.json) self.assertEqual(response.status_code, 200) self.assertIsInstance(json.data, list) self.assertIsInstance(json.data[0], dict) - response = self.get(self.DOCTYPE, "&as_dict=False") - json = frappe._dict(response.json()) + response = self.get(f"/api/resource/{self.DOCTYPE}", {"sid": self.sid, "as_dict": False}) + json = frappe._dict(response.json) self.assertEqual(response.status_code, 200) self.assertIsInstance(json.data, list) self.assertIsInstance(json.data[0], list) def test_get_list_debug(self): # test 5: fetch response with debug - response = self.get(self.DOCTYPE, "&debug=true") + response = self.get(f"/api/resource/{self.DOCTYPE}", {"sid": self.sid, "debug": True}) self.assertEqual(response.status_code, 200) - self.assertIn("exc", response.json()) - self.assertIsInstance(response.json()["exc"], str) - self.assertIsInstance(eval(response.json()["exc"]), list) + self.assertIn("exc", response.json) + self.assertIsInstance(response.json["exc"], str) + self.assertIsInstance(eval(response.json["exc"]), list) def test_get_list_fields(self): # test 6: fetch response with fields - response = self.get(self.DOCTYPE, r'&fields=["description"]') + response = self.get(f"/api/resource/{self.DOCTYPE}", {"sid": self.sid, "fields": '["description"]'}) self.assertEqual(response.status_code, 200) - json = frappe._dict(response.json()) + json = frappe._dict(response.json) self.assertIn("description", json.data[0]) def test_create_document(self): # test 7: POST method on /api/resource to create doc - data = {"description": frappe.mock("paragraph")} - response = self.post(self.DOCTYPE, data) + data = {"description": frappe.mock("paragraph"), "sid": self.sid} + response = self.post(f"/api/resource/{self.DOCTYPE}", data) self.assertEqual(response.status_code, 200) - docname = response.json()["data"]["name"] + docname = response.json["data"]["name"] self.assertIsInstance(docname, str) self.GENERATED_DOCUMENTS.append(docname) def test_update_document(self): # test 8: PUT method on /api/resource to update doc generated_desc = frappe.mock("paragraph") - data = {"description": generated_desc} + data = {"description": generated_desc, "sid": self.sid} random_doc = choice(self.GENERATED_DOCUMENTS) desc_before_update = frappe.db.get_value(self.DOCTYPE, random_doc, "description") - response = self.put(f"{self.DOCTYPE}/{random_doc}", data=data) + response = self.put(f"/api/resource/{self.DOCTYPE}/{random_doc}", data=data) self.assertEqual(response.status_code, 200) - self.assertNotEqual(response.json()["data"]["description"], desc_before_update) - self.assertEqual(response.json()["data"]["description"], generated_desc) + self.assertNotEqual(response.json["data"]["description"], desc_before_update) + self.assertEqual(response.json["data"]["description"], generated_desc) def test_delete_document(self): # test 9: DELETE method on /api/resource doc_to_delete = choice(self.GENERATED_DOCUMENTS) - response = self.delete(f"{self.DOCTYPE}/{doc_to_delete}") + response = self.delete(f"/api/resource/{self.DOCTYPE}/{doc_to_delete}") self.assertEqual(response.status_code, 202) - self.assertDictEqual(response.json(), {"message": "ok"}) + self.assertDictEqual(response.json, {"message": "ok"}) self.GENERATED_DOCUMENTS.remove(doc_to_delete) non_existent_doc = frappe.generate_hash(length=12) - response = self.delete(f"{self.DOCTYPE}/{non_existent_doc}") + with suppress_stdout(): + response = self.delete(f"/api/resource/{self.DOCTYPE}/{non_existent_doc}") self.assertEqual(response.status_code, 404) - self.assertDictEqual(response.json(), {}) + self.assertDictEqual(response.json, {}) + + def test_run_doc_method(self): + # test 10: Run whitelisted method on doc via /api/resource + # status_code is 403 if no other tests are run before this - it's not logged in + self.post("/api/resource/Website Theme/Standard", {"run_method": "get_apps"}) + response = self.get("/api/resource/Website Theme/Standard", {"run_method": "get_apps"}) + self.assertIn(response.status_code, (403, 200)) + + if response.status_code == 403: + self.assertTrue(set(response.json.keys()) == {'exc_type', 'exception', 'exc', '_server_messages'}) + self.assertEqual(response.json.get('exc_type'), 'PermissionError') + self.assertEqual(response.json.get('exception'), 'frappe.exceptions.PermissionError: Not permitted') + self.assertIsInstance(response.json.get('exc'), str) + + elif response.status_code == 200: + data = response.json.get("data") + self.assertIsInstance(data, list) + self.assertIsInstance(data[0], dict) -class TestMethodAPI(unittest.TestCase): - METHOD_URL = f"{get_site_url(frappe.local.site)}/api/method" +class TestMethodAPI(FrappeAPITestCase): + METHOD_PATH = "/api/method" def test_version(self): # test 1: test for /api/method/version - response = requests.get(f"{self.METHOD_URL}/version") - json = frappe._dict(response.json()) + response = self.get(f"{self.METHOD_PATH}/version") + json = frappe._dict(response.json) self.assertEqual(response.status_code, 200) self.assertIsInstance(json, dict) @@ -170,7 +238,27 @@ class TestMethodAPI(unittest.TestCase): def test_ping(self): # test 2: test for /api/method/ping - response = requests.get(f"{self.METHOD_URL}/ping") + response = self.get(f"{self.METHOD_PATH}/ping") self.assertEqual(response.status_code, 200) - self.assertIsInstance(response.json(), dict) - self.assertEqual(response.json()['message'], "pong") + self.assertIsInstance(response.json, dict) + self.assertEqual(response.json["message"], "pong") + + def test_get_user_info(self): + # test 3: test for /api/method/frappe.realtime.get_user_info + response = self.get(f"{self.METHOD_PATH}/frappe.realtime.get_user_info") + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response.json, dict) + self.assertIn(response.json.get("message").get("user"), ("Administrator", "Guest")) + + def test_auth_cycle(self): + # test 4: Pass authorization token in request + global authorization_token + user = frappe.get_doc("User", "Administrator") + api_key, api_secret = user.api_key, user.get_password("api_secret") + authorization_token = f"{api_key}:{api_secret}" + response = self.get("/api/method/frappe.auth.get_logged_user") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json["message"], "Administrator") + + authorization_token = None diff --git a/frappe/tests/test_db.py b/frappe/tests/test_db.py index 6e96849b35..bbd09590be 100644 --- a/frappe/tests/test_db.py +++ b/frappe/tests/test_db.py @@ -291,6 +291,16 @@ class TestDB(unittest.TestCase): frappe.db.MAX_WRITES_PER_TRANSACTION = Database.MAX_WRITES_PER_TRANSACTION + def test_pk_collision_ignoring(self): + # note has `name` generated from title + for _ in range(3): + frappe.get_doc(doctype="Note", title="duplicate name").insert(ignore_if_duplicate=True) + + with savepoint(): + self.assertRaises(frappe.DuplicateEntryError, frappe.get_doc(doctype="Note", title="duplicate name").insert) + # recover transaction to continue other tests + raise Exception + @run_only_if(db_type_is.MARIADB) class TestDDLCommandsMaria(unittest.TestCase): diff --git a/frappe/tests/test_document.py b/frappe/tests/test_document.py index 34a1dd070c..18f104c28d 100644 --- a/frappe/tests/test_document.py +++ b/frappe/tests/test_document.py @@ -1,11 +1,20 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE -import os import unittest +from contextlib import contextmanager +from datetime import timedelta +from unittest.mock import patch import frappe -from frappe.utils import cint -from frappe.model.naming import revert_series_if_last, make_autoname, parse_naming_series +from frappe.desk.doctype.note.note import Note +from frappe.model.naming import make_autoname, parse_naming_series, revert_series_if_last +from frappe.utils import cint, now_datetime + + +class CustomTestNote(Note): + @property + def age(self): + return now_datetime() - self.creation class TestDocument(unittest.TestCase): @@ -255,5 +264,58 @@ class TestDocument(unittest.TestCase): def test_limit_for_get(self): doc = frappe.get_doc("DocType", "DocType") - # assuming DocType has more that 3 Data fields - self.assertEquals(len(doc.get("fields", filters={"fieldtype": "Data"}, limit=3)), 3) \ No newline at end of file + # assuming DocType has more than 3 Data fields + self.assertEquals(len(doc.get("fields", limit=3)), 3) + + # limit with filters + self.assertEquals(len(doc.get("fields", filters={"fieldtype": "Data"}, limit=3)), 3) + + def test_virtual_fields(self): + """Virtual fields are accessible via API and Form views, whenever .as_dict is invoked + """ + frappe.db.delete("Custom Field", {"dt": "Note", "fieldname":"age"}) + note = frappe.new_doc("Note") + note.content = "some content" + note.title = frappe.generate_hash(length=20) + note.insert() + + def patch_note(): + return patch("frappe.controllers", new={frappe.local.site: {'Note': CustomTestNote}}) + + @contextmanager + def customize_note(with_options=False): + options = "frappe.utils.now_datetime() - doc.creation" if with_options else "" + custom_field = frappe.get_doc({ + "doctype": "Custom Field", + "dt": "Note", + "fieldname": "age", + "fieldtype": "Data", + "read_only": True, + "is_virtual": True, + "options": options, + }) + + try: + yield custom_field.insert(ignore_if_duplicate=True) + finally: + custom_field.delete() + + with patch_note(): + doc = frappe.get_last_doc("Note") + self.assertIsInstance(doc, CustomTestNote) + self.assertIsInstance(doc.age, timedelta) + self.assertIsNone(doc.as_dict().get("age")) + self.assertIsNone(doc.get_valid_dict().get("age")) + + with customize_note(), patch_note(): + doc = frappe.get_last_doc("Note") + self.assertIsInstance(doc, CustomTestNote) + self.assertIsInstance(doc.age, timedelta) + self.assertIsInstance(doc.as_dict().get("age"), timedelta) + self.assertIsInstance(doc.get_valid_dict().get("age"), timedelta) + + with customize_note(with_options=True): + doc = frappe.get_last_doc("Note") + self.assertIsInstance(doc, Note) + self.assertIsInstance(doc.as_dict().get("age"), timedelta) + self.assertIsInstance(doc.get_valid_dict().get("age"), timedelta) diff --git a/frappe/tests/test_frappe_client.py b/frappe/tests/test_frappe_client.py index e84163eb41..2d815d0731 100644 --- a/frappe/tests/test_frappe_client.py +++ b/frappe/tests/test_frappe_client.py @@ -10,7 +10,8 @@ import requests import base64 class TestFrappeClient(unittest.TestCase): - PASSWORD = "admin" + PASSWORD = frappe.conf.admin_password or "admin" + def test_insert_many(self): server = FrappeClient(get_url(), "Administrator", self.PASSWORD, verify=False) frappe.db.delete("Note", {"title": ("in", ('Sing','a','song','of','sixpence'))}) @@ -169,7 +170,6 @@ class TestFrappeClient(unittest.TestCase): res = requests.post(get_url() + "/api/method/frappe.auth.get_logged_user", headers=header) self.assertEqual(res.status_code, 403) - # random api key and api secret api_key = "@3djdk3kld" api_secret = "ksk&93nxoe3os" diff --git a/frappe/tests/test_utils.py b/frappe/tests/test_utils.py index 27d1f7651d..15029e961a 100644 --- a/frappe/tests/test_utils.py +++ b/frappe/tests/test_utils.py @@ -419,3 +419,96 @@ class TestXlsxUtils(unittest.TestCase): val = handle_html("

html data >

") self.assertIn("html data >", val) self.assertEqual("abc", handle_html("abc")) + + +class TestLinkTitle(unittest.TestCase): + def test_link_title_doctypes_in_boot_info(self): + """ + Test that doctypes are added to link_title_map in boot_info + """ + custom_doctype = frappe.get_doc( + { + "doctype": "DocType", + "module": "Core", + "custom": 1, + "fields": [ + { + "label": "Test Field", + "fieldname": "test_title_field", + "fieldtype": "Data", + } + ], + "show_title_field_in_link": 1, + "title_field": "test_title_field", + "permissions": [{"role": "System Manager", "read": 1}], + "name": "Test Custom Doctype for Link Title", + } + ) + custom_doctype.insert() + + prop_setter = frappe.get_doc( + { + "doctype": "Property Setter", + "doc_type": "User", + "property": "show_title_field_in_link", + "property_type": "Check", + "doctype_or_field": "DocType", + "value": "1", + } + ).insert() + + from frappe.boot import get_link_title_doctypes + + link_title_doctypes = get_link_title_doctypes() + self.assertTrue("User" in link_title_doctypes) + self.assertTrue("Test Custom Doctype for Link Title" in link_title_doctypes) + + prop_setter.delete() + custom_doctype.delete() + + def test_link_titles_on_getdoc(self): + """ + Test that link titles are added to the doctype on getdoc + """ + prop_setter = frappe.get_doc( + { + "doctype": "Property Setter", + "doc_type": "User", + "property": "show_title_field_in_link", + "property_type": "Check", + "doctype_or_field": "DocType", + "value": "1", + } + ).insert() + + user = frappe.get_doc( + { + "doctype": "User", + "user_type": "Website User", + "email": "test_user_for_link_title@example.com", + "send_welcome_email": 0, + "first_name": "Test User for Link Title", + } + ).insert(ignore_permissions=True) + + todo = frappe.get_doc( + { + "doctype": "ToDo", + "description": "test-link-title-on-getdoc", + "allocated_to": user.name, + } + ).insert() + + from frappe.desk.form.load import getdoc + + getdoc("ToDo", todo.name) + link_titles = frappe.local.response["_link_titles"] + + self.assertTrue(f"{user.doctype}::{user.name}" in link_titles) + self.assertEqual(link_titles[f"{user.doctype}::{user.name}"], user.full_name) + + todo.delete() + user.delete() + prop_setter.delete() + + diff --git a/frappe/tests/ui_test_helpers.py b/frappe/tests/ui_test_helpers.py index 67c58a1154..986494bfce 100644 --- a/frappe/tests/ui_test_helpers.py +++ b/frappe/tests/ui_test_helpers.py @@ -134,6 +134,12 @@ def create_contact_records(): insert_contact('Test Form Contact 2', '54321') insert_contact('Test Form Contact 3', '12345') +@frappe.whitelist() +def create_multiple_contact_records(): + if frappe.db.get_all('Contact', {'first_name': 'Multiple Contact 1'}): + return + for index in range(1001): + insert_contact('Multiple Contact {}'.format(index+1), '12345{}'.format(index+1)) def insert_contact(first_name, phone_number): doc = frappe.get_doc({ diff --git a/frappe/translations/de.csv b/frappe/translations/de.csv index f682a51e17..17151a14eb 100644 --- a/frappe/translations/de.csv +++ b/frappe/translations/de.csv @@ -148,6 +148,8 @@ More Information,Mehr Informationen, More...,Mehr..., Move,Bewegen, My Account,Mein Konto, +My Profile,Mein Profil, +My Settings,Meine Einstellungen, New Address,Neue Adresse, New Contact,Neuer Kontakt, Next,Weiter, @@ -406,7 +408,7 @@ Allow Self Approval,Erlaube Selbstgenehmigung, Allow approval for creator of the document,Genehmigung für den Ersteller des Dokuments zulassen, Allow events in timeline,Ereignisse in der Zeitleiste zulassen, Allow in Quick Entry,In Schnelleingabe zulassen, -Allow on Submit,Beim Übertragen zulassen, +Allow on Submit,Änderungen zulassen wenn gebucht, Allow only one session per user,Nur eine Sitzung pro Benutzer zulassen, Allow page break inside tables,Seitenumbruch innerhalb von Tabellen erlauben, Allow saving if mandatory fields are not filled,Speichern trotz leerer Pflichtfelder zulassen, diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py index 141adb9ea6..1233bcd30f 100644 --- a/frappe/utils/__init__.py +++ b/frappe/utils/__init__.py @@ -438,7 +438,8 @@ def touch_file(path): os.utime(path, None) return path -def get_test_client(): +def get_test_client() -> Client: + """Returns an test instance of the Frappe WSGI""" from frappe.app import application return Client(application) diff --git a/frappe/utils/formatters.py b/frappe/utils/formatters.py index 9916853caf..ae925a0ab2 100644 --- a/frappe/utils/formatters.py +++ b/frappe/utils/formatters.py @@ -88,9 +88,14 @@ def format_value(value, df=None, doc=None, currency=None, translated=False, form return frappe.utils.markdown(value) elif df.get("fieldtype") == "Table MultiSelect": + values = [] meta = frappe.get_meta(df.options) link_field = [df for df in meta.fields if df.fieldtype == 'Link'][0] - values = [v.get(link_field.fieldname, 'asdf') for v in value] + for v in value: + v.update({'__link_titles': doc.get('__link_titles')}) + formatted_value = frappe.format_value(v.get(link_field.fieldname, ''), link_field, v) + values.append(formatted_value) + return ', '.join(values) elif df.get("fieldtype") == "Duration": @@ -100,4 +105,19 @@ def format_value(value, df=None, doc=None, currency=None, translated=False, form elif df.get("fieldtype") == "Text Editor": return "
{}
".format(value) + elif df.get("fieldtype") in ["Link", "Dynamic Link"]: + if not doc or not doc.get("__link_titles") or not df.options: + return value + + doctype = df.options + if df.get("fieldtype") == "Dynamic Link": + if not df.parent: + return value + + meta = frappe.get_meta(df.parent) + _field = meta.get_field(df.options) + doctype = _field.options + + return doc.__link_titles.get("{0}::{1}".format(doctype, value), value) + return value diff --git a/frappe/website/doctype/web_form/web_form.js b/frappe/website/doctype/web_form/web_form.js index d69d21c64d..1f27b350be 100644 --- a/frappe/website/doctype/web_form/web_form.js +++ b/frappe/website/doctype/web_form/web_form.js @@ -60,7 +60,7 @@ frappe.ui.form.on("Web Form", { options: field.options, reqd: field.reqd, default: field.default, - read_only: field.read_only, + read_only: field.read_only || field.is_virtual, depends_on: field.depends_on, mandatory_depends_on: field.mandatory_depends_on, read_only_depends_on: field.read_only_depends_on, diff --git a/frappe/www/printview.py b/frappe/www/printview.py index bea1300764..9e337db47f 100644 --- a/frappe/www/printview.py +++ b/frappe/www/printview.py @@ -169,6 +169,48 @@ def get_rendered_template(doc, name=None, print_format=None, meta=None, return html +def set_link_titles(doc): + # Adds name with title of link field doctype to __link_titles + if not doc.get("__link_titles"): + setattr(doc, "__link_titles", {}) + + meta = frappe.get_meta(doc.doctype) + set_title_values_for_link_and_dynamic_link_fields(meta, doc) + set_title_values_for_table_and_multiselect_fields(meta, doc) + +def set_title_values_for_link_and_dynamic_link_fields(meta, doc, parent_doc=None): + if parent_doc and not parent_doc.get("__link_titles"): + setattr(parent_doc, "__link_titles", {}) + elif doc and not doc.get("__link_titles"): + setattr(doc, "__link_titles", {}) + + for field in meta.get_link_fields() + meta.get_dynamic_link_fields(): + if not doc.get(field.fieldname): + continue + + # If link field, then get doctype from options + # If dynamic link field, then get doctype from dependent field + doctype = field.options if field.fieldtype == "Link" else doc.get(field.options) + + meta = frappe.get_meta(doctype) + if not meta or not (meta.title_field and meta.show_title_field_in_link): + continue + + link_title = frappe.get_cached_value(doctype, doc.get(field.fieldname), meta.title_field) + if parent_doc: + parent_doc.__link_titles["{0}::{1}".format(doctype, doc.get(field.fieldname))] = link_title + elif doc: + doc.__link_titles["{0}::{1}".format(doctype, doc.get(field.fieldname))] = link_title + +def set_title_values_for_table_and_multiselect_fields(meta, doc): + for field in meta.get_table_fields(): + if not doc.get(field.fieldname): + continue + + _meta = frappe.get_meta(field.options) + for value in doc.get(field.fieldname): + set_title_values_for_link_and_dynamic_link_fields(_meta, value, doc) + def convert_markdown(doc, meta): '''Convert text field values to markdown if necessary''' for field in meta.fields: @@ -190,6 +232,7 @@ def get_html_and_style(doc, name=None, print_format=None, meta=None, doc = frappe.get_doc(json.loads(doc)) print_format = get_print_format_doc(print_format, meta=meta or frappe.get_meta(doc.doctype)) + set_link_titles(doc) try: html = get_rendered_template(doc, name=name, print_format=print_format, meta=meta,