diff --git a/cypress/fixtures/child_table_doctype_1.js b/cypress/fixtures/child_table_doctype_1.js new file mode 100644 index 0000000000..4657d63e2e --- /dev/null +++ b/cypress/fixtures/child_table_doctype_1.js @@ -0,0 +1,59 @@ +export default { + name: "Child Table Doctype 1", + actions: [], + custom: 1, + autoname: "format: Test-{####}", + creation: "2022-02-09 20:15:21.242213", + doctype: "DocType", + editable_grid: 1, + engine: "InnoDB", + fields: [ + { + fieldname: "data", + fieldtype: "Data", + in_list_view: 1, + label: "Data" + }, + { + fieldname: "barcode", + fieldtype: "Barcode", + in_list_view: 1, + label: "Barcode" + }, + { + fieldname: "check", + fieldtype: "Check", + in_list_view: 1, + label: "Check" + }, + { + fieldname: "rating", + fieldtype: "Rating", + in_list_view: 1, + label: "Rating" + }, + { + fieldname: "duration", + fieldtype: "Duration", + in_list_view: 1, + label: "Duration" + }, + { + fieldname: "date", + fieldtype: "Date", + in_list_view: 1, + label: "Date" + } + ], + links: [], + istable: 1, + modified: "2022-02-10 12:03:12.603763", + modified_by: "Administrator", + module: "Custom", + naming_rule: "By fieldname", + owner: "Administrator", + permissions: [], + sort_field: 'modified', + sort_order: 'ASC', + track_changes: 1 +}; \ No newline at end of file diff --git a/cypress/fixtures/doctype_with_child_table.js b/cypress/fixtures/doctype_with_child_table.js index bbb2127448..014074b0b5 100644 --- a/cypress/fixtures/doctype_with_child_table.js +++ b/cypress/fixtures/doctype_with_child_table.js @@ -20,6 +20,12 @@ export default { label: "Child Table", options: "Child Table Doctype", reqd: 1 + }, + { + fieldname: "child_table_1", + fieldtype: "Table", + label: "Child Table 1", + options: "Child Table Doctype 1" } ], links: [], diff --git a/cypress/integration/dashboard_links.js b/cypress/integration/dashboard_links.js index 93d10cf1fd..019de1991d 100644 --- a/cypress/integration/dashboard_links.js +++ b/cypress/integration/dashboard_links.js @@ -1,5 +1,6 @@ import doctype_with_child_table from '../fixtures/doctype_with_child_table'; import child_table_doctype from '../fixtures/child_table_doctype'; +import child_table_doctype_1 from '../fixtures/child_table_doctype_1'; import doctype_to_link from '../fixtures/doctype_to_link'; const doctype_to_link_name = doctype_to_link.name; const child_table_doctype_name = child_table_doctype.name; @@ -9,6 +10,7 @@ context('Dashboard links', () => { cy.visit('/login'); cy.login(); cy.insert_doc('DocType', child_table_doctype, true); + cy.insert_doc('DocType', child_table_doctype_1, true); cy.insert_doc('DocType', doctype_with_child_table, true); cy.insert_doc('DocType', doctype_to_link, true); return cy.window().its('frappe').then(frappe => { diff --git a/cypress/integration/grid_search.js b/cypress/integration/grid_search.js new file mode 100644 index 0000000000..d30545a2e1 --- /dev/null +++ b/cypress/integration/grid_search.js @@ -0,0 +1,107 @@ +import doctype_with_child_table from '../fixtures/doctype_with_child_table'; +import child_table_doctype from '../fixtures/child_table_doctype'; +import child_table_doctype_1 from '../fixtures/child_table_doctype_1'; +const doctype_with_child_table_name = doctype_with_child_table.name; + +context('Grid Search', () => { + before(() => { + cy.visit('/login'); + cy.login(); + cy.visit('/app/website'); + cy.insert_doc('DocType', child_table_doctype, true); + cy.insert_doc('DocType', child_table_doctype_1, true); + cy.insert_doc('DocType', doctype_with_child_table, true); + return cy.window().its('frappe').then(frappe => { + return frappe.xcall("frappe.tests.ui_test_helpers.insert_doctype_with_child_table_record", { + name: doctype_with_child_table_name + }); + }); + }); + + it('Test search row visibility', () => { + cy.window().its('frappe').then(frappe => { + frappe.model.user_settings.save('Doctype With Child Table', 'GridView', { + 'Child Table Doctype 1': [ + {'fieldname': 'data', 'columns': 2}, + {'fieldname': 'barcode', 'columns': 1}, + {'fieldname': 'check', 'columns': 1}, + {'fieldname': 'rating', 'columns': 2}, + {'fieldname': 'duration', 'columns': 2}, + {'fieldname': 'date', 'columns': 2} + ] + }); + }); + + cy.visit(`/app/doctype-with-child-table/Test Grid Search`); + + cy.get('.frappe-control[data-fieldname="child_table_1"]').as('table'); + cy.get('@table').find('.grid-row-check:last').click(); + cy.get('@table').find('.grid-footer').contains('Delete').click(); + cy.get('.grid-heading-row .grid-row .search').should('not.exist'); + }); + + it('test search field for different fieldtypes', () => { + cy.visit(`/app/doctype-with-child-table/Test Grid Search`); + + cy.get('.frappe-control[data-fieldname="child_table_1"]').as('table'); + + // Index Column + cy.get('@table').find('.grid-heading-row .row-index.search input').type('3'); + cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 2); + cy.get('@table').find('.grid-heading-row .row-index.search input').clear(); + + // Data Column + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Data"]').type('Data'); + cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 1); + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Data"]').clear(); + + // Barcode Column + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Barcode"]').type('092'); + cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 4); + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Barcode"]').clear(); + + // Check Column + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Check"]').type('1'); + cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 9); + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Check"]').clear(); + + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Check"]').type('0'); + cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 11); + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Check"]').clear(); + + // Rating Column + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Rating"]').type('3'); + cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 3); + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Rating"]').clear(); + + // Duration Column + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Duration"]').type('3d'); + cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 3); + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Duration"]').clear(); + + // Date Column + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Date"]').type('2022'); + cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 4); + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Date"]').clear(); + }); + + it('test with multiple filter', () => { + cy.get('.frappe-control[data-fieldname="child_table_1"]').as('table'); + + // Data Column + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Data"]').type('a'); + cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 10); + + // Barcode Column + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Barcode"]').type('0'); + cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 8); + + // Duration Column + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Duration"]').type('d'); + cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 5); + + // Date Column + cy.get('@table').find('.grid-heading-row .search input[data-fieldtype="Date"]').type('02-'); + cy.get('@table').find('.grid-body .rows .grid-row').should('have.length', 2); + }); +}); \ No newline at end of file diff --git a/cypress/integration/list_paging.js b/cypress/integration/list_paging.js index b6832f5a53..4a59024a7b 100644 --- a/cypress/integration/list_paging.js +++ b/cypress/integration/list_paging.js @@ -31,5 +31,8 @@ context('List Paging', () => { 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'); }); }); diff --git a/cypress/integration/web_form.js b/cypress/integration/web_form.js index 8346c96313..bd1c7e147e 100644 --- a/cypress/integration/web_form.js +++ b/cypress/integration/web_form.js @@ -7,8 +7,8 @@ context('Web Form', () => { cy.visit('/update-profile'); cy.get_field('last_name', 'Data').type('_Test User', {force: true}).wait(200); cy.get('.web-form-actions .btn-primary').click(); - cy.wait(500); - cy.get('.modal.show > .modal-dialog').should('be.visible'); + cy.wait(5000); + cy.url().should('include', '/me'); }); it('Navigate and Submit a MultiStep WebForm', () => { @@ -16,14 +16,12 @@ context('Web Form', () => { cy.visit('/update-profile-duplicate'); cy.get_field('last_name', 'Data').type('_Test User', {force: true}).wait(200); cy.get('.btn-next').should('be.visible'); - cy.get('.web-form-footer .btn-primary').should('not.be.visible'); cy.get('.btn-next').click(); cy.get('.btn-previous').should('be.visible'); cy.get('.btn-next').should('not.be.visible'); - cy.get('.web-form-footer .btn-primary').should('be.visible'); cy.get('.web-form-actions .btn-primary').click(); - cy.wait(500); - cy.get('.modal.show > .modal-dialog').should('be.visible'); + cy.wait(5000); + cy.url().should('include', '/me'); }); }); }); diff --git a/esbuild/esbuild.js b/esbuild/esbuild.js index 43c01e88fb..ff31aa4b74 100644 --- a/esbuild/esbuild.js +++ b/esbuild/esbuild.js @@ -286,7 +286,7 @@ function get_watch_config() { notify_redis({ error }); } else { let { - assets_json, + new_assets_json, prev_assets_json } = await write_assets_json(result.metafile); @@ -294,7 +294,7 @@ function get_watch_config() { if (prev_assets_json) { changed_files = get_rebuilt_assets( prev_assets_json, - assets_json + new_assets_json ); let timestamp = new Date().toLocaleTimeString(); @@ -384,6 +384,7 @@ let prev_assets_json; let curr_assets_json; async function write_assets_json(metafile) { + let rtl = false; prev_assets_json = curr_assets_json; let out = {}; for (let output in metafile.outputs) { @@ -392,13 +393,14 @@ async function write_assets_json(metafile) { if (info.entryPoint) { let key = path.basename(info.entryPoint); if (key.endsWith('.css') && asset_path.includes('/css-rtl/')) { + rtl = true; key = `rtl_${key}`; } out[key] = asset_path; } } - let assets_json_path = path.resolve(assets_path, "assets.json"); + let assets_json_path = path.resolve(assets_path, `assets${rtl?'-rtl':''}.json`); let assets_json; try { assets_json = await fs.promises.readFile(assets_json_path, "utf-8"); @@ -407,21 +409,21 @@ async function write_assets_json(metafile) { } assets_json = JSON.parse(assets_json); // update with new values - assets_json = Object.assign({}, assets_json, out); - curr_assets_json = assets_json; + let new_assets_json = Object.assign({}, assets_json, out); + curr_assets_json = new_assets_json; await fs.promises.writeFile( assets_json_path, - JSON.stringify(assets_json, null, 4) + JSON.stringify(new_assets_json, null, 4) ); - await update_assets_json_in_cache(assets_json); + await update_assets_json_in_cache(); return { - assets_json, + new_assets_json, prev_assets_json }; } -function update_assets_json_in_cache(assets_json) { +function update_assets_json_in_cache() { // update assets_json cache in redis, so that it can be read directly by python return new Promise(resolve => { let client = get_redis_subscriber("redis_cache"); @@ -429,7 +431,7 @@ function update_assets_json_in_cache(assets_json) { client.on("error", _ => { log_warn("Cannot connect to redis_cache to update assets_json"); }); - client.set("assets_json", JSON.stringify(assets_json), err => { + client.del("assets_json", err => { client.unref(); resolve(); }); diff --git a/frappe/__init__.py b/frappe/__init__.py index 1f5c7811a0..8cbe60fb43 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -36,6 +36,7 @@ from frappe.query_builder import ( patch_query_execute, patch_query_aggregation, ) +from frappe.utils.data import cstr __version__ = '14.0.0-dev' @@ -215,6 +216,7 @@ def init(site, sites_path=None, new_site=False): local.cache = {} local.document_cache = {} local.meta_cache = {} + local.autoincremented_status_map = {site: -1} local.form_dict = _dict() local.session = _dict() local.dev_server = _dev_server @@ -858,8 +860,7 @@ def set_value(doctype, docname, fieldname, value=None): return frappe.client.set_value(doctype, docname, fieldname, value) def get_cached_doc(*args, **kwargs): - if args and len(args) > 1 and isinstance(args[1], str): - key = get_document_cache_key(args[0], args[1]) + if key := can_cache_doc(args): # local cache doc = local.document_cache.get(key) if doc: @@ -877,8 +878,24 @@ def get_cached_doc(*args, **kwargs): return doc +def can_cache_doc(args): + """ + Determine if document should be cached based on get_doc params. + Returns cache key if doc can be cached, None otherwise. + """ + + if not args: + return + + doctype = args[0] + name = doctype if len(args) == 1 else args[1] + + # Only cache if both doctype and name are strings + if isinstance(doctype, str) and isinstance(name, str): + return get_document_cache_key(doctype, name) + def get_document_cache_key(doctype, name): - return '{0}::{1}'.format(doctype, name) + return f'{doctype}::{name}' def clear_document_cache(doctype, name): cache().hdel("last_modified", doctype) @@ -919,8 +936,7 @@ def get_doc(*args, **kwargs): doc = frappe.model.document.get_doc(*args, **kwargs) # set in cache - if args and len(args) > 1: - key = get_document_cache_key(args[0], args[1]) + if key := can_cache_doc(args): local.document_cache[key] = doc cache().hset('document_cache', key, doc.as_dict()) @@ -970,8 +986,7 @@ def delete_doc(doctype=None, name=None, force=0, ignore_doctypes=None, for_reloa def delete_doc_if_exists(doctype, name, force=0): """Delete document if exists.""" - if db.exists(doctype, name): - delete_doc(doctype, name, force=force) + delete_doc(doctype, name, force=force, ignore_missing=True) def reload_doctype(doctype, force=False, reset_permissions=False): """Reload DocType from model (`[module]/[doctype]/[name]/[name].json`) files.""" @@ -1009,7 +1024,7 @@ def get_module(modulename): def scrub(txt): """Returns sluggified string. e.g. `Sales Order` becomes `sales_order`.""" - return txt.replace(' ', '_').replace('-', '_').lower() + return cstr(txt).replace(' ', '_').replace('-', '_').lower() def unscrub(txt): """Returns titlified string. e.g. `sales_order` becomes `Sales Order`.""" @@ -1244,9 +1259,10 @@ def get_newargs(fn, kwargs): if hasattr(fn, 'fnargs'): fnargs = fn.fnargs else: - fnargs = inspect.getfullargspec(fn).args - fnargs.extend(inspect.getfullargspec(fn).kwonlyargs) - varkw = inspect.getfullargspec(fn).varkw + fullargspec = inspect.getfullargspec(fn) + fnargs = fullargspec.args + fnargs.extend(fullargspec.kwonlyargs) + varkw = fullargspec.varkw newargs = {} for a in kwargs: @@ -1258,7 +1274,7 @@ def get_newargs(fn, kwargs): return newargs -def make_property_setter(args, ignore_validate=False, validate_fields_for_doctype=True): +def make_property_setter(args, ignore_validate=False, validate_fields_for_doctype=True, is_system_generated=True): """Create a new **Property Setter** (for overriding DocType and DocField properties). If doctype is not specified, it will create a property setter for all fields with the @@ -1289,6 +1305,7 @@ def make_property_setter(args, ignore_validate=False, validate_fields_for_doctyp 'property': args.property, 'value': args.value, 'property_type': args.property_type or "Data", + 'is_system_generated': is_system_generated, '__islocal': 1 }) ps.flags.ignore_validate = ignore_validate @@ -1456,7 +1473,7 @@ def get_list(doctype, *args, **kwargs): :param fields: List of fields or `*`. :param filters: List of filters (see example). :param order_by: Order By e.g. `modified desc`. - :param limit_page_start: Start results at record #. Default 0. + :param limit_start: Start results at record #. Default 0. :param limit_page_length: No of records in the page. Default 20. Example usage: diff --git a/frappe/commands/site.py b/frappe/commands/site.py index b54f369e34..e788c7ec4d 100644 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -677,7 +677,9 @@ def _drop_site(site, db_root_username=None, db_root_password=None, archived_site try: if not no_backup: - scheduled_backup(ignore_files=False, force=True) + click.secho(f"Taking backup of {site}", fg="green") + odb = scheduled_backup(ignore_files=False, force=True, verbose=True) + odb.print_summary() except Exception as err: if force: pass @@ -692,6 +694,7 @@ def _drop_site(site, db_root_username=None, db_root_password=None, archived_site click.echo("\n".join(messages)) sys.exit(1) + click.secho("Dropping site database and user", fg="green") drop_user_and_database(frappe.conf.db_name, db_root_username, db_root_password) archived_sites_path = archived_sites_path or os.path.join(frappe.get_app_path('frappe'), '..', '..', '..', 'archived', 'sites') @@ -725,7 +728,7 @@ def move(dest_dir, site): @click.command('set-password') @click.argument('user') @click.argument('password', required=False) -@click.option('--logout-all-sessions', help='Logout from all sessions', is_flag=True, default=False) +@click.option('--logout-all-sessions', help='Log out from all sessions', is_flag=True, default=False) @pass_context def set_password(context, user, password=None, logout_all_sessions=False): "Set password for a user on a site" @@ -738,7 +741,7 @@ def set_password(context, user, password=None, logout_all_sessions=False): @click.command('set-admin-password') @click.argument('admin-password', required=False) -@click.option('--logout-all-sessions', help='Logout from all sessions', is_flag=True, default=False) +@click.option('--logout-all-sessions', help='Log out from all sessions', is_flag=True, default=False) @pass_context def set_admin_password(context, admin_password=None, logout_all_sessions=False): "Set Administrator password for a site" diff --git a/frappe/core/doctype/activity_log/activity_log.py b/frappe/core/doctype/activity_log/activity_log.py index 69565a2c2a..70d4ca3ffe 100644 --- a/frappe/core/doctype/activity_log/activity_log.py +++ b/frappe/core/doctype/activity_log/activity_log.py @@ -1,15 +1,14 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2017, Frappe Technologies and contributors +# Copyright (c) 2022, Frappe Technologies and contributors # License: MIT. See LICENSE -from frappe import _ -from frappe.utils import get_fullname, now -from frappe.model.document import Document -from frappe.core.utils import set_timeline_doc import frappe +from frappe import _ +from frappe.core.utils import set_timeline_doc +from frappe.model.document import Document from frappe.query_builder import DocType, Interval from frappe.query_builder.functions import Now -from pypika.terms import PseudoColumn +from frappe.utils import get_fullname, now + class ActivityLog(Document): def before_insert(self): @@ -49,5 +48,5 @@ def clear_activity_logs(days=None): days = 90 doctype = DocType("Activity Log") frappe.db.delete(doctype, filters=( - doctype.creation < PseudoColumn(f"({Now() - Interval(days=days)})") - )) \ No newline at end of file + doctype.creation < (Now() - Interval(days=days)) + )) diff --git a/frappe/core/doctype/communication/test_communication.py b/frappe/core/doctype/communication/test_communication.py index d933c2f494..8012d8facf 100644 --- a/frappe/core/doctype/communication/test_communication.py +++ b/frappe/core/doctype/communication/test_communication.py @@ -4,8 +4,8 @@ import unittest from urllib.parse import quote import frappe -from frappe.email.doctype.email_queue.email_queue import EmailQueue from frappe.core.doctype.communication.communication import get_emails +from frappe.email.doctype.email_queue.email_queue import EmailQueue test_records = frappe.get_test_records('Communication') @@ -202,7 +202,7 @@ class TestCommunication(unittest.TestCase): self.assertIn(("Note", note.name), doc_links) - def parse_emails(self): + def test_parse_emails(self): emails = get_emails( [ 'comm_recipient+DocType+DocName@example.com', diff --git a/frappe/core/doctype/data_export/exporter.py b/frappe/core/doctype/data_export/exporter.py index 79570d5048..9f1492af19 100644 --- a/frappe/core/doctype/data_export/exporter.py +++ b/frappe/core/doctype/data_export/exporter.py @@ -324,7 +324,7 @@ class DataExporter: d = doc.copy() meta = frappe.get_meta(dt) if self.all_doctypes: - d.name = '"'+ d.name+'"' + d.name = f'"{d.name}"' if len(rows) < rowidx + 1: rows.append([""] * (len(self.columns) + 1)) diff --git a/frappe/core/doctype/doctype/doctype.js b/frappe/core/doctype/doctype/doctype.js index f250a6a109..88cc5577a6 100644 --- a/frappe/core/doctype/doctype/doctype.js +++ b/frappe/core/doctype/doctype/doctype.js @@ -61,6 +61,13 @@ frappe.ui.form.on('DocType', { frm.events.set_naming_rule_description(frm); }, + istable: (frm) => { + if (frm.doc.istable && frm.is_new()) { + frm.set_value('autoname', 'autoincrement'); + frm.set_value('allow_rename', 0); + } + }, + naming_rule: function(frm) { // set the "autoname" property based on naming_rule if (frm.doc.naming_rule && !frm.__from_autoname) { @@ -70,6 +77,10 @@ frappe.ui.form.on('DocType', { if (frm.doc.naming_rule=='Set by user') { frm.set_value('autoname', 'Prompt'); + } else if (frm.doc.naming_rule === 'Autoincrement') { + frm.set_value('autoname', 'autoincrement'); + // set allow rename to be false when using autoincrement + frm.set_value('allow_rename', 0); } else if (frm.doc.naming_rule=='By fieldname') { frm.set_value('autoname', 'field:'); } else if (frm.doc.naming_rule=='By "Naming Series" field') { @@ -91,6 +102,7 @@ frappe.ui.form.on('DocType', { set_naming_rule_description(frm) { let naming_rule_description = { 'Set by user': '', + 'Autoincrement': 'Uses Auto Increment feature of database.
WARNING: After using this option, any other naming option will not be accessible.', 'By fieldname': 'Format: field:[fieldname]. Valid fieldname must exist', 'By "Naming Series" field': 'Format: naming_series:[fieldname]. Fieldname called naming_series must exist', 'Expression': 'Format: format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####} - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.', @@ -111,6 +123,8 @@ frappe.ui.form.on('DocType', { frm.__from_autoname = true; if (frm.doc.autoname.toLowerCase() === 'prompt') { frm.set_value('naming_rule', 'Set by user'); + } else if (frm.doc.autoname.toLowerCase() === 'autoincrement') { + frm.set_value('naming_rule', 'Autoincrement'); } else if (frm.doc.autoname.startsWith('field:')) { frm.set_value('naming_rule', 'By fieldname'); } else if (frm.doc.autoname.startsWith('naming_series:')) { diff --git a/frappe/core/doctype/doctype/doctype.json b/frappe/core/doctype/doctype/doctype.json index 2bba4127bb..8169a59566 100644 --- a/frappe/core/doctype/doctype/doctype.json +++ b/frappe/core/doctype/doctype/doctype.json @@ -208,7 +208,7 @@ "label": "Naming" }, { - "description": "Naming Options:\n
  1. field:[fieldname] - By Field
  2. naming_series: - By Naming Series (field called naming_series must be present
  3. Prompt - Prompt user for a name
  4. [series] - Series by prefix (separated by a dot); for example PRE.#####
  5. \n
  6. format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####} - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.
", + "description": "Naming Options:\n
  1. field:[fieldname] - By Field
  2. autoincrement - Uses Databases' Auto Increment feature
  3. naming_series: - By Naming Series (field called naming_series must be present
  4. Prompt - Prompt user for a name
  5. [series] - Series by prefix (separated by a dot); for example PRE.#####
  6. \n
  7. format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####} - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.
", "fieldname": "autoname", "fieldtype": "Data", "label": "Auto Name", @@ -216,6 +216,7 @@ "oldfieldtype": "Data" }, { + "depends_on": "eval:doc.naming_rule !== \"Autoincrement\"", "fieldname": "name_case", "fieldtype": "Select", "label": "Name Case", @@ -282,6 +283,7 @@ }, { "default": "1", + "depends_on": "eval:doc.naming_rule !== \"Autoincrement\"", "fieldname": "allow_rename", "fieldtype": "Check", "label": "Allow Rename", @@ -565,7 +567,7 @@ "fieldtype": "Select", "label": "Naming Rule", "length": 40, - "options": "\nSet by user\nBy fieldname\nBy \"Naming Series\" field\nExpression\nExpression (old style)\nRandom\nBy script" + "options": "\nSet by user\nAutoincrement\nBy fieldname\nBy \"Naming Series\" field\nExpression\nExpression (old style)\nRandom\nBy script" }, { "fieldname": "migration_hash", @@ -593,6 +595,7 @@ ], "icon": "fa fa-bolt", "idx": 6, + "index_web_pages_for_search": 1, "links": [ { "group": "Views", @@ -670,10 +673,11 @@ "link_fieldname": "reference_doctype" } ], - "modified": "2022-01-07 16:07:06.196534", + "modified": "2022-02-15 21:47:16.467217", "modified_by": "Administrator", "module": "Core", "name": "DocType", + "naming_rule": "Set by user", "owner": "Administrator", "permissions": [ { @@ -703,5 +707,6 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 5f82abac1f..29b56fbff6 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -60,6 +60,7 @@ class DocType(Document): self.check_developer_mode() + self.validate_autoname() self.validate_name() self.set_defaults_for_single_and_table() @@ -714,6 +715,18 @@ class DocType(Document): self.name) return max_idx and max_idx[0][0] or 0 + def validate_autoname(self): + if not self.is_new(): + doc_before_save = self.get_doc_before_save() + if doc_before_save: + if (self.autoname == "autoincrement" and doc_before_save.autoname != "autoincrement") \ + or (self.autoname != "autoincrement" and doc_before_save.autoname == "autoincrement"): + frappe.throw(_("Cannot change to/from Autoincrement naming rule")) + + else: + if self.autoname == "autoincrement": + self.allow_rename = 0 + def validate_name(self, name=None): if not name: name = self.name diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py index cb22f581c6..dc6d14b451 100644 --- a/frappe/core/doctype/doctype/test_doctype.py +++ b/frappe/core/doctype/doctype/test_doctype.py @@ -505,7 +505,23 @@ class TestDocType(unittest.TestCase): dt.delete() -def new_doctype(name, unique=0, depends_on='', fields=None): + def test_autoincremented_doctype_transition(self): + frappe.delete_doc("testy_autoinc_dt") + dt = new_doctype("testy_autoinc_dt", autoincremented=True).insert(ignore_permissions=True) + dt.autoname = "hash" + + try: + dt.save(ignore_permissions=True) + except frappe.ValidationError as e: + self.assertEqual(e.args[0], "Cannot change to/from Autoincrement naming rule") + else: + self.fail("Shouldnt be possible to transition autoincremented doctype to any other naming rule") + finally: + # cleanup + dt.delete(ignore_permissions=True) + + +def new_doctype(name, unique=0, depends_on='', fields=None, autoincremented=False): doc = frappe.get_doc({ "doctype": "DocType", "module": "Core", @@ -521,7 +537,8 @@ def new_doctype(name, unique=0, depends_on='', fields=None): "role": "System Manager", "read": 1, }], - "name": name + "name": name, + "autoname": "autoincrement" if autoincremented else "" }) if fields: diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index f6310ef454..971cd455f5 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -383,7 +383,7 @@ class TestFile(unittest.TestCase): }).insert(ignore_permissions=True) test_file.make_thumbnail() - self.assertEquals(test_file.thumbnail_url, '/files/image_small.jpg') + self.assertEqual(test_file.thumbnail_url, '/files/image_small.jpg') # test web image without extension test_file = frappe.get_doc({ @@ -400,7 +400,7 @@ class TestFile(unittest.TestCase): test_file.reload() test_file.file_url = "/files/image_small.jpg" test_file.make_thumbnail(suffix="xs", crop=True) - self.assertEquals(test_file.thumbnail_url, '/files/image_small_xs.jpg') + self.assertEqual(test_file.thumbnail_url, '/files/image_small_xs.jpg') frappe.clear_messages() test_file.db_set('thumbnail_url', None) @@ -408,7 +408,7 @@ class TestFile(unittest.TestCase): test_file.file_url = frappe.utils.get_url('unknown.jpg') test_file.make_thumbnail(suffix="xs") self.assertEqual(json.loads(frappe.message_log[0]).get("message"), f"File '{frappe.utils.get_url('unknown.jpg')}' not found") - self.assertEquals(test_file.thumbnail_url, None) + self.assertEqual(test_file.thumbnail_url, None) def test_file_unzip(self): file_path = frappe.get_app_path('frappe', 'www/_test/assets/file.zip') diff --git a/frappe/core/doctype/log_settings/log_settings.py b/frappe/core/doctype/log_settings/log_settings.py index 5c9bc6c265..dec33070c0 100644 --- a/frappe/core/doctype/log_settings/log_settings.py +++ b/frappe/core/doctype/log_settings/log_settings.py @@ -7,7 +7,6 @@ from frappe import _ from frappe.model.document import Document from frappe.query_builder import DocType, Interval from frappe.query_builder.functions import Now -from pypika.terms import PseudoColumn class LogSettings(Document): @@ -19,7 +18,7 @@ class LogSettings(Document): def clear_error_logs(self): table = DocType("Error Log") frappe.db.delete(table, filters=( - table.creation < PseudoColumn(f"({Now() - Interval(days=self.clear_error_log_after)})") + table.creation < (Now() - Interval(days=self.clear_error_log_after)) )) def clear_activity_logs(self): diff --git a/frappe/core/doctype/log_settings/test_log_settings.py b/frappe/core/doctype/log_settings/test_log_settings.py index 40287948fd..f398577665 100644 --- a/frappe/core/doctype/log_settings/test_log_settings.py +++ b/frappe/core/doctype/log_settings/test_log_settings.py @@ -1,8 +1,100 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2020, Frappe Technologies and Contributors +# Copyright (c) 2022, Frappe Technologies and Contributors # License: MIT. See LICENSE -# import frappe -import unittest -class TestLogSettings(unittest.TestCase): - pass +from datetime import datetime + +import frappe +from frappe.utils import now_datetime, add_to_date +from frappe.core.doctype.log_settings.log_settings import run_log_clean_up +from frappe.tests.utils import FrappeTestCase + + +class TestLogSettings(FrappeTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + + frappe.db.set_single_value( + "Log Settings", + { + "clear_error_log_after": 1, + "clear_activity_log_after": 1, + "clear_email_queue_after": 1, + }, + ) + + def setUp(self) -> None: + if self._testMethodName == "test_delete_logs": + self.datetime = frappe._dict() + self.datetime.current = now_datetime() + self.datetime.past = add_to_date(self.datetime.current, days=-4) + setup_test_logs(self.datetime.past) + + def tearDown(self) -> None: + if self._testMethodName == "test_delete_logs": + del self.datetime + + def test_delete_logs(self): + # make sure test data is present + activity_log_count = frappe.db.count( + "Activity Log", {"creation": ("<=", self.datetime.past)} + ) + error_log_count = frappe.db.count( + "Error Log", {"creation": ("<=", self.datetime.past)} + ) + email_queue_count = frappe.db.count( + "Email Queue", {"creation": ("<=", self.datetime.past)} + ) + + self.assertNotEqual(activity_log_count, 0) + self.assertNotEqual(error_log_count, 0) + self.assertNotEqual(email_queue_count, 0) + + # run clean up job + run_log_clean_up() + + # test if logs are deleted + activity_log_count = frappe.db.count( + "Activity Log", {"creation": ("<", self.datetime.past)} + ) + error_log_count = frappe.db.count( + "Error Log", {"creation": ("<", self.datetime.past)} + ) + email_queue_count = frappe.db.count( + "Email Queue", {"creation": ("<", self.datetime.past)} + ) + + self.assertEqual(activity_log_count, 0) + self.assertEqual(error_log_count, 0) + self.assertEqual(email_queue_count, 0) + + +def setup_test_logs(past: datetime) -> None: + activity_log = frappe.get_doc( + { + "doctype": "Activity Log", + "subject": "Test subject", + "full_name": "test user2", + } + ).insert(ignore_permissions=True) + activity_log.db_set("creation", past) + + error_log = frappe.get_doc( + { + "doctype": "Error Log", + "method": "test_method", + "error": "traceback", + } + ).insert(ignore_permissions=True) + error_log.db_set("creation", past) + + doc1 = frappe.get_doc( + { + "doctype": "Email Queue", + "sender": "test1@example.com", + "message": "This is a test email1", + "priority": 1, + "expose_recipients": "test@receiver.com", + } + ).insert(ignore_permissions=True) + doc1.db_set("creation", past) diff --git a/frappe/core/doctype/report/report.py b/frappe/core/doctype/report/report.py index 9cb40dffd4..bf82a3f684 100644 --- a/frappe/core/doctype/report/report.py +++ b/frappe/core/doctype/report/report.py @@ -11,7 +11,7 @@ from frappe.modules import make_boilerplate from frappe.core.doctype.page.page import delete_custom_role from frappe.core.doctype.custom_role.custom_role import get_custom_allowed_roles from frappe.desk.reportview import append_totals_row -from frappe.utils.safe_exec import safe_exec +from frappe.utils.safe_exec import safe_exec, check_safe_sql_query class Report(Document): @@ -110,8 +110,7 @@ class Report(Document): if not self.query: frappe.throw(_("Must specify a Query to run"), title=_('Report Document Error')) - if not self.query.lower().startswith("select"): - frappe.throw(_("Query must be a SELECT"), title=_('Report Document Error')) + check_safe_sql_query(self.query) result = [list(t) for t in frappe.db.sql(self.query, filters)] columns = self.get_columns() or [cstr(c[0]) for c in frappe.db.get_description()] diff --git a/frappe/core/doctype/report/test_report.py b/frappe/core/doctype/report/test_report.py index a077956d71..e58d038993 100644 --- a/frappe/core/doctype/report/test_report.py +++ b/frappe/core/doctype/report/test_report.py @@ -1,17 +1,19 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE +import textwrap + import frappe, json, os -import unittest from frappe.desk.query_report import run, save_report, add_total_row from frappe.desk.reportview import delete_report, save_report as _save_report from frappe.custom.doctype.customize_form.customize_form import reset_customization from frappe.core.doctype.user_permission.test_user_permission import create_user +from frappe.tests.utils import FrappeTestCase test_records = frappe.get_test_records('Report') test_dependencies = ['User'] -class TestReport(unittest.TestCase): +class TestReport(FrappeTestCase): def test_report_builder(self): if frappe.db.exists('Report', 'User Activity Report'): frappe.delete_doc('Report', 'User Activity Report') @@ -335,3 +337,29 @@ result = [ self.assertEqual(result[-1][0], "Total") self.assertEqual(result[-1][1], 200) self.assertEqual(result[-1][2], 150.50) + + def test_cte_in_query_report(self): + cte_query = textwrap.dedent(""" + with enabled_users as ( + select name + from `tabUser` + where enabled = 1 + ) + select * from enabled_users; + """) + + report = frappe.get_doc({ + "doctype": "Report", + "ref_doctype": "User", + "report_name": "Enabled Users List", + "report_type": "Query Report", + "is_standard": "No", + "query": cte_query, + }).insert() + + if frappe.db.db_type == "mariadb": + col, rows = report.execute_query_report(filters={}) + self.assertEqual(col[0], "name") + self.assertGreaterEqual(len(rows), 1) + elif frappe.db.db_type == "postgres": + self.assertRaises(frappe.PermissionError, report.execute_query_report, filters={}) diff --git a/frappe/core/doctype/server_script/test_server_script.py b/frappe/core/doctype/server_script/test_server_script.py index d9381bcd16..aa4507b858 100644 --- a/frappe/core/doctype/server_script/test_server_script.py +++ b/frappe/core/doctype/server_script/test_server_script.py @@ -112,7 +112,10 @@ class TestServerScript(unittest.TestCase): self.assertEqual(frappe.get_doc('Server Script', 'test_return_value').execute_method(), 'hello') def test_permission_query(self): - self.assertTrue('where (1 = 1)' in frappe.db.get_list('ToDo', run=False)) + if frappe.conf.db_type == "mariadb": + self.assertTrue('where (1 = 1)' in frappe.db.get_list('ToDo', run=False)) + else: + self.assertTrue('where (1 = \'1\')' in frappe.db.get_list('ToDo', run=False)) self.assertTrue(isinstance(frappe.db.get_list('ToDo'), list)) def test_attribute_error(self): diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index d08755f9a8..1ad977547c 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -253,8 +253,8 @@ class User(Document): self.email_new_password(new_password) except frappe.OutgoingEmailError: - print(frappe.get_traceback()) - pass # email server not set, don't send email + # email server not set, don't send email + frappe.log_error(frappe.get_traceback()) @Document.hook def validate_reset_password(self): diff --git a/frappe/core/doctype/user_permission/user_permission.js b/frappe/core/doctype/user_permission/user_permission.js index 8d5c5c1a23..f6989db5d8 100644 --- a/frappe/core/doctype/user_permission/user_permission.js +++ b/frappe/core/doctype/user_permission/user_permission.js @@ -44,8 +44,9 @@ frappe.ui.form.on('User Permission', { set_applicable_for_constraint: frm => { frm.toggle_reqd('applicable_for', !frm.doc.apply_to_all_doctypes); + if (frm.doc.apply_to_all_doctypes && frm.doc.applicable_for) { - frm.set_value('applicable_for', null); + frm.set_value('applicable_for', null, null, true); } }, diff --git a/frappe/core/web_form/edit_profile/edit_profile.json b/frappe/core/web_form/edit_profile/edit_profile.json index 7072584670..c04e705820 100644 --- a/frappe/core/web_form/edit_profile/edit_profile.json +++ b/frappe/core/web_form/edit_profile/edit_profile.json @@ -1,133 +1,159 @@ { - "accept_payment": 0, - "allow_comments": 0, - "allow_delete": 0, - "allow_edit": 1, - "allow_incomplete": 0, - "allow_multiple": 0, - "allow_print": 0, - "amount": 0.0, - "amount_based_on_field": 0, - "breadcrumbs": "[{\"title\": _(\"My Account\"), \"route\": \"me\"}]", - "creation": "2016-09-19 05:16:59.242754", - "doc_type": "User", - "docstatus": 0, - "doctype": "Web Form", - "idx": 0, - "introduction_text": "", - "is_standard": 1, - "login_required": 1, - "max_attachment_size": 0, - "modified": "2019-01-28 12:45:17.158069", - "modified_by": "Administrator", - "module": "Core", - "name": "edit-profile", - "owner": "Administrator", - "published": 1, - "route": "update-profile", - "show_in_grid": 0, - "show_sidebar": 1, - "sidebar_items": [], - "success_message": "Profile updated successfully.", - "success_url": "/me", - "title": "Update Profile", + "accept_payment": 0, + "allow_comments": 0, + "allow_delete": 0, + "allow_edit": 1, + "allow_incomplete": 0, + "allow_multiple": 0, + "allow_print": 0, + "amount": 0.0, + "amount_based_on_field": 0, + "apply_document_permissions": 0, + "breadcrumbs": "[{\"title\": _(\"My Account\"), \"route\": \"me\"}]", + "creation": "2016-09-19 05:16:59.242754", + "doc_type": "User", + "docstatus": 0, + "doctype": "Web Form", + "idx": 0, + "introduction_text": "", + "is_multi_step_form": 0, + "is_standard": 1, + "login_required": 1, + "max_attachment_size": 0, + "modified": "2022-03-22 15:00:43.456738", + "modified_by": "Administrator", + "module": "Core", + "name": "edit-profile", + "owner": "Administrator", + "published": 1, + "route": "update-profile", + "route_to_success_link": 0, + "show_attachments": 0, + "show_in_grid": 0, + "show_sidebar": 0, + "sidebar_items": [], + "success_message": "Profile updated successfully.", + "success_url": "/me", + "title": "Update Profile", "web_form_fields": [ { - "allow_read_on_all_link_options": 0, - "fieldname": "first_name", - "fieldtype": "Data", - "hidden": 0, - "label": "First Name", - "max_length": 0, - "max_value": 0, - "read_only": 0, - "reqd": 1, + "allow_read_on_all_link_options": 0, + "fieldname": "first_name", + "fieldtype": "Data", + "hidden": 0, + "label": "First Name", + "max_length": 0, + "max_value": 0, + "read_only": 0, + "reqd": 1, "show_in_filter": 0 - }, + }, { - "allow_read_on_all_link_options": 0, - "fieldname": "middle_name", - "fieldtype": "Data", - "hidden": 0, - "label": "Middle Name (Optional)", - "max_length": 0, - "max_value": 0, - "read_only": 0, - "reqd": 0, + "allow_read_on_all_link_options": 0, + "fieldname": "middle_name", + "fieldtype": "Data", + "hidden": 0, + "label": "Middle Name (Optional)", + "max_length": 0, + "max_value": 0, + "read_only": 0, + "reqd": 0, "show_in_filter": 0 - }, + }, { - "allow_read_on_all_link_options": 0, - "fieldname": "last_name", - "fieldtype": "Data", - "hidden": 0, - "label": "Last Name", - "max_length": 0, - "max_value": 0, - "read_only": 0, - "reqd": 0, + "allow_read_on_all_link_options": 0, + "fieldname": "last_name", + "fieldtype": "Data", + "hidden": 0, + "label": "Last Name", + "max_length": 0, + "max_value": 0, + "read_only": 0, + "reqd": 0, "show_in_filter": 0 - }, + }, { - "allow_read_on_all_link_options": 0, - "description": "", - "fieldname": "user_image", - "fieldtype": "Attach Image", - "hidden": 0, - "label": "User Image", - "max_length": 0, - "max_value": 0, - "read_only": 0, - "reqd": 0, + "allow_read_on_all_link_options": 0, + "fieldname": "", + "fieldtype": "Column Break", + "hidden": 0, + "max_length": 0, + "max_value": 0, + "read_only": 0, + "reqd": 0, "show_in_filter": 0 - }, + }, { - "allow_read_on_all_link_options": 0, - "fieldtype": "Section Break", - "hidden": 0, - "label": "More Information", - "max_length": 0, - "max_value": 0, - "read_only": 0, - "reqd": 0, + "allow_read_on_all_link_options": 0, + "description": "", + "fieldname": "user_image", + "fieldtype": "Attach Image", + "hidden": 0, + "label": "Profile Picture", + "max_length": 0, + "max_value": 0, + "read_only": 0, + "reqd": 0, "show_in_filter": 0 - }, + }, { - "allow_read_on_all_link_options": 0, - "fieldname": "phone", - "fieldtype": "Data", - "hidden": 0, - "label": "Phone", - "max_length": 0, - "max_value": 0, - "read_only": 0, - "reqd": 0, + "allow_read_on_all_link_options": 0, + "fieldtype": "Section Break", + "hidden": 0, + "label": "More Information", + "max_length": 0, + "max_value": 0, + "read_only": 0, + "reqd": 0, "show_in_filter": 0 - }, + }, { - "allow_read_on_all_link_options": 0, - "fieldname": "mobile_no", - "fieldtype": "Data", - "hidden": 0, - "label": "Mobile Number", - "max_length": 0, - "max_value": 0, - "read_only": 0, - "reqd": 0, + "allow_read_on_all_link_options": 0, + "fieldname": "phone", + "fieldtype": "Data", + "hidden": 0, + "label": "Phone", + "max_length": 0, + "max_value": 0, + "read_only": 0, + "reqd": 0, "show_in_filter": 0 - }, + }, { - "allow_read_on_all_link_options": 0, - "description": "", - "fieldname": "language", - "fieldtype": "Link", - "hidden": 0, - "label": "Language", - "max_length": 0, - "max_value": 0, - "options": "Language", - "read_only": 0, - "reqd": 0, + "allow_read_on_all_link_options": 0, + "fieldname": "mobile_no", + "fieldtype": "Data", + "hidden": 0, + "label": "Mobile Number", + "max_length": 0, + "max_value": 0, + "read_only": 0, + "reqd": 0, + "show_in_filter": 0 + }, + { + "allow_read_on_all_link_options": 0, + "fieldname": "", + "fieldtype": "Column Break", + "hidden": 0, + "max_length": 0, + "max_value": 0, + "read_only": 0, + "reqd": 0, + "show_in_filter": 0 + }, + { + "allow_read_on_all_link_options": 0, + "description": "", + "fieldname": "language", + "fieldtype": "Link", + "hidden": 0, + "label": "Language", + "max_length": 0, + "max_value": 0, + "options": "Language", + "read_only": 0, + "reqd": 0, "show_in_filter": 0 } ] diff --git a/frappe/custom/doctype/custom_field/custom_field.json b/frappe/custom/doctype/custom_field/custom_field.json index f09829a688..5632da2149 100644 --- a/frappe/custom/doctype/custom_field/custom_field.json +++ b/frappe/custom/doctype/custom_field/custom_field.json @@ -7,6 +7,7 @@ "document_type": "Setup", "engine": "InnoDB", "field_order": [ + "is_system_generated", "dt", "module", "label", @@ -425,13 +426,20 @@ "fieldtype": "Link", "label": "Module (for export)", "options": "Module Def" + }, + { + "default": "0", + "fieldname": "is_system_generated", + "fieldtype": "Check", + "label": "Is System Generated", + "read_only": 1 } ], "icon": "fa fa-glass", "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2022-02-14 15:42:21.885999", + "modified": "2022-02-28 22:22:54.893269", "modified_by": "Administrator", "module": "Custom", "name": "Custom Field", diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py index cb1ea2c54d..af10c6d76a 100644 --- a/frappe/custom/doctype/custom_field/custom_field.py +++ b/frappe/custom/doctype/custom_field/custom_field.py @@ -119,7 +119,7 @@ def create_custom_field_if_values_exist(doctype, df): frappe.db.count(dt=doctype, filters=IfNull(df.fieldname, "") != ""): create_custom_field(doctype, df) -def create_custom_field(doctype, df, ignore_validate=False): +def create_custom_field(doctype, df, ignore_validate=False, is_system_generated=True): df = frappe._dict(df) if not df.fieldname and df.label: df.fieldname = frappe.scrub(df.label) @@ -130,8 +130,7 @@ def create_custom_field(doctype, df, ignore_validate=False): "permlevel": 0, "fieldtype": 'Data', "hidden": 0, - # Looks like we always use this programatically? - # "is_standard": 1 + "is_system_generated": is_system_generated }) custom_field.update(df) custom_field.flags.ignore_validate = ignore_validate diff --git a/frappe/custom/doctype/customize_form/customize_form.js b/frappe/custom/doctype/customize_form/customize_form.js index 4862185b99..7c2a7a2aff 100644 --- a/frappe/custom/doctype/customize_form/customize_form.js +++ b/frappe/custom/doctype/customize_form/customize_form.js @@ -14,7 +14,6 @@ frappe.ui.form.on("Customize Form", { }, onload: function(frm) { - frm.disable_save(); frm.set_query("doc_type", function() { return { translate_values: false, @@ -110,7 +109,7 @@ frappe.ui.form.on("Customize Form", { }, refresh: function(frm) { - frm.disable_save(); + frm.disable_save(true); frm.page.clear_icons(); if (frm.doc.doc_type) { @@ -169,7 +168,7 @@ frappe.ui.form.on("Customize Form", { doc_type = localStorage.getItem("customize_doctype"); } if (doc_type) { - setTimeout(() => frm.set_value("doc_type", doc_type), 1000); + setTimeout(() => frm.set_value("doc_type", doc_type, false, true), 1000); } }, @@ -243,7 +242,8 @@ frappe.ui.form.on("Customize Form Field", { }, fields_add: function(frm, cdt, cdn) { var f = frappe.model.get_doc(cdt, cdn); - f.is_custom_field = 1; + f.is_system_generated = false; + f.is_custom_field = true; } }); @@ -341,11 +341,11 @@ frappe.customize_form.confirm = function(msg, frm) { } frappe.customize_form.clear_locals_and_refresh = function(frm) { + delete frm.doc.__unsaved; // clear doctype from locals frappe.model.clear_doc("DocType", frm.doc.doc_type); delete frappe.meta.docfield_copy[frm.doc.doc_type]; - frm.refresh(); -} +}; extend_cscript(cur_frm.cscript, new frappe.model.DocTypeController({frm: cur_frm})); diff --git a/frappe/custom/doctype/customize_form/customize_form.py b/frappe/custom/doctype/customize_form/customize_form.py index 81cd38ff87..b6bd7ba787 100644 --- a/frappe/custom/doctype/customize_form/customize_form.py +++ b/frappe/custom/doctype/customize_form/customize_form.py @@ -402,7 +402,7 @@ class CustomizeForm(Document): "property": prop, "value": value, "property_type": property_type - }) + }, is_system_generated=False) def get_existing_property_value(self, property_name, fieldname=None): # check if there is any need to make property setter! @@ -487,12 +487,21 @@ def reset_customization(doctype): setters = frappe.get_all("Property Setter", filters={ 'doc_type': doctype, 'field_name': ['!=', 'naming_series'], - 'property': ['!=', 'options'] + 'property': ['!=', 'options'], + 'is_system_generated': False }, pluck='name') for setter in setters: frappe.delete_doc("Property Setter", setter) + custom_fields = frappe.get_all("Custom Field", filters={ + 'dt': doctype, + 'is_system_generated': False + }, pluck='name') + + for field in custom_fields: + frappe.delete_doc("Custom Field", field) + frappe.clear_cache(doctype=doctype) doctype_properties = { diff --git a/frappe/custom/doctype/property_setter/property_setter.json b/frappe/custom/doctype/property_setter/property_setter.json index 9707f1ee1c..039826b3b7 100644 --- a/frappe/custom/doctype/property_setter/property_setter.json +++ b/frappe/custom/doctype/property_setter/property_setter.json @@ -6,6 +6,7 @@ "document_type": "Setup", "engine": "InnoDB", "field_order": [ + "is_system_generated", "help", "sb0", "doctype_or_field", @@ -103,13 +104,20 @@ { "fieldname": "section_break_9", "fieldtype": "Section Break" + }, + { + "default": "0", + "fieldname": "is_system_generated", + "fieldtype": "Check", + "label": "Is System Generated", + "read_only": 1 } ], "icon": "fa fa-glass", "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2021-12-14 14:15:41.929071", + "modified": "2022-02-28 22:24:12.377693", "modified_by": "Administrator", "module": "Custom", "name": "Property Setter", diff --git a/frappe/database/__init__.py b/frappe/database/__init__.py index 7b26ac31b3..5db0537ed7 100644 --- a/frappe/database/__init__.py +++ b/frappe/database/__init__.py @@ -18,7 +18,8 @@ def setup_database(force, source_sql=None, verbose=None, no_mariadb_socket=False def drop_user_and_database(db_name, root_login=None, root_password=None): import frappe if frappe.conf.db_type == 'postgres': - pass + import frappe.database.postgres.setup_db + return frappe.database.postgres.setup_db.drop_user_and_database(db_name, root_login, root_password) else: import frappe.database.mariadb.setup_db return frappe.database.mariadb.setup_db.drop_user_and_database(db_name, root_login, root_password) diff --git a/frappe/database/database.py b/frappe/database/database.py index 4152e32cd7..cf924d0a57 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -10,7 +10,7 @@ import re import string from contextlib import contextmanager from time import time -from typing import Dict, List, Tuple, Union +from typing import Dict, List, Optional, Tuple, Union from pypika.terms import Criterion, NullValue, PseudoColumn @@ -119,6 +119,9 @@ class Database(object): if not run: return query + # remove \n \t from start and end of query + query = re.sub(r'^\s*|\s*$', '', query) + if re.search(r'ifnull\(', query, flags=re.IGNORECASE): # replaces ifnull in query with coalesce query = re.sub(r'ifnull\(', 'coalesce(', query, flags=re.IGNORECASE) @@ -142,8 +145,6 @@ class Database(object): self.log_query(query, values, debug, explain) if values!=(): - if isinstance(values, dict): - values = dict(values) # MySQL-python==1.2.5 hack! if not isinstance(values, (dict, tuple, list)): @@ -386,7 +387,7 @@ class Database(object): """ ret = self.get_values(doctype, filters, fieldname, ignore, as_dict, debug, - order_by, cache=cache, for_update=for_update, run=run, pluck=pluck, distinct=distinct) + order_by, cache=cache, for_update=for_update, run=run, pluck=pluck, distinct=distinct, limit=1) if not run: return ret @@ -395,7 +396,7 @@ class Database(object): def get_values(self, doctype, filters=None, fieldname="name", ignore=None, as_dict=False, debug=False, order_by="KEEP_DEFAULT_ORDERING", update=None, cache=False, for_update=False, - run=True, pluck=False, distinct=False): + run=True, pluck=False, distinct=False, limit=None): """Returns multiple document properties. :param doctype: DocType name. @@ -425,14 +426,15 @@ class Database(object): if isinstance(filters, list): out = self._get_value_for_many_names( - doctype, - filters, - fieldname, - order_by, + doctype=doctype, + names=filters, + field=fieldname, + order_by=order_by, debug=debug, run=run, pluck=pluck, distinct=distinct, + limit=limit, ) else: @@ -446,17 +448,18 @@ class Database(object): if order_by: order_by = "modified" if order_by == "KEEP_DEFAULT_ORDERING" else order_by out = self._get_values_from_table( - fields, - filters, - doctype, - as_dict, - debug, - order_by, - update, + fields=fields, + filters=filters, + doctype=doctype, + as_dict=as_dict, + debug=debug, + order_by=order_by, + update=update, for_update=for_update, run=run, pluck=pluck, - distinct=distinct + distinct=distinct, + limit=limit, ) except Exception as e: if ignore and (frappe.db.is_missing_column(e) or frappe.db.is_table_missing(e)): @@ -558,7 +561,7 @@ class Database(object): def get_list(*args, **kwargs): return frappe.get_list(*args, **kwargs) - def set_single_value(self, doctype, fieldname, value, *args, **kwargs): + def set_single_value(self, doctype: str, fieldname: Union[str, Dict], value: Optional[Union[str, int]] = None, *args, **kwargs): """Set field value of Single DocType. :param doctype: DocType of the single object @@ -625,6 +628,7 @@ class Database(object): run=True, pluck=False, distinct=False, + limit=None, ): field_objects = [] @@ -643,6 +647,7 @@ class Database(object): field_objects=field_objects, fields=fields, distinct=distinct, + limit=limit, ) if ( fields == "*" @@ -656,7 +661,7 @@ class Database(object): ) return r - def _get_value_for_many_names(self, doctype, names, field, order_by, debug=False, run=True, pluck=False, distinct=False): + def _get_value_for_many_names(self, doctype, names, field, order_by, debug=False, run=True, pluck=False, distinct=False, limit=None): names = list(filter(None, names)) if names: return self.get_all( @@ -669,6 +674,7 @@ class Database(object): as_list=1, run=run, distinct=distinct, + limit_page_length=limit ) else: return {} @@ -884,27 +890,39 @@ class Database(object): return self.sql("select name from `tab{doctype}` limit 1".format(doctype=doctype)) def exists(self, dt, dn=None, cache=False): - """Returns true if document exists. + """Return the document name of a matching document, or None. - :param dt: DocType name. - :param dn: Document name or filter dict.""" - if isinstance(dt, str): - if dt!="DocType" and dt==dn: - return True # single always exists (!) - try: - return self.get_value(dt, dn, "name", cache=cache) - except Exception: - return None + Note: `cache` only works if `dt` and `dn` are of type `str`. - elif isinstance(dt, dict) and dt.get('doctype'): - try: - conditions = [] - for d in dt: - if d == 'doctype': continue - conditions.append([d, '=', dt[d]]) - return self.get_all(dt['doctype'], filters=conditions, as_list=1) - except Exception: - return None + ## Examples + + Pass doctype and docname (only in this case we can cache the result) + + ``` + exists("User", "jane@example.org", cache=True) + ``` + + Pass a dict of filters including the `"doctype"` key: + + ``` + exists({"doctype": "User", "full_name": "Jane Doe"}) + ``` + + Pass the doctype and a dict of filters: + + ``` + exists("User", {"full_name": "Jane Doe"}) + ``` + """ + if dt != "DocType" and dt == dn: + # single always exists (!) + return dn + + if isinstance(dt, dict): + dt = dt.copy() # don't modify the original dict + dt, dn = dt.pop("doctype"), dt + + return self.get_value(dt, dn, ignore=True, cache=cache) def count(self, dt, filters=None, debug=False, cache=False): """Returns `COUNT(*)` for given DocType and filters.""" diff --git a/frappe/database/mariadb/schema.py b/frappe/database/mariadb/schema.py index fd4bfc6dd0..3b7aa443f2 100644 --- a/frappe/database/mariadb/schema.py +++ b/frappe/database/mariadb/schema.py @@ -1,12 +1,16 @@ import frappe from frappe import _ from frappe.database.schema import DBTable +from frappe.database.sequence import create_sequence +from frappe.model import log_types + class MariaDBTable(DBTable): def create(self): additional_definitions = "" engine = self.meta.get("engine") or "InnoDB" varchar_len = frappe.db.VARCHAR_LEN + name_column = f"name varchar({varchar_len}) primary key" # columns column_defs = self.get_column_definitions() @@ -29,9 +33,27 @@ class MariaDBTable(DBTable): ) ) + ',\n' + # creating sequence(s) + if (not self.meta.issingle and self.meta.autoname == "autoincrement")\ + or self.doctype in log_types: + + # NOTE: using a very small cache - as during backup, if the sequence was used in anyform, + # it drops the cache and uses the next non cached value in setval func and + # puts that in the backup file, which will start the counter + # from that value when inserting any new record in the doctype. + # By default the cache is 1000 which will mess up the sequence when + # using the system after a restore. + # issue link: https://jira.mariadb.org/browse/MDEV-21786 + create_sequence(self.doctype, check_not_exists=True, cache=50) + + # NOTE: not used nextval func as default as the ability to restore + # database with sequences has bugs in mariadb and gives a scary error. + # issue link: https://jira.mariadb.org/browse/MDEV-21786 + name_column = "name bigint primary key" + # create table query = f"""create table `{self.table_name}` ( - name varchar({varchar_len}) not null primary key, + {name_column}, creation datetime(6), modified datetime(6), modified_by varchar({varchar_len}), diff --git a/frappe/database/postgres/database.py b/frappe/database/postgres/database.py index a20ffe17a5..eb3e33d39c 100644 --- a/frappe/database/postgres/database.py +++ b/frappe/database/postgres/database.py @@ -99,8 +99,13 @@ class PostgresDatabase(Database): return db_size[0].get('database_size') # pylint: disable=W0221 - def sql(self, query, *args, **kwargs): - return super(PostgresDatabase, self).sql(modify_query(query), *args, **kwargs) + def sql(self, query, values=(), *args, **kwargs): + return super(PostgresDatabase, self).sql( + modify_query(query), + modify_values(values), + *args, + **kwargs + ) def get_tables(self, cached=True): return [d[0] for d in self.sql("""select table_name @@ -333,10 +338,45 @@ def modify_query(query): if re.search('from tab', query, flags=re.IGNORECASE): query = re.sub(r'from tab([\w-]*)', r'from "tab\1"', query, flags=re.IGNORECASE) + # only find int (with/without signs), ignore decimals (with/without signs), ignore hashes (which start with numbers), + # drop .0 from decimals and add quotes around them + # + # >>> query = "c='abcd' , a >= 45, b = -45.0, c = 40, d=4500.0, e=3500.53, f=40psdfsd, g=9092094312, h=12.00023" + # >>> re.sub(r"([=><]+)\s*(?!\d+[a-zA-Z])(?![+-]?\d+\.\d\d+)([+-]?\d+)(\.0)?", r"\1 '\2'", query) + # "c='abcd' , a >= '45', b = '-45', c = '40', d= '4500', e=3500.53, f=40psdfsd, g= '9092094312', h=12.00023 + + query = re.sub(r"([=><]+)\s*(?!\d+[a-zA-Z])(?![+-]?\d+\.\d\d+)([+-]?\d+)(\.0)?", r"\1 '\2'", query) return query +def modify_values(values): + def stringify_value(value): + if isinstance(value, int): + value = str(value) + elif isinstance(value, float): + truncated_float = int(value) + if value == truncated_float: + value = str(truncated_float) + + return value + + if not values: + return values + + if isinstance(values, dict): + for k, v in values.items(): + values[k] = stringify_value(v) + elif isinstance(values, (tuple, list)): + new_values = [] + for val in values: + new_values.append(stringify_value(val)) + values = new_values + else: + values = stringify_value(values) + + return values + def replace_locate_with_strpos(query): # strpos is the locate equivalent in postgres if re.search(r'locate\(', query, flags=re.IGNORECASE): - query = re.sub(r'locate\(([^,]+),([^)]+)\)', r'strpos(\2, \1)', query, flags=re.IGNORECASE) + query = re.sub(r'locate\(([^,]+),([^)]+)(\)?)\)', r'strpos(\2\3, \1)', query, flags=re.IGNORECASE) return query diff --git a/frappe/database/postgres/schema.py b/frappe/database/postgres/schema.py index bb7ff20a26..b09f73300e 100644 --- a/frappe/database/postgres/schema.py +++ b/frappe/database/postgres/schema.py @@ -2,10 +2,14 @@ import frappe from frappe import _ from frappe.utils import cint, flt from frappe.database.schema import DBTable, get_definition +from frappe.database.sequence import create_sequence +from frappe.model import log_types + class PostgresTable(DBTable): def create(self): varchar_len = frappe.db.VARCHAR_LEN + name_column = f"name varchar({varchar_len}) primary key" additional_definitions = "" # columns @@ -26,9 +30,21 @@ class PostgresTable(DBTable): ) ) + # creating sequence(s) + if (not self.meta.issingle and self.meta.autoname == "autoincrement")\ + or self.doctype in log_types: + + # The sequence cache is per connection. + # Since we're opening and closing connections for every transaction this results in skipping the cache + # to the next non-cached value hence not using cache in postgres. + # ref: https://stackoverflow.com/questions/21356375/postgres-9-0-4-sequence-skipping-numbers + create_sequence(self.doctype, check_not_exists=True) + name_column = "name bigint primary key" + + # TODO: set docstatus length # create table frappe.db.sql(f"""create table `{self.table_name}` ( - name varchar({varchar_len}) not null primary key, + {name_column}, creation timestamp(6), modified timestamp(6), modified_by varchar({varchar_len}), diff --git a/frappe/database/postgres/setup_db.py b/frappe/database/postgres/setup_db.py index b3b2e0fd41..4b265e7660 100644 --- a/frappe/database/postgres/setup_db.py +++ b/frappe/database/postgres/setup_db.py @@ -95,3 +95,11 @@ def get_root_connection(root_login=None, root_password=None): frappe.local.flags.root_connection = frappe.database.get_db(user=root_login, password=root_password) return frappe.local.flags.root_connection + + +def drop_user_and_database(db_name, root_login, root_password): + root_conn = get_root_connection(frappe.flags.root_login or root_login, frappe.flags.root_password or root_password) + root_conn.commit() + root_conn.sql(f"SELECT pg_terminate_backend (pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = %s", (db_name, )) + root_conn.sql(f"DROP DATABASE IF EXISTS {db_name}") + root_conn.sql(f"DROP USER IF EXISTS {db_name}") diff --git a/frappe/database/sequence.py b/frappe/database/sequence.py new file mode 100644 index 0000000000..334fd3d71e --- /dev/null +++ b/frappe/database/sequence.py @@ -0,0 +1,80 @@ +from frappe import db, scrub + + +def create_sequence( + doctype_name: str, + *, + slug: str = "_id_seq", + check_not_exists: bool = False, + cycle: bool = False, + cache: int = 0, + start_value: int = 0, + increment_by: int = 0, + min_value: int = 0, + max_value: int = 0 +) -> str: + + query = "create sequence" + sequence_name = scrub(doctype_name + slug) + + if check_not_exists: + query += " if not exists" + + query += f" {sequence_name}" + + if cache: + query += f" cache {cache}" + else: + # in postgres, the default is cache 1 + if db.db_type == "mariadb": + query += " nocache" + + if start_value: + # default is 1 + query += f" start with {start_value}" + + if increment_by: + # default is 1 + query += f" increment by {increment_by}" + + if min_value: + # default is 1 + query += f" min value {min_value}" + + if max_value: + query += f" max value {max_value}" + + if not cycle: + if db.db_type == "mariadb": + query += " nocycle" + else: + query += " cycle" + + db.sql(query) + + return sequence_name + + +def get_next_val(doctype_name: str, slug: str = "_id_seq") -> int: + if db.db_type == "postgres": + return db.sql(f"select nextval(\'\"{scrub(doctype_name + slug)}\"\')")[0][0] + return db.sql(f"select nextval(`{scrub(doctype_name + slug)}`)")[0][0] + + +def set_next_val( + doctype_name: str, + next_val: int, + *, + slug: str = "_id_seq", + is_val_used :bool = False +) -> None: + + if not is_val_used: + is_val_used = 0 if db.db_type == "mariadb" else "f" + else: + is_val_used = 1 if db.db_type == "mariadb" else "t" + + if db.db_type == "postgres": + db.sql(f"SELECT SETVAL('\"{scrub(doctype_name + slug)}\"', {next_val}, '{is_val_used}')") + else: + db.sql(f"SELECT SETVAL(`{scrub(doctype_name + slug)}`, {next_val}, {is_val_used})") diff --git a/frappe/desk/doctype/todo/todo.py b/frappe/desk/doctype/todo/todo.py index e689faafbe..3b84b94754 100644 --- a/frappe/desk/doctype/todo/todo.py +++ b/frappe/desk/doctype/todo/todo.py @@ -72,7 +72,8 @@ class ToDo(Document): assignments = frappe.get_all("ToDo", filters={ "reference_type": self.reference_type, "reference_name": self.reference_name, - "status": ("!=", "Cancelled") + "status": ("!=", "Cancelled"), + "allocated_to": ("is", "set") }, pluck="allocated_to") assignments.reverse() diff --git a/frappe/desk/form/load.py b/frappe/desk/form/load.py index dba84e5175..9abf64c2cc 100644 --- a/frappe/desk/form/load.py +++ b/frappe/desk/form/load.py @@ -10,6 +10,7 @@ import frappe.desk.form.meta from frappe.model.utils.user_settings import get_user_settings from frappe.permissions import get_doc_permissions from frappe.desk.form.document_follow import is_document_followed +from frappe.utils.data import cstr from frappe import _ from frappe import _dict from urllib.parse import quote @@ -311,6 +312,7 @@ def get_assignments(dt, dn): 'reference_type': dt, 'reference_name': dn, 'status': ('!=', 'Cancelled'), + 'allocated_to': ("is", "set") }) @frappe.whitelist() @@ -355,7 +357,7 @@ def get_document_email(doctype, name): return None email = email.split("@") - return "{0}+{1}+{2}@{3}".format(email[0], quote(doctype), quote(name), email[1]) + return "{0}+{1}+{2}@{3}".format(email[0], quote(doctype), quote(cstr(name)), email[1]) def get_automatic_email_link(): return frappe.db.get_value("Email Account", {"enable_incoming": 1, "enable_automatic_linking": 1}, "email_id") diff --git a/frappe/desk/query_report.py b/frappe/desk/query_report.py index b344763916..f5f50b14fe 100644 --- a/frappe/desk/query_report.py +++ b/frappe/desk/query_report.py @@ -352,14 +352,10 @@ def export_query(): ) return - columns = get_columns_dict(data.columns) - from frappe.utils.xlsxutils import make_xlsx - data["result"] = handle_duration_fieldtype_values( - data.get("result"), data.get("columns") - ) - xlsx_data, column_widths = build_xlsx_data(columns, data, visible_idx, include_indentation) + format_duration_fields(data) + xlsx_data, column_widths = build_xlsx_data(data, visible_idx, include_indentation) xlsx_file = make_xlsx(xlsx_data, "Query Report", column_widths=column_widths) frappe.response["filename"] = report_name + ".xlsx" @@ -367,39 +363,18 @@ def export_query(): frappe.response["type"] = "binary" -def handle_duration_fieldtype_values(result, columns): - for i, col in enumerate(columns): - fieldtype = None - if isinstance(col, str): - col = col.split(":") - if len(col) > 1: - if col[1]: - fieldtype = col[1] - if "/" in fieldtype: - fieldtype, options = fieldtype.split("/") - else: - fieldtype = "Data" - else: - fieldtype = col.get("fieldtype") +def format_duration_fields(data: frappe._dict) -> None: + for i, col in enumerate(data.columns): + if col.get("fieldtype") != "Duration": + continue - if fieldtype == "Duration": - for entry in range(0, len(result)): - row = result[entry] - if isinstance(row, dict): - val_in_seconds = row[col.fieldname] - if val_in_seconds: - duration_val = format_duration(val_in_seconds) - row[col.fieldname] = duration_val - else: - val_in_seconds = row[i] - if val_in_seconds: - duration_val = format_duration(val_in_seconds) - row[i] = duration_val - - return result + for row in data.result: + index = col.fieldname if isinstance(row, dict) else i + if row[index]: + row[index] = format_duration(row[index]) -def build_xlsx_data(columns, data, visible_idx, include_indentation, ignore_visible_idx=False): +def build_xlsx_data(data, visible_idx, include_indentation, ignore_visible_idx=False): result = [[]] column_widths = [] diff --git a/frappe/desk/search.py b/frappe/desk/search.py index 3b76953ed1..dd22f821cf 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -9,7 +9,6 @@ from frappe import _, is_whitelisted import re import wrapt -UNTRANSLATED_DOCTYPES = ["DocType", "Role"] def sanitize_searchfield(searchfield): blacklisted_keywords = ['select', 'delete', 'drop', 'update', 'case', 'and', 'or', 'like'] @@ -114,6 +113,7 @@ def search_widget(doctype, txt, query=None, searchfield=None, start=0, or_filters = [] + translated_search_doctypes = frappe.get_hooks("translated_search_doctypes") # build from doctype if txt: search_fields = ["name"] @@ -125,7 +125,7 @@ def search_widget(doctype, txt, query=None, searchfield=None, start=0, for f in search_fields: fmeta = meta.get_field(f.strip()) - if (doctype not in UNTRANSLATED_DOCTYPES) and (f == "name" or (fmeta and fmeta.fieldtype in ["Data", "Text", "Small Text", "Long Text", + if (doctype not in translated_search_doctypes) and (f == "name" or (fmeta and fmeta.fieldtype in ["Data", "Text", "Small Text", "Long Text", "Link", "Select", "Read Only", "Text Editor"])): or_filters.append([doctype, f.strip(), "like", "%{0}%".format(txt)]) @@ -160,7 +160,7 @@ def search_widget(doctype, txt, query=None, searchfield=None, start=0, ptype = 'select' if frappe.only_has_select_perm(doctype) else 'read' ignore_permissions = True if doctype == "DocType" else (cint(ignore_user_permissions) and has_permission(doctype, ptype=ptype)) - if doctype in UNTRANSLATED_DOCTYPES: + if doctype in translated_search_doctypes: page_length = None values = frappe.get_list(doctype, @@ -175,7 +175,7 @@ def search_widget(doctype, txt, query=None, searchfield=None, start=0, as_list=not as_dict, strict=False) - if doctype in UNTRANSLATED_DOCTYPES: + if doctype in translated_search_doctypes: # Filtering the values array so that query is included in very element values = ( v for v in values @@ -257,7 +257,7 @@ def scrub_custom_query(query, key, txt): def relevance_sorter(key, query, as_dict): value = _(key.name if as_dict else key[0]) return ( - value.lower().startswith(query.lower()) is not True, + cstr(value).lower().startswith(query.lower()) is not True, value ) diff --git a/frappe/email/doctype/auto_email_report/auto_email_report.py b/frappe/email/doctype/auto_email_report/auto_email_report.py index 682f0df7cf..abeb681a25 100644 --- a/frappe/email/doctype/auto_email_report/auto_email_report.py +++ b/frappe/email/doctype/auto_email_report/auto_email_report.py @@ -15,8 +15,6 @@ from frappe.utils.csvutils import to_csv from frappe.utils.xlsxutils import make_xlsx from frappe.desk.query_report import build_xlsx_data -max_reports_per_user = frappe.local.conf.max_reports_per_user or 3 - class AutoEmailReport(Document): def autoname(self): @@ -46,6 +44,8 @@ class AutoEmailReport(Document): def validate_report_count(self): '''check that there are only 3 enabled reports per user''' count = frappe.db.sql('select count(*) from `tabAuto Email Report` where user=%s and enabled=1', self.user)[0][0] + max_reports_per_user = frappe.local.conf.max_reports_per_user or 3 + if count > max_reports_per_user + (-1 if self.flags.in_insert else 0): frappe.throw(_('Only {0} emailed reports are allowed per user').format(max_reports_per_user)) @@ -104,7 +104,7 @@ class AutoEmailReport(Document): report_data['columns'] = columns report_data['result'] = data - xlsx_data, column_widths = build_xlsx_data(columns, report_data, [], 1, ignore_visible_idx=True) + xlsx_data, column_widths = build_xlsx_data(report_data, [], 1, ignore_visible_idx=True) xlsx_file = make_xlsx(xlsx_data, "Auto Email Report", column_widths=column_widths) return xlsx_file.getvalue() @@ -113,7 +113,7 @@ class AutoEmailReport(Document): report_data['columns'] = columns report_data['result'] = data - xlsx_data, column_widths = build_xlsx_data(columns, report_data, [], 1, ignore_visible_idx=True) + xlsx_data, column_widths = build_xlsx_data(report_data, [], 1, ignore_visible_idx=True) return to_csv(xlsx_data) else: diff --git a/frappe/email/doctype/email_queue/email_queue.py b/frappe/email/doctype/email_queue/email_queue.py index 9b4f3b984c..dd8623cae2 100644 --- a/frappe/email/doctype/email_queue/email_queue.py +++ b/frappe/email/doctype/email_queue/email_queue.py @@ -111,7 +111,6 @@ class EmailQueue(Document): """ Send emails to recipients. """ if not self.can_send_now(): - frappe.db.rollback() return with SendMailContext(self, is_background_task) as ctx: diff --git a/frappe/email/doctype/newsletter/test_newsletter.py b/frappe/email/doctype/newsletter/test_newsletter.py index 8c1f803a46..b091c31c74 100644 --- a/frappe/email/doctype/newsletter/test_newsletter.py +++ b/frappe/email/doctype/newsletter/test_newsletter.py @@ -51,7 +51,7 @@ class TestNewsletterMixin: "reference_name": newsletter, }) frappe.delete_doc("Newsletter", newsletter) - frappe.db.delete("Newsletter Email Group", newsletter) + frappe.db.delete("Newsletter Email Group", {"parent": newsletter}) newsletters.remove(newsletter) def setup_email_group(self): diff --git a/frappe/email/doctype/notification/test_notification.py b/frappe/email/doctype/notification/test_notification.py index f05d35be3e..f6f216ada2 100644 --- a/frappe/email/doctype/notification/test_notification.py +++ b/frappe/email/doctype/notification/test_notification.py @@ -240,7 +240,7 @@ class TestNotification(unittest.TestCase): self.assertTrue(email_queue) # check if description is changed after alert since set_property_after_alert is set - self.assertEquals(todo.description, 'Changed by Notification') + self.assertEqual(todo.description, 'Changed by Notification') recipients = [d.recipient for d in email_queue.recipients] self.assertTrue('test2@example.com' in recipients) diff --git a/frappe/email/queue.py b/frappe/email/queue.py index 16e3fecf48..f6f52e79e2 100755 --- a/frappe/email/queue.py +++ b/frappe/email/queue.py @@ -1,10 +1,12 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE import frappe from frappe import msgprint, _ from frappe.utils.verified_command import get_signed_params, verify_request from frappe.utils import get_url, now_datetime, cint +from frappe.query_builder import DocType, Interval +from frappe.query_builder.functions import Now def get_emails_sent_this_month(email_account=None): """Get count of emails sent from a specific email account. @@ -162,15 +164,16 @@ def get_queue(): by priority desc, creation asc limit 500''', { 'now': now_datetime() }, as_dict=True) -def clear_outbox(days=None): +def clear_outbox(days: int = None) -> None: """Remove low priority older than 31 days in Outbox or configured in Log Settings. Note: Used separate query to avoid deadlock """ - if not days: - days=31 + days = days or 31 + email_queue = DocType("Email Queue") - email_queues = frappe.db.sql_list("""SELECT `name` FROM `tabEmail Queue` - WHERE `priority`=0 AND `modified` < (NOW() - INTERVAL '{0}' DAY)""".format(days)) + email_queues = frappe.qb.from_(email_queue).select(email_queue.name).where( + email_queue.modified < (Now() - Interval(days=days)) + ).run(pluck=True) if email_queues: frappe.db.delete("Email Queue", {"name": ("in", email_queues)}) diff --git a/frappe/event_streaming/doctype/event_update_log/event_update_log.py b/frappe/event_streaming/doctype/event_update_log/event_update_log.py index f4871be312..cd5100623c 100644 --- a/frappe/event_streaming/doctype/event_update_log/event_update_log.py +++ b/frappe/event_streaming/doctype/event_update_log/event_update_log.py @@ -203,12 +203,17 @@ def get_unread_update_logs(consumer_name, dt, dn): SELECT update_log.name FROM `tabEvent Update Log` update_log - JOIN `tabEvent Update Log Consumer` consumer ON consumer.parent = update_log.name + JOIN `tabEvent Update Log Consumer` consumer ON consumer.parent = %(log_name)s WHERE consumer.consumer = %(consumer)s AND update_log.ref_doctype = %(dt)s AND update_log.docname = %(dn)s - """, {'consumer': consumer_name, "dt": dt, "dn": dn}, as_dict=0)] + """, { + "consumer": consumer_name, + "dt": dt, + "dn": dn, + "log_name": "update_log.name" if frappe.conf.db_type == "mariadb" else "CAST(update_log.name AS VARCHAR)" + }, as_dict=0)] logs = frappe.get_all( 'Event Update Log', diff --git a/frappe/exceptions.py b/frappe/exceptions.py index 6ee72b5f81..fcac349708 100644 --- a/frappe/exceptions.py +++ b/frappe/exceptions.py @@ -110,3 +110,6 @@ class InvalidAuthorizationPrefix(CSRFTokenError): pass class InvalidAuthorizationToken(CSRFTokenError): pass class InvalidDatabaseFile(ValidationError): pass class ExecutableNotFound(FileNotFoundError): pass + +class InvalidRemoteException(Exception): + pass diff --git a/frappe/frappeclient.py b/frappe/frappeclient.py index 59db38584c..7a1587aae0 100644 --- a/frappe/frappeclient.py +++ b/frappe/frappeclient.py @@ -7,6 +7,7 @@ import json import requests import frappe +from frappe.utils.data import cstr class AuthError(Exception): @@ -122,7 +123,7 @@ class FrappeClient(object): '''Update a remote document :param doc: dict or Document object to be updated remotely. `name` is mandatory for this''' - url = self.url + "/api/resource/" + doc.get("doctype") + "/" + doc.get("name") + url = self.url + "/api/resource/" + doc.get("doctype") + "/" + cstr(doc.get("name")) res = self.session.put(url, data={"data":frappe.as_json(doc)}, verify=self.verify, headers=self.headers) return frappe._dict(self.post_process(res)) @@ -207,7 +208,7 @@ class FrappeClient(object): if fields: params["fields"] = json.dumps(fields) - res = self.session.get(self.url + "/api/resource/" + doctype + "/" + name, + res = self.session.get(self.url + "/api/resource/" + doctype + "/" + cstr(name), params=params, verify=self.verify, headers=self.headers) return self.post_process(res) diff --git a/frappe/handler.py b/frappe/handler.py old mode 100755 new mode 100644 index bba2f3c057..b4ec077947 --- a/frappe/handler.py +++ b/frappe/handler.py @@ -251,11 +251,10 @@ def ping(): def run_doc_method(method, docs=None, dt=None, dn=None, arg=None, args=None): """run a whitelisted controller method""" - import inspect - import json + from inspect import getfullargspec - if not args: - args = arg or "" + if not args and arg: + args = arg if dt: # not called from a doctype (from a page) if not dn: @@ -263,9 +262,7 @@ def run_doc_method(method, docs=None, dt=None, dn=None, arg=None, args=None): doc = frappe.get_doc(dt, dn) else: - if isinstance(docs, str): - docs = json.loads(docs) - + docs = frappe.parse_json(docs) doc = frappe.get_doc(docs) doc._original_modified = doc.modified doc.check_if_latest() @@ -274,16 +271,16 @@ def run_doc_method(method, docs=None, dt=None, dn=None, arg=None, args=None): throw_permission_error() try: - args = json.loads(args) + args = frappe.parse_json(args) except ValueError: - args = args + pass method_obj = getattr(doc, method) fn = getattr(method_obj, '__func__', method_obj) is_whitelisted(fn) is_valid_http_method(fn) - fnargs = inspect.getfullargspec(method_obj).args + fnargs = getfullargspec(method_obj).args if not fnargs or (len(fnargs)==1 and fnargs[0]=="self"): response = doc.run_method(method) diff --git a/frappe/hooks.py b/frappe/hooks.py index 1ecd47b535..74473966c1 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -394,4 +394,6 @@ override_whitelisted_methods = { "frappe.core.doctype.file.file.create_new_folder": "frappe.core.api.file.create_new_folder", "frappe.core.doctype.file.file.move_file": "frappe.core.api.file.move_file", "frappe.core.doctype.file.file.zip_files": "frappe.core.api.file.zip_files", -} \ No newline at end of file +} + +translated_search_doctypes = ["DocType", "Role", "Country", "Gender", "Salutation"] diff --git a/frappe/installer.py b/frappe/installer.py index 6ebab95a7d..e28a942f01 100644 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -5,10 +5,11 @@ import json import os import sys from collections import OrderedDict -from typing import List, Dict +from typing import List, Dict, Tuple import frappe from frappe.defaults import _clear_cache +from frappe.utils import is_git_url def _new_site( @@ -34,7 +35,6 @@ def _new_site( from frappe.commands.scheduler import _is_scheduler_enabled from frappe.utils import get_site_path, scheduler, touch_file - if not force and os.path.exists(site): print("Site {0} already exists".format(site)) sys.exit(1) @@ -124,6 +124,86 @@ def install_db(root_login=None, root_password=None, db_name=None, source_sql=Non frappe.flags.in_install_db = False +def find_org(org_repo: str) -> Tuple[str, str]: + """ find the org a repo is in + + find_org() + ref -> https://github.com/frappe/bench/blob/develop/bench/utils/__init__.py#L390 + + :param org_repo: + :type org_repo: str + + :raises InvalidRemoteException: if the org is not found + + :return: organisation and repository + :rtype: Tuple[str, str] + """ + from frappe.exceptions import InvalidRemoteException + import requests + + for org in ["frappe", "erpnext"]: + res = requests.head(f"https://api.github.com/repos/{org}/{org_repo}") + if res.ok: + return org, org_repo + + raise InvalidRemoteException + + +def fetch_details_from_tag(_tag: str) -> Tuple[str, str, str]: + """ parse org, repo, tag from string + + fetch_details_from_tag() + ref -> https://github.com/frappe/bench/blob/develop/bench/utils/__init__.py#L403 + + :param _tag: input string + :type _tag: str + + :return: organisation, repostitory, tag + :rtype: Tuple[str, str, str] + """ + app_tag = _tag.split("@") + org_repo = app_tag[0].split("/") + + try: + repo, tag = app_tag + except ValueError: + repo, tag = app_tag + [None] + + try: + org, repo = org_repo + except Exception: + org, repo = find_org(org_repo[0]) + + return org, repo, tag + + +def parse_app_name(name: str) -> str: + """parse repo name from name + + __setup_details_from_git() + ref -> https://github.com/frappe/bench/blob/develop/bench/app.py#L114 + + + :param name: git tag + :type name: str + + :return: repository name + :rtype: str + """ + name = name.rstrip("/") + if os.path.exists(name): + repo = os.path.split(name)[-1] + elif is_git_url(name): + if name.startswith("git@") or name.startswith("ssh://"): + _repo = name.split(":")[1].rsplit("/", 1)[1] + else: + _repo = name.rsplit("/", 2)[2] + repo = _repo.split(".")[0] + else: + _, repo, _ = fetch_details_from_tag(name) + return repo + + def install_app(name, verbose=False, set_as_patched=True): from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs from frappe.model.sync import sync_for @@ -140,7 +220,8 @@ def install_app(name, verbose=False, set_as_patched=True): # install pre-requisites if app_hooks.required_apps: for app in app_hooks.required_apps: - install_app(app, verbose=verbose) + name = parse_app_name(name) + install_app(name, verbose=verbose) frappe.flags.in_install = name frappe.clear_cache() @@ -611,7 +692,7 @@ def is_downgrade(sql_file_path, verbose=False): downgrade = backup_version > current_version if verbose and downgrade: - print("Your site will be downgraded from Frappe {0} to {1}".format(current_version, backup_version)) + print(f"Your site will be downgraded from Frappe {backup_version} to {current_version}") return downgrade diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 0b9f6e0211..be233eb7e3 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -475,7 +475,7 @@ class BaseDocument(object): d = self.get_valid_dict(convert_dates_to_str=True, ignore_nulls = self.doctype in DOCTYPES_FOR_DOCTYPE) # don't update name, as case might've been changed - name = d['name'] + name = cstr(d['name']) del d['name'] columns = list(d) @@ -963,10 +963,13 @@ class BaseDocument(object): from frappe.model.meta import get_default_df df = get_default_df(fieldname) - if not currency and df: - currency = self.get(df.get("options")) - if not frappe.db.exists('Currency', currency, cache=True): - currency = None + if ( + df.fieldtype == "Currency" + and not currency + and (currency_field := df.get("options")) + and (currency_value := self.get(currency_field)) + ): + currency = frappe.db.get_value('Currency', currency_value, cache=True) val = self.get(fieldname) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index a6b96e8fb5..16056d382a 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -164,7 +164,8 @@ class DatabaseQuery(object): # left join parent, child tables for child in self.tables[1:]: - args.tables += f" {self.join} {child} on ({child}.parent = {self.tables[0]}.name)" + parent_name = self.cast_name(f"{self.tables[0]}.name") + args.tables += f" {self.join} {child} on ({child}.parent = {parent_name})" if self.grouped_or_conditions: self.conditions.append(f"({' or '.join(self.grouped_or_conditions)})") @@ -318,21 +319,60 @@ class DatabaseQuery(object): ] # add tables from fields if self.fields: - for field in self.fields: - if not ("tab" in field and "." in field) or any(x for x in sql_functions if x in field): + for i, field in enumerate(self.fields): + # add cast in locate/strpos + func_found = False + for func in sql_functions: + if func in field.lower(): + self.fields[i] = self.cast_name(field, func) + func_found = True + break + + if func_found or not ("tab" in field and "." in field): continue table_name = field.split('.')[0] if table_name.lower().startswith('group_concat('): table_name = table_name[13:] - if table_name.lower().startswith('ifnull('): - table_name = table_name[7:] if not table_name[0]=='`': table_name = f"`{table_name}`" if table_name not in self.tables: self.append_table(table_name) + def cast_name(self, column: str, sql_function: str = "",) -> str: + if frappe.db.db_type == "postgres": + if "name" in column.lower(): + if "cast(" not in column.lower() or "::" not in column: + if not sql_function: + return f"cast({column} as varchar)" + + elif sql_function == "locate(": + return re.sub( + r'locate\(([^,]+),([^)]+)\)', + r'locate(\1, cast(\2 as varchar))', + column, + flags=re.IGNORECASE + ) + + elif sql_function == "strpos(": + return re.sub( + r'strpos\(([^,]+),([^)]+)\)', + r'strpos(cast(\1 as varchar), \2)', + column, + flags=re.IGNORECASE + ) + + elif sql_function == "ifnull(": + return re.sub( + r"ifnull\(([^,]+)", + r"ifnull(cast(\1 as varchar)", + column, + flags=re.IGNORECASE + ) + + return column + def append_table(self, table_name): self.tables.append(table_name) doctype = table_name[4:-1] @@ -423,6 +463,8 @@ class DatabaseQuery(object): ifnull(`tabDocType`.`fieldname`, fallback) operator "value" """ + # TODO: refactor + from frappe.boot import get_additional_filters_from_hooks additional_filters_config = get_additional_filters_from_hooks() f = get_filter(self.doctype, f, additional_filters_config) @@ -432,15 +474,16 @@ class DatabaseQuery(object): self.append_table(tname) if 'ifnull(' in f.fieldname: - column_name = f.fieldname + column_name = self.cast_name(f.fieldname, "ifnull(") else: - column_name = f"{tname}.{f.fieldname}" - - can_be_null = True + column_name = self.cast_name(f"{tname}.{f.fieldname}") if f.operator.lower() in additional_filters_config: f.update(get_additional_filter_field(additional_filters_config, f, f.value)) + meta = frappe.get_meta(f.doctype) + can_be_null = True + # prepare in condition if f.operator.lower() in ('ancestors of', 'descendants of', 'not ancestors of', 'not descendants of'): values = f.value or '' @@ -449,12 +492,8 @@ class DatabaseQuery(object): # if not isinstance(values, (list, tuple)): # values = values.split(",") - ref_doctype = f.doctype - - if frappe.get_meta(f.doctype).get_field(f.fieldname) is not None : - ref_doctype = frappe.get_meta(f.doctype).get_field(f.fieldname).options - - result=[] + field = meta.get_field(f.fieldname) + ref_doctype = field.options if field else f.doctype lft, rgt = '', '' if f.value: @@ -474,29 +513,30 @@ class DatabaseQuery(object): }, order_by='`lft` DESC') fallback = "''" - value = [frappe.db.escape((v.name or '').strip(), percent=False) for v in result] + value = [frappe.db.escape((cstr(v.name) or '').strip(), percent=False) for v in result] if len(value): value = f"({', '.join(value)})" else: value = "('')" + # changing operator to IN as the above code fetches all the parent / child values and convert into tuple # which can be directly used with IN operator to query. f.operator = 'not in' if f.operator.lower() in ('not ancestors of', 'not descendants of') else 'in' - elif f.operator.lower() in ('in', 'not in'): values = f.value or '' if isinstance(values, str): values = values.split(",") fallback = "''" - value = [frappe.db.escape((v or '').strip(), percent=False) for v in values] + value = [frappe.db.escape((cstr(v) or '').strip(), percent=False) for v in values] if len(value): value = f"({', '.join(value)})" else: value = "('')" + else: - df = frappe.get_meta(f.doctype).get("fields", {"fieldname": f.fieldname}) + df = meta.get("fields", {"fieldname": f.fieldname}) df = df[0] if df else None if df and df.fieldtype in ("Check", "Float", "Int", "Currency", "Percent"): @@ -513,7 +553,8 @@ class DatabaseQuery(object): fallback = "'0001-01-01 00:00:00'" elif f.operator.lower() in ('between') and \ - (f.fieldname in ('creation', 'modified') or (df and (df.fieldtype=="Date" or df.fieldtype=="Datetime"))): + (f.fieldname in ('creation', 'modified') or + (df and (df.fieldtype=="Date" or df.fieldtype=="Datetime"))): value = get_between_date_filter(f.value, df) fallback = "'0001-01-01 00:00:00'" @@ -528,7 +569,7 @@ class DatabaseQuery(object): fallback = "''" can_be_null = True - if 'ifnull' not in column_name: + if 'ifnull' not in column_name.lower(): column_name = f'ifnull({column_name}, {fallback})' elif df and df.fieldtype=="Date": @@ -570,7 +611,7 @@ class DatabaseQuery(object): value = f"{tname}.{quote}{f.value.name}{quote}" # escape value - elif isinstance(value, str) and not f.operator.lower() == 'between': + elif isinstance(value, str) and f.operator.lower() != 'between': value = f"{frappe.db.escape(value, percent=False)}" if ( diff --git a/frappe/model/delete_doc.py b/frappe/model/delete_doc.py index ef73a349cc..f055cd79d0 100644 --- a/frappe/model/delete_doc.py +++ b/frappe/model/delete_doc.py @@ -158,7 +158,7 @@ def update_naming_series(doc): and getattr(doc, "naming_series", None): revert_series_if_last(doc.naming_series, doc.name, doc) - elif doc.meta.autoname.split(":")[0] not in ("Prompt", "field", "hash"): + elif doc.meta.autoname.split(":")[0] not in ("Prompt", "field", "hash", "autoincrement"): revert_series_if_last(doc.meta.autoname, doc.name, doc) def delete_from_table(doctype, name, ignore_doctypes, doc): diff --git a/frappe/model/naming.py b/frappe/model/naming.py index 9024b3d7b4..013e5a19db 100644 --- a/frappe/model/naming.py +++ b/frappe/model/naming.py @@ -1,14 +1,18 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE -from typing import Optional +from typing import Optional, TYPE_CHECKING, Union import frappe from frappe import _ +from frappe.database.sequence import get_next_val, set_next_val from frappe.utils import now_datetime, cint, cstr import re from frappe.model import log_types from frappe.query_builder import DocType +if TYPE_CHECKING: + from frappe.model.meta import Meta + def set_new_name(doc): """ @@ -24,11 +28,16 @@ def set_new_name(doc): doc.run_method("before_naming") - autoname = frappe.get_meta(doc.doctype).autoname or "" + meta = frappe.get_meta(doc.doctype) + autoname = meta.autoname or "" if autoname.lower() != "prompt" and not frappe.flags.in_import: doc.name = None + if is_autoincremented(doc.doctype, meta): + doc.name = get_next_val(doc.doctype) + return + if getattr(doc, "amended_from", None): _set_amended_name(doc) return @@ -64,9 +73,37 @@ def set_new_name(doc): doc.name = validate_name( doc.doctype, doc.name, - frappe.get_meta(doc.doctype).get_field("name_case") + meta.get_field("name_case") ) +def is_autoincremented(doctype: str, meta: "Meta" = None): + if doctype in log_types: + if frappe.local.autoincremented_status_map.get(frappe.local.site) is None or \ + frappe.local.autoincremented_status_map[frappe.local.site] == -1: + if frappe.db.sql( + f"""select data_type FROM information_schema.columns + where column_name = 'name' and table_name = 'tab{doctype}'""" + )[0][0] == "bigint": + frappe.local.autoincremented_status_map[frappe.local.site] = 1 + return True + else: + frappe.local.autoincremented_status_map[frappe.local.site] = 0 + + elif frappe.local.autoincremented_status_map[frappe.local.site]: + return True + + else: + if not meta: + meta = frappe.get_meta(doctype) + + if getattr(meta, "issingle", False): + return False + + if meta.autoname == "autoincrement": + return True + + return False + def set_name_from_naming_options(autoname, doc): """ Get a name based on the autoname field option @@ -284,9 +321,19 @@ def get_default_naming_series(doctype): return None -def validate_name(doctype: str, name: str, case: Optional[str] = None): +def validate_name(doctype: str, name: Union[int, str], case: Optional[str] = None): if not name: frappe.throw(_("No Name Specified for {0}").format(doctype)) + + if isinstance(name, int): + if is_autoincremented(doctype): + # this will set the sequence val to be the provided name and set it to be used + # so that the sequence will start from the next val of the setted val(name) + set_next_val(doctype, name, is_val_used=True) + return name + + frappe.throw(_("Invalid name type (integer) for varchar name column"), frappe.NameError) + if name.startswith("New "+doctype): frappe.throw(_("There were some errors setting the name, please contact the administrator"), frappe.NameError) if case == "Title Case": diff --git a/frappe/modules/utils.py b/frappe/modules/utils.py index 13b52d2020..4768faff48 100644 --- a/frappe/modules/utils.py +++ b/frappe/modules/utils.py @@ -272,7 +272,7 @@ def make_boilerplate(template, doc, opts=None): frappe.utils.cstr(source.read()).format( app_publisher=app_publisher, year=frappe.utils.nowdate()[:4], - classname=doc.name.replace(" ", ""), + classname=doc.name.replace(" ", "").replace("-", ""), base_class_import=base_class_import, base_class=base_class, doctype=doc.name, **opts, diff --git a/frappe/patches.txt b/frappe/patches.txt index a666480c90..bc2bc22637 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -197,4 +197,5 @@ frappe.patches.v14_0.copy_mail_data #08.03.21 frappe.patches.v14_0.update_github_endpoints #08-11-2021 frappe.patches.v14_0.remove_db_aggregation frappe.patches.v14_0.update_color_names_in_kanban_board_column +frappe.patches.v14_0.update_is_system_generated_flag frappe.patches.v14_0.update_auto_account_deletion_duration diff --git a/frappe/patches/v14_0/update_is_system_generated_flag.py b/frappe/patches/v14_0/update_is_system_generated_flag.py new file mode 100644 index 0000000000..657e02aebc --- /dev/null +++ b/frappe/patches/v14_0/update_is_system_generated_flag.py @@ -0,0 +1,17 @@ +import frappe + +def execute(): + # assuming all customization generated by Admin is system generated customization + custom_field = frappe.qb.DocType("Custom Field") + ( + frappe.qb.update(custom_field) + .set(custom_field.is_system_generated, True) + .where(custom_field.owner == 'Administrator').run() + ) + + property_setter = frappe.qb.DocType("Property Setter") + ( + frappe.qb.update(property_setter) + .set(property_setter.is_system_generated, True) + .where(property_setter.owner == 'Administrator').run() + ) diff --git a/frappe/permissions.py b/frappe/permissions.py index af17faba01..a6c17fb59f 100644 --- a/frappe/permissions.py +++ b/frappe/permissions.py @@ -594,4 +594,4 @@ def is_parent_valid(child_doctype, parent_doctype): from frappe.core.utils import find parent_meta = frappe.get_meta(parent_doctype) child_table_field_exists = find(parent_meta.get_table_fields(), lambda d: d.options == child_doctype) - return not parent_meta.istable and child_table_field_exists \ No newline at end of file + return not parent_meta.istable and child_table_field_exists diff --git a/frappe/public/css/tree.css b/frappe/public/css/tree.css index 2aa411bc11..8b216bc321 100644 --- a/frappe/public/css/tree.css +++ b/frappe/public/css/tree.css @@ -24,7 +24,7 @@ ul.tree-children { } .tree-link .node-parent, .tree-link .node-leaf { - margin-right: 5px; + margin-right: 8px; } .tree-link.active i { color: #5e64ff; diff --git a/frappe/public/js/frappe/file_uploader/FileUploader.vue b/frappe/public/js/frappe/file_uploader/FileUploader.vue index 97ed5585e2..c671b28160 100644 --- a/frappe/public/js/frappe/file_uploader/FileUploader.vue +++ b/frappe/public/js/frappe/file_uploader/FileUploader.vue @@ -526,11 +526,13 @@ export default { error: true }); capture.show(); - capture.submit(data_url => { - let filename = `capture_${frappe.datetime.now_datetime().replaceAll(/[: -]/g, '_')}.png`; - this.url_to_file(data_url, filename, 'image/png').then((file) => - this.add_files([file]) - ); + capture.submit(data_urls => { + data_urls.forEach(data_url => { + let filename = `capture_${frappe.datetime.now_datetime().replaceAll(/[: -]/g, '_')}.png`; + this.url_to_file(data_url, filename, 'image/png').then((file) => + this.add_files([file]) + ); + }); }); }, show_google_drive_picker() { diff --git a/frappe/public/js/frappe/form/controls/attach.js b/frappe/public/js/frappe/form/controls/attach.js index bd66225171..a91058a208 100644 --- a/frappe/public/js/frappe/form/controls/attach.js +++ b/frappe/public/js/frappe/form/controls/attach.js @@ -37,8 +37,8 @@ frappe.ui.form.ControlAttach = class ControlAttach extends frappe.ui.form.Contro if(this.frm) { me.parse_validate_and_set_in_model(null); me.refresh(); - me.frm.attachments.remove_attachment_by_filename(me.value, function() { - me.parse_validate_and_set_in_model(null); + me.frm.attachments.remove_attachment_by_filename(me.value, async () => { + await me.parse_validate_and_set_in_model(null); me.refresh(); me.frm.doc.docstatus == 1 ? me.frm.save('Update') : me.frm.save(); }); @@ -110,9 +110,9 @@ frappe.ui.form.ControlAttach = class ControlAttach extends frappe.ui.form.Contro return this.value || null; } - on_upload_complete(attachment) { + async on_upload_complete(attachment) { if(this.frm) { - this.parse_validate_and_set_in_model(attachment.file_url); + await this.parse_validate_and_set_in_model(attachment.file_url); this.frm.attachments.update_attachment(attachment); this.frm.doc.docstatus == 1 ? this.frm.save('Update') : this.frm.save(); } diff --git a/frappe/public/js/frappe/form/footer/form_timeline.js b/frappe/public/js/frappe/form/footer/form_timeline.js index d440874f36..0070d384d7 100644 --- a/frappe/public/js/frappe/form/footer/form_timeline.js +++ b/frappe/public/js/frappe/form/footer/form_timeline.js @@ -454,7 +454,10 @@ class FormTimeline extends BaseTimeline { let edit_box = this.make_editable(edit_wrapper); let content_wrapper = comment_wrapper.find('.content'); let more_actions_wrapper = comment_wrapper.find('.more-actions'); - if (frappe.model.can_delete("Comment")) { + if (frappe.model.can_delete("Comment") && ( + frappe.session.user == doc.owner || + frappe.user.has_role("System Manager") + )) { const delete_option = $(`
  • diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index 56e909dd0c..6191e35073 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -246,10 +246,12 @@ frappe.ui.form.Form = class FrappeForm { var me = this; // on main doc - frappe.model.on(me.doctype, "*", function(fieldname, value, doc) { + frappe.model.on(me.doctype, "*", function(fieldname, value, doc, skip_dirty_trigger=false) { // set input - if(doc.name===me.docname) { - me.dirty(); + if (cstr(doc.name) === me.docname) { + if (!skip_dirty_trigger) { + me.dirty(); + } let field = me.fields_dict[fieldname]; field && field.refresh(fieldname); @@ -953,10 +955,12 @@ frappe.ui.form.Form = class FrappeForm { this.toolbar.set_primary_action(); } - disable_save() { + disable_save(set_dirty=false) { // IMPORTANT: this function should be called in refresh event this.save_disabled = true; this.toolbar.current_status = null; + // field changes should make form dirty + this.set_dirty = set_dirty; this.page.clear_primary_action(); } @@ -1447,7 +1451,7 @@ frappe.ui.form.Form = class FrappeForm { return doc; } - set_value(field, value, if_missing) { + set_value(field, value, if_missing, skip_dirty_trigger=false) { var me = this; var _set = function(f, v) { var fieldobj = me.fields_dict[f]; @@ -1467,7 +1471,7 @@ frappe.ui.form.Form = class FrappeForm { me.refresh_field(f); return Promise.resolve(); } else { - return frappe.model.set_value(me.doctype, me.doc.name, f, v); + return frappe.model.set_value(me.doctype, me.doc.name, f, v, me.fieldtype, skip_dirty_trigger); } } } else { @@ -1697,13 +1701,17 @@ frappe.ui.form.Form = class FrappeForm { } update_in_all_rows(table_fieldname, fieldname, value) { - // update the child value in all tables where it is missing - if(!value) return; - var cl = this.doc[table_fieldname] || []; - for(var i = 0; i < cl.length; i++){ - if(!cl[i][fieldname]) cl[i][fieldname] = value; - } - refresh_field("items"); + // Update the `value` of the field named `fieldname` in all rows of the + // child table named `table_fieldname`. + // Do not overwrite existing values. + if (value === undefined) return; + + frappe.model + .get_children(this.doc, table_fieldname) + .filter(child => !frappe.model.has_value(child.doctype, child.name, fieldname)) + .forEach(child => + frappe.model.set_value(child.doctype, child.name, fieldname, value) + ); } get_sum(table_fieldname, fieldname) { diff --git a/frappe/public/js/frappe/form/grid.js b/frappe/public/js/frappe/form/grid.js index e620caa244..7bbe4b123a 100644 --- a/frappe/public/js/frappe/form/grid.js +++ b/frappe/public/js/frappe/form/grid.js @@ -35,7 +35,7 @@ export default class Grid { && this.frm.meta.__form_grid_templates[this.df.fieldname]) { this.template = this.frm.meta.__form_grid_templates[this.df.fieldname]; } - + this.filter = {}; this.is_grid = true; this.debounced_refresh = this.refresh.bind(this); this.debounced_refresh = frappe.utils.debounce(this.debounced_refresh, 100); @@ -274,6 +274,8 @@ export default class Grid { } make_head() { + if (this.prevent_build) return; + // labels if (this.header_row) { $(this.parent).find(".grid-heading-row .grid-row").remove(); @@ -286,12 +288,42 @@ export default class Grid { grid: this, configure_columns: true }); + + this.header_search = new GridRow({ + parent: $(this.parent).find(".grid-heading-row"), + parent_df: this.df, + docfields: this.docfields, + frm: this.frm, + grid: this, + show_search: true + }); + + Object.keys(this.filter).length !== 0 && + this.update_search_columns(); } - refresh(force) { + update_search_columns() { + for (const field in this.filter) { + if (this.filter[field] && !this.header_search.search_columns[field]) { + delete this.filter[field]; + this.data = this.get_data(Object.keys(this.filter).length !== 0); + break; + } + + if (this.filter[field] && this.filter[field].value) { + let $input = this.header_search.row_index.find('input'); + if (field && field !== 'row-index') { + $input = this.header_search.search_columns[field].find('input'); + } + $input.val(this.filter[field].value); + } + } + } + + refresh() { if (this.frm && this.frm.setting_dependency) return; - this.data = this.get_data(); + this.data = this.get_data(Object.keys(this.filter).length !== 0); !this.wrapper && this.make(); let $rows = $(this.parent).find('.rows'); @@ -453,7 +485,7 @@ export default class Grid { } make_sortable($rows) { - new Sortable($rows.get(0), { + this.grid_sortable = new Sortable($rows.get(0), { group: { name: this.df.fieldname }, handle: '.sortable-handle', draggable: '.grid-row', @@ -484,14 +516,78 @@ export default class Grid { $(this.frm.wrapper).trigger("grid-make-sortable", [this.frm]); } - get_data() { - var data = this.frm ? - this.frm.doc[this.df.fieldname] || [] - : this.df.data || this.get_modal_data(); - // data.sort(function(a, b) { return a.idx - b.idx}); + get_data(filter_field) { + let data = []; + if (filter_field) { + data = this.get_filtered_data(); + } else { + data = this.frm ? + this.frm.doc[this.df.fieldname] || [] + : this.df.data || this.get_modal_data(); + } return data; } + get_filtered_data() { + if (!this.frm) return; + + let all_data = this.frm.doc[this.df.fieldname]; + + for (const field in this.filter) { + all_data = all_data.filter(data => { + let {df, value} = this.filter[field]; + return this.get_data_based_on_fieldtype(df, data, value.toLowerCase()); + }); + } + + return all_data; + } + + get_data_based_on_fieldtype(df, data, value) { + let fieldname = df.fieldname; + let fieldtype = df.fieldtype; + let fieldvalue = data[fieldname]; + + if (fieldtype === "Check") { + value = frappe.utils.string_to_boolean(value); + return (Boolean(fieldvalue) === value) && data; + } else if (fieldtype === "Sr No" && data.idx.toString().includes(value)) { + return data; + } else if (fieldtype === "Duration" && fieldvalue) { + let formatted_duration = frappe.utils.get_formatted_duration(fieldvalue); + + if (formatted_duration.includes(value)) { + return data; + } + } else if (fieldtype === "Barcode" && fieldvalue) { + let barcode = fieldvalue.startsWith(' -1) { + return data; + } + } else if (fieldvalue && fieldvalue.toLowerCase().includes(value)) { + return data; + } + } + get_modal_data() { return this.df.get_data ? this.df.get_data().filter(data => { if (!this.deleted_docs || !in_list(this.deleted_docs, data.name)) { @@ -775,18 +871,19 @@ export default class Grid { } setup_user_defined_columns() { - if (this.frm) { - let user_settings = frappe.get_user_settings(this.frm.doctype, 'GridView'); - if (user_settings && user_settings[this.doctype] && user_settings[this.doctype].length) { - this.user_defined_columns = user_settings[this.doctype].map(row => { - let column = frappe.meta.get_docfield(this.doctype, row.fieldname); - if (column) { - column.in_list_view = 1; - column.columns = row.columns; - return column; - } - }); - } + if (!this.frm) return; + + let user_settings = frappe.get_user_settings(this.frm.doctype, 'GridView'); + if (user_settings && user_settings[this.doctype] && user_settings[this.doctype].length) { + this.user_defined_columns = user_settings[this.doctype].map(row => { + let column = frappe.meta.get_docfield(this.doctype, row.fieldname); + + if (column) { + column.in_list_view = 1; + column.columns = row.columns; + return column; + } + }); } } diff --git a/frappe/public/js/frappe/form/grid_row.js b/frappe/public/js/frappe/form/grid_row.js index 221a120a18..c12ac23319 100644 --- a/frappe/public/js/frappe/form/grid_row.js +++ b/frappe/public/js/frappe/form/grid_row.js @@ -8,7 +8,7 @@ export default class GridRow { this.set_docfields(); this.columns = {}; this.columns_list = []; - this.row_check_html = ''; + this.row_check_html = ''; this.make(); } make() { @@ -204,23 +204,65 @@ export default class GridRow { })); } render_row(refresh) { - var me = this; + if (this.show_search && !this.show_search_row()) return; + + let me = this; this.set_row_index(); // index (1, 2, 3 etc) - if(!this.row_index) { + if (!this.row_index && !this.show_search) { // REDESIGN-TODO: Make translation contextual, this No is Number var txt = (this.doc ? this.doc.idx : __("No.")); - this.row_index = $( - `
    + + this.row_check = $( + `
    ${this.row_check_html} -
    `) +
    `) + .appendTo(this.row); + + this.row_index = $( + ``) .appendTo(this.row) .on('click', function(e) { if(!$(e.target).hasClass('grid-row-check')) { me.toggle_view(); } }); + } else if (this.show_search) { + this.row_check = $( + `` + ).appendTo(this.row); + + this.row_index = $( + `` + ).appendTo(this.row); + + this.row_index.find('input').on('keyup', frappe.utils.debounce((e) => { + let df = { + fieldtype: "Sr No" + }; + + this.grid.filter['row-index'] = { + df: df, + value: e.target.value + }; + + if (e.target.value == "") { + delete this.grid.filter['row-index']; + } + + this.grid.grid_sortable + .option('disabled', Object.keys(this.grid.filter).length !== 0); + + this.grid.prevent_build = true; + me.grid.refresh(); + this.grid.prevent_build = false; + }, 500)); + frappe.utils.only_allow_num_decimal(this.row_index.find('input')); } else { this.row_index.find('span').html(txt); } @@ -546,6 +588,7 @@ export default class GridRow { setup_columns() { this.focus_set = false; + this.search_columns = {}; this.grid.setup_visible_columns(); this.grid.visible_columns.forEach((col, ci) => { @@ -561,8 +604,10 @@ export default class GridRow { txt = __(txt); } let column; - if (!this.columns[df.fieldname]) { + if (!this.columns[df.fieldname] && !this.show_search) { column = this.make_column(df, colsize, txt, ci); + } else if (!this.columns[df.fieldname] && this.show_search) { + column = this.make_search_column(df, colsize); } else { column = this.columns[df.fieldname]; this.refresh_field(df.fieldname, txt); @@ -580,6 +625,77 @@ export default class GridRow { } } }); + + if (this.show_search) { + // last empty column + $(`
    `) + .appendTo(this.row); + } + } + + show_search_row() { + // show or remove search columns based on grid rows + this.show_search = this.frm && this.frm.doc && + this.frm.doc[this.grid.df.fieldname] && + this.frm.doc[this.grid.df.fieldname].length >= 20; + !this.show_search && this.wrapper.remove(); + return this.show_search; + } + + make_search_column(df, colsize) { + let title = ""; + let input_class = ""; + let is_disabled = ""; + + if (["Text", "Small Text"].includes(df.fieldtype)) { + input_class = "grid-overflow-no-ellipsis"; + } else if (["Int", "Currency", "Float", "Percent"].includes(df.fieldtype)) { + input_class = "text-right"; + } else if (df.fieldtype === "Check") { + title = __("1 = True & 0 = False"); + input_class = "text-center"; + } else if (df.fieldtype === 'Password') { + is_disabled = 'disabled'; + title = __('Password cannot be filtered'); + } + + let $col = $('') + .appendTo(this.row); + + let $search_input = $(` + + `).appendTo($col); + + this.search_columns[df.fieldname] = $col; + + $search_input.on('keyup', frappe.utils.debounce((e) => { + this.grid.filter[df.fieldname] = { + df: df, + value: e.target.value + }; + + if (e.target.value == '') { + delete this.grid.filter[df.fieldname]; + } + + this.grid.grid_sortable + .option('disabled', Object.keys(this.grid.filter).length !== 0); + + this.grid.prevent_build = true; + this.grid.refresh(); + this.grid.prevent_build = false; + }, 500)); + + ["Currency", "Float", "Int", "Percent", "Rating"].includes(df.fieldtype) && + frappe.utils.only_allow_num_decimal($search_input); + + return $col; } make_column(df, colsize, txt, ci) { diff --git a/frappe/public/js/frappe/form/sidebar/attachments.js b/frappe/public/js/frappe/form/sidebar/attachments.js index 538534e5cf..0713d5dc43 100644 --- a/frappe/public/js/frappe/form/sidebar/attachments.js +++ b/frappe/public/js/frappe/form/sidebar/attachments.js @@ -44,8 +44,17 @@ frappe.ui.form.Attachments = class Attachments { // add attachment objects var attachments = this.get_attachments(); if(attachments.length) { - attachments.forEach(function(attachment) { - me.add_attachment(attachment) + let exists = {}; + let unique_attachments = attachments.filter(attachment => { + return Object.prototype.hasOwnProperty.call( + exists, + attachment.file_name + ) + ? false + : (exists[attachment.file_name] = true); + }); + unique_attachments.forEach(attachment => { + me.add_attachment(attachment); }); } else { this.attachments_label.removeClass("has-attachments"); @@ -75,7 +84,19 @@ frappe.ui.form.Attachments = class Attachments { remove_action = function(target_id) { frappe.confirm(__("Are you sure you want to delete the attachment?"), function() { - me.remove_attachment(target_id); + let target_attachment = me + .get_attachments() + .find(attachment => attachment.name === target_id); + let to_be_removed = me + .get_attachments() + .filter( + attachment => + attachment.file_name === + target_attachment.file_name + ); + to_be_removed.forEach(attachment => + me.remove_attachment(attachment.name) + ); } ); return false; diff --git a/frappe/public/js/frappe/form/toolbar.js b/frappe/public/js/frappe/form/toolbar.js index 016390a4e1..e55eb9fdeb 100644 --- a/frappe/public/js/frappe/form/toolbar.js +++ b/frappe/public/js/frappe/form/toolbar.js @@ -534,14 +534,14 @@ frappe.ui.form.Toolbar = class Toolbar { }); } show_title_as_dirty() { - if(this.frm.save_disabled) + if (this.frm.save_disabled && !this.frm.set_dirty) return; - if(this.frm.doc.__unsaved) { + if (this.frm.is_dirty()) { this.page.set_indicator(__("Not Saved"), "orange"); } - $(this.frm.wrapper).attr("data-state", this.frm.doc.__unsaved ? "dirty" : "clean"); + $(this.frm.wrapper).attr("data-state", this.frm.is_dirty() ? "dirty" : "clean"); } show_jump_to_field_dialog() { diff --git a/frappe/public/js/frappe/list/list_settings.js b/frappe/public/js/frappe/list/list_settings.js index 782a077a78..186a4370bc 100644 --- a/frappe/public/js/frappe/list/list_settings.js +++ b/frappe/public/js/frappe/list/list_settings.js @@ -375,7 +375,7 @@ export default class ListSettings { let me = this; if (me.removed_fields) { - me.removed_fields.concat(fields); + me.removed_fields = me.removed_fields.concat(fields); } else { me.removed_fields = fields; } diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index beee935040..069f353368 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -915,7 +915,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { return this.settings.get_form_link(doc); } - const docname = doc.name.match(/[%'"#\s]/) + const docname = cstr(doc.name).match(/[%'"#\s]/) ? encodeURIComponent(doc.name) : doc.name; diff --git a/frappe/public/js/frappe/microtemplate.js b/frappe/public/js/frappe/microtemplate.js index 151d008d3e..176862e233 100644 --- a/frappe/public/js/frappe/microtemplate.js +++ b/frappe/public/js/frappe/microtemplate.js @@ -138,6 +138,7 @@ frappe.render_tree = function(opts) { opts.base_url = frappe.urllib.get_base_url(); opts.landscape = false; opts.print_css = frappe.boot.print_css; + opts.print_format_css_path = frappe.assets.bundled_asset('print_format.bundle.css'); var tree = frappe.render_template("print_tree", opts); var w = window.open(); diff --git a/frappe/public/js/frappe/model/model.js b/frappe/public/js/frappe/model/model.js index fe959b259d..3b95a4b3f1 100644 --- a/frappe/public/js/frappe/model/model.js +++ b/frappe/public/js/frappe/model/model.js @@ -412,7 +412,7 @@ $.extend(frappe.model, { } }, - set_value: function(doctype, docname, fieldname, value, fieldtype) { + set_value: function(doctype, docname, fieldname, value, fieldtype, skip_dirty_trigger=false) { /* help: Set a value locally (if changed) and execute triggers */ var doc; @@ -438,11 +438,11 @@ $.extend(frappe.model, { } doc[key] = value; - tasks.push(() => frappe.model.trigger(key, value, doc)); + tasks.push(() => frappe.model.trigger(key, value, doc, skip_dirty_trigger)); } else { // execute link triggers (want to reselect to execute triggers) if(in_list(["Link", "Dynamic Link"], fieldtype) && doc) { - tasks.push(() => frappe.model.trigger(key, value, doc)); + tasks.push(() => frappe.model.trigger(key, value, doc, skip_dirty_trigger)); } } }); @@ -467,7 +467,7 @@ $.extend(frappe.model, { frappe.model.events[doctype][fieldname].push(fn); }, - trigger: function(fieldname, value, doc) { + trigger: function(fieldname, value, doc, skip_dirty_trigger=false) { const tasks = []; function enqueue_events(events) { @@ -477,7 +477,7 @@ $.extend(frappe.model, { if (!fn) continue; tasks.push(() => { - const return_value = fn(fieldname, value, doc); + const return_value = fn(fieldname, value, doc, skip_dirty_trigger); // if the trigger returns a promise, return it, // or use the default promise frappe.after_ajax diff --git a/frappe/public/js/frappe/ui/capture.js b/frappe/public/js/frappe/ui/capture.js index d408fadb33..eb444be4e9 100644 --- a/frappe/public/js/frappe/ui/capture.js +++ b/frappe/public/js/frappe/ui/capture.js @@ -28,6 +28,24 @@ frappe._.get_data_uri = element => { return data_uri; }; +function get_file_input() { + let input = document.createElement("input"); + input.setAttribute("type", "file"); + input.setAttribute("accept", "image/*"); + input.setAttribute("multiple", ""); + + return input; +} + +function read(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + /** * @description Frappe's Capture object. * @@ -45,6 +63,9 @@ frappe.ui.Capture = class { constructor(options = {}) { this.options = frappe.ui.Capture.OPTIONS; this.set_options(options); + + this.facing_mode = "environment"; + this.images = []; } set_options(options) { @@ -53,69 +74,71 @@ frappe.ui.Capture = class { return this; } - render() { - return navigator.mediaDevices.getUserMedia({ video: true }).then(stream => { - this.stream = stream; + show() { + this.build_dialog(); - this.dialog = new frappe.ui.Dialog({ - title: this.options.title, - animate: this.options.animate, - on_hide: () => this.stop_media_stream() - }); + if (frappe.is_mobile()) { + this.show_for_mobile(); + } else { + this.show_for_desktop(); + } + } - this.dialog.get_close_btn().on('click', () => { - this.hide(); - }); + build_dialog() { + let me = this; + me.dialog = new frappe.ui.Dialog({ + title: this.options.title, + animate: this.options.animate, + fields: [ + { + fieldtype: "HTML", + fieldname: "capture" + }, + { + fieldtype: "HTML", + fieldname: "total_count" + } + ], + on_hide: this.stop_media_stream() + }); - const set_take_photo_action = () => { - this.dialog.set_primary_action(__('Take Photo'), () => { - const data_url = frappe._.get_data_uri(video); - $e.find('.fc-p').attr('src', data_url); + me.$template = $(frappe.ui.Capture.TEMPLATE); - $e.find('.fc-s').hide(); - $e.find('.fc-p').show(); + let field = me.dialog.get_field("capture"); + $(field.wrapper).html(me.$template); - this.dialog.set_secondary_action_label(__('Retake')); - this.dialog.get_secondary_btn().show(); - - this.dialog.set_primary_action(__('Submit'), () => { - this.hide(); - if (this.callback) this.callback(data_url); - }); - }); - }; - - set_take_photo_action(); - - this.dialog.set_secondary_action(() => { - $e.find('.fc-p').hide(); - $e.find('.fc-s').show(); - - this.dialog.get_secondary_btn().hide(); - this.dialog.get_primary_btn().off('click'); - set_take_photo_action(); - }); - - this.dialog.get_secondary_btn().hide(); - - const $e = $(frappe.ui.Capture.TEMPLATE); - - const video = $e.find('video')[0]; - video.srcObject = this.stream; - video.play(); - const $container = $(this.dialog.body); - - $container.html($e); + me.dialog.get_close_btn().on('click', () => { + me.hide(); }); } - show() { - this.render() + show_for_mobile() { + let me = this; + if (!me.input) { + me.input = get_file_input(); + } + + me.input.onchange = async () => { + for (let file of me.input.files) { + let f = await read(file); + me.images.push(f); + } + + me.render_preview(); + me.dialog.show(); + }; + me.input.click(); + } + + show_for_desktop() { + let me = this; + + this.render_stream() .then(() => { - this.dialog.show(); + me.dialog.show(); }) .catch(err => { - if (this.options.error) { + if (me.options.error) { frappe.show_alert(frappe.ui.Capture.ERR_MESSAGE, 3); } @@ -123,6 +146,159 @@ frappe.ui.Capture = class { }); } + render_stream() { + let me = this; + let constraints = { + video: { + facingMode: this.facing_mode + } + }; + + return navigator.mediaDevices.getUserMedia(constraints).then(stream => { + me.stream = stream; + me.dialog.custom_actions.empty(); + me.dialog.get_primary_btn().off('click'); + me.setup_take_photo_action(); + me.setup_preview_action(); + me.setup_toggle_camera(); + + me.$template.find('.fc-stream-container').show(); + me.$template.find('.fc-preview-container').hide(); + me.video = me.$template.find('video')[0]; + me.video.srcObject = me.stream; + me.video.load(); + me.video.play(); + }); + } + + render_preview() { + this.stop_media_stream(); + this.$template.find('.fc-stream-container').hide(); + this.$template.find('.fc-preview-container').show(); + this.dialog.get_primary_btn().off('click'); + + let images = ``; + + this.images.forEach((image, idx) => { + images += ` +
    + + ${frappe.utils.icon("close", "lg")} + + +
    + `; + }); + + this.$template.find('.fc-preview-container').empty(); + $(this.$template.find('.fc-preview-container')).html( + `
    + ${images} +
    ` + ); + + this.setup_capture_action(); + this.setup_submit_action(); + this.setup_remove_action(); + this.update_count(); + this.dialog.custom_actions.empty(); + } + + setup_take_photo_action() { + let me = this; + + this.dialog.set_primary_action(__('Take Photo'), () => { + const data_url = frappe._.get_data_uri(me.video); + + me.images.push(data_url); + me.setup_preview_action(); + me.update_count(); + }); + } + + setup_preview_action() { + let me = this; + + if (!this.images.length) { + return; + } + + this.dialog.set_secondary_action_label(__("Preview")); + this.dialog.set_secondary_action(() => { + me.dialog.get_primary_btn().off('click'); + me.render_preview(); + }); + } + + setup_remove_action() { + let me = this; + let elements = this.$template[0].getElementsByClassName("capture-remove-btn"); + + elements.forEach(el => { + el.onclick = () => { + let idx = parseInt(el.getAttribute("data-idx")); + + me.images.splice(idx, 1); + me.render_preview(); + }; + }); + } + + update_count() { + let field = this.dialog.get_field("total_count"); + let msg = `${__("Total Images")}: ${this.images.length}`; + + if (this.images.length === 0) { + msg = __("No Images"); + } + + $(field.wrapper).html(` +
    +
    ${msg}
    +
    + `); + } + + setup_toggle_camera() { + let me = this; + + this.dialog.add_custom_action(__("Switch Camera"), () => { + me.facing_mode = me.facing_mode == "environment" ? "user" : "environment"; + + frappe.show_alert({ + message: __("Switching Camera") + }); + + me.stop_media_stream(); + me.render_stream(); + }, "btn-switch"); + } + + setup_capture_action() { + let me = this; + + this.dialog.set_secondary_action_label(__("Capture")); + this.dialog.set_secondary_action(() => { + if (frappe.is_mobile()) { + me.show_for_mobile(); + } else { + me.render_stream(); + } + }); + } + + setup_submit_action() { + let me = this; + + this.dialog.set_primary_action(__('Submit'), () => { + me.hide(); + + if (me.callback) { + me.callback(me.images); + } + }); + } + hide() { if (this.dialog) this.dialog.hide(); this.stop_media_stream(); @@ -148,11 +324,11 @@ frappe.ui.Capture.OPTIONS = { frappe.ui.Capture.ERR_MESSAGE = __('Unable to load camera.'); frappe.ui.Capture.TEMPLATE = `
    -
    -
    - - -
    +
    + +
    +
    `; diff --git a/frappe/public/js/frappe/ui/page.js b/frappe/public/js/frappe/ui/page.js index 91a2390cdb..e3134b1f38 100644 --- a/frappe/public/js/frappe/ui/page.js +++ b/frappe/public/js/frappe/ui/page.js @@ -47,13 +47,17 @@ frappe.ui.Page = class Page { } setup_scroll_handler() { - window.addEventListener('scroll', () => { - if (document.documentElement.scrollTop) { - $('.page-head').toggleClass('drop-shadow', true); + let last_scroll = 0; + window.addEventListener('scroll', frappe.utils.throttle(() => { + $('.page-head').toggleClass('drop-shadow', !!document.documentElement.scrollTop); + let current_scroll = document.documentElement.scrollTop; + if (current_scroll > 0 && last_scroll <= current_scroll) { + $('.page-head').css("top", "-15px"); } else { - $('.page-head').removeClass('drop-shadow'); + $('.page-head').css("top", "var(--navbar-height)"); } - }); + last_scroll = current_scroll; + }), 500); } get_empty_state(title, message, primary_action) { diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js index 6971d3bc20..0514576380 100644 --- a/frappe/public/js/frappe/utils/utils.js +++ b/frappe/public/js/frappe/utils/utils.js @@ -231,7 +231,7 @@ Object.assign(frappe.utils, { if (tt && (tt.substr(0, 1)===">" || tt.substr(0, 4)===">")) { part.push(t); } else { - out.concat(part); + out = out.concat(part); out.push(t); part = []; } @@ -1102,7 +1102,7 @@ Object.assign(frappe.utils, { seconds: round(seconds % 60) }; - if (duration_options.hide_days) { + if (duration_options && duration_options.hide_days) { total_duration.hours = round(seconds / 3600); total_duration.days = 0; } @@ -1462,5 +1462,23 @@ Object.assign(frappe.utils, { console.log(error); // eslint-disable-line return Promise.resolve(name); } + }, + + only_allow_num_decimal(input) { + input.on('input', (e) => { + let self = $(e.target); + self.val(self.val().replace(/[^0-9.]/g, '')); + if ((e.which != 46 || self.val().indexOf('.') != -1) && (e.which < 48 || e.which > 57)) { + e.preventDefault(); + } + }); + }, + + string_to_boolean(string) { + switch (string.toLowerCase().trim()) { + case "t": case "true": case "y": case "yes": case "1": return true; + case "f": case "false": case "n": case "no": case "0": case null: return false; + default: return string; + } } }); diff --git a/frappe/public/js/frappe/views/calendar/calendar.js b/frappe/public/js/frappe/views/calendar/calendar.js index 0ab5e2e7dc..2739b3dd78 100644 --- a/frappe/public/js/frappe/views/calendar/calendar.js +++ b/frappe/public/js/frappe/views/calendar/calendar.js @@ -29,7 +29,7 @@ frappe.views.CalendarView = class CalendarView extends frappe.views.ListView { .then(() => { this.page_title = __('{0} Calendar', [this.page_title]); this.calendar_settings = frappe.views.calendar[this.doctype] || {}; - this.calendar_name = frappe.utils.to_title_case(frappe.get_route()[3] || ''); + this.calendar_name = frappe.get_route()[3]; }); } @@ -72,12 +72,17 @@ frappe.views.CalendarView = class CalendarView extends frappe.views.ListView { const calendar_name = this.calendar_name; return new Promise(resolve => { - if (calendar_name === 'Default') { + if (calendar_name === 'default') { Object.assign(options, frappe.views.calendar[this.doctype]); resolve(options); } else { frappe.model.with_doc('Calendar View', calendar_name, () => { const doc = frappe.get_doc('Calendar View', calendar_name); + if (!doc) { + frappe.show_alert(__("{0} is not a valid Calendar. Redirecting to default Calendar.", [calendar_name.bold()])); + frappe.set_route("List", this.doctype, "Calendar", "default"); + return; + } Object.assign(options, { field_map: { id: "name", diff --git a/frappe/public/js/frappe/views/reports/print_tree.html b/frappe/public/js/frappe/views/reports/print_tree.html index 817c0c1e9f..973c1c0e21 100644 --- a/frappe/public/js/frappe/views/reports/print_tree.html +++ b/frappe/public/js/frappe/views/reports/print_tree.html @@ -1,91 +1,106 @@ - - - - - - - {{ title }} - - - - - + - - -