diff --git a/cypress/integration/list_paging.js b/cypress/integration/list_paging.js new file mode 100644 index 0000000000..b6832f5a53 --- /dev/null +++ b/cypress/integration/list_paging.js @@ -0,0 +1,35 @@ +context('List Paging', () => { + before(() => { + cy.login(); + cy.visit('/app/website'); + return cy.window().its('frappe').then(frappe => { + return frappe.call("frappe.tests.ui_test_helpers.create_multiple_todo_records"); + }); + }); + + it('test load more with count selection buttons', () => { + cy.visit('/app/todo/view/report'); + + cy.get('.list-paging-area .list-count').should('contain.text', '20 of'); + cy.get('.list-paging-area .btn-more').click(); + cy.get('.list-paging-area .list-count').should('contain.text', '40 of'); + cy.get('.list-paging-area .btn-more').click(); + cy.get('.list-paging-area .list-count').should('contain.text', '60 of'); + + cy.get('.list-paging-area .btn-group .btn-paging[data-value="100"]').click(); + + cy.get('.list-paging-area .list-count').should('contain.text', '100 of'); + cy.get('.list-paging-area .btn-more').click(); + cy.get('.list-paging-area .list-count').should('contain.text', '200 of'); + cy.get('.list-paging-area .btn-more').click(); + cy.get('.list-paging-area .list-count').should('contain.text', '300 of'); + + // check if refresh works after load more + cy.get('.page-head .standard-actions [data-original-title="Refresh"]').click(); + cy.get('.list-paging-area .list-count').should('contain.text', '300 of'); + + cy.get('.list-paging-area .btn-group .btn-paging[data-value="500"]').click(); + + cy.get('.list-paging-area .list-count').should('contain.text', '500 of'); + }); +}); diff --git a/cypress/integration/number_card.js b/cypress/integration/number_card.js new file mode 100644 index 0000000000..a01ff1152d --- /dev/null +++ b/cypress/integration/number_card.js @@ -0,0 +1,22 @@ +context('Number Card', () => { + before(() => { + cy.login(); + cy.visit('/app/website'); + }); + + it('Check filter populate for child table doctype', () => { + cy.visit('/app/number-card/new-number-card-1'); + cy.get('[data-fieldname="parent_document_type"]').should('have.css', 'display', 'none'); + + cy.get_field('document_type', 'Link'); + cy.fill_field('document_type', 'Workspace Link', 'Link').focus().blur(); + cy.get_field('document_type', 'Link').should('have.value', 'Workspace Link'); + + cy.fill_field('label', 'Test Number Card', 'Data'); + + cy.get('[data-fieldname="filters_json"]').click().wait(200); + cy.get('.modal-body .filter-action-buttons .add-filter').click(); + cy.get('.modal-body .fieldname-select-area').click(); + cy.get('.modal-actions .btn-modal-close').click(); + }); +}); \ No newline at end of file diff --git a/cypress/integration/report_view.js b/cypress/integration/report_view.js index 6e3a28bbfc..bacbf9c172 100644 --- a/cypress/integration/report_view.js +++ b/cypress/integration/report_view.js @@ -13,9 +13,6 @@ context('Report View', () => { 'enabled': 0, 'docstatus': 1 // submit document }, true); - return cy.window().its('frappe').then(frappe => { - return frappe.call("frappe.tests.ui_test_helpers.create_multiple_contact_records"); - }); }); it('Field with enabled allow_on_submit should be editable.', () => { @@ -43,32 +40,4 @@ context('Report View', () => { expect(r.message.enabled).to.equals(1); }); }); - - it('test load more with count selection buttons', () => { - cy.visit('/app/contact/view/report'); - - cy.get('.list-paging-area .list-count').should('contain.text', '20 of'); - cy.get('.list-paging-area .btn-more').click(); - cy.get('.list-paging-area .list-count').should('contain.text', '40 of'); - cy.get('.list-paging-area .btn-more').click(); - cy.get('.list-paging-area .list-count').should('contain.text', '60 of'); - - cy.get('.list-paging-area .btn-group .btn-paging[data-value="100"]').click(); - - cy.get('.list-paging-area .list-count').should('contain.text', '100 of'); - cy.get('.list-paging-area .btn-more').click(); - cy.get('.list-paging-area .list-count').should('contain.text', '200 of'); - cy.get('.list-paging-area .btn-more').click(); - cy.get('.list-paging-area .list-count').should('contain.text', '300 of'); - - // check if refresh works after load more - cy.get('.page-head .standard-actions [data-original-title="Refresh"]').click(); - cy.get('.list-paging-area .list-count').should('contain.text', '300 of'); - - cy.get('.list-paging-area .btn-group .btn-paging[data-value="500"]').click(); - - cy.get('.list-paging-area .list-count').should('contain.text', '500 of'); - cy.get('.list-paging-area .btn-more').click(); - cy.get('.list-paging-area .list-count').should('contain.text', '1000 of'); - }); }); diff --git a/esbuild/esbuild.js b/esbuild/esbuild.js index 792cb56198..43c01e88fb 100644 --- a/esbuild/esbuild.js +++ b/esbuild/esbuild.js @@ -9,7 +9,7 @@ const cliui = require("cliui")(); const chalk = require("chalk"); const html_plugin = require("./frappe-html"); const rtlcss = require('rtlcss'); -const postCssPlugin = require("esbuild-plugin-postcss2").default; +const postCssPlugin = require("@frappe/esbuild-plugin-postcss2").default; const ignore_assets = require("./ignore-assets"); const sass_options = require("./sass_options"); const build_cleanup_plugin = require("./build-cleanup"); diff --git a/esbuild/frappe-html.js b/esbuild/frappe-html.js index 8c4b7ca3d7..9a7edb144d 100644 --- a/esbuild/frappe-html.js +++ b/esbuild/frappe-html.js @@ -20,7 +20,8 @@ module.exports = { .then(content => { content = scrub_html_template(content); return { - contents: `\n\tfrappe.templates['${filename}'] = \`${content}\`;\n` + contents: `\n\tfrappe.templates['${filename}'] = \`${content}\`;\n`, + watchFiles: [filepath] }; }) .catch(() => { diff --git a/frappe/__init__.py b/frappe/__init__.py index 8a8b70afe3..86f8be35ea 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -35,6 +35,7 @@ from frappe.query_builder import ( patch_query_execute, patch_query_aggregation, ) +from frappe.utils.data import cstr __version__ = '14.0.0-dev' @@ -214,6 +215,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 @@ -850,8 +852,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: @@ -869,8 +870,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) @@ -911,8 +928,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()) @@ -1001,7 +1017,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`.""" diff --git a/frappe/boot.py b/frappe/boot.py index 524913059c..b5008f778a 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -325,6 +325,7 @@ def get_desk_settings(): def get_notification_settings(): return frappe.get_cached_doc('Notification Settings', frappe.session.user) +@frappe.whitelist() def get_link_title_doctypes(): dts = frappe.get_all("DocType", {"show_title_field_in_link": 1}) custom_dts = frappe.get_all( diff --git a/frappe/commands/site.py b/frappe/commands/site.py old mode 100755 new mode 100644 index c5d2257d75..b54f369e34 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -1,7 +1,7 @@ # imports - standard imports import os -import sys import shutil +import sys # imports - third party imports import click @@ -65,11 +65,11 @@ def restore(context, sql_file_path, encryption_key=None, db_root_username=None, "Restore site database from an sql file" from frappe.installer import ( _new_site, - extract_sql_from_archive, extract_files, + extract_sql_from_archive, is_downgrade, is_partial, - validate_database_sql + validate_database_sql, ) from frappe.utils.backups import Backup if not os.path.exists(sql_file_path): @@ -207,7 +207,7 @@ def restore(context, sql_file_path, encryption_key=None, db_root_username=None, @click.option('--encryption-key', help='Backup encryption key') @pass_context def partial_restore(context, sql_file_path, verbose, encryption_key=None): - from frappe.installer import partial_restore, extract_sql_from_archive + from frappe.installer import extract_sql_from_archive, partial_restore from frappe.utils.backups import Backup if not os.path.exists(sql_file_path): @@ -545,7 +545,7 @@ def _use(site, sites_path='.'): def use(site, sites_path='.'): if os.path.exists(os.path.join(sites_path, site)): - with open(os.path.join(sites_path, "currentsite.txt"), "w") as sitefile: + with open(os.path.join(sites_path, "currentsite.txt"), "w") as sitefile: sitefile.write(site) print("Current Site set to {}".format(site)) else: @@ -751,6 +751,7 @@ def set_admin_password(context, admin_password=None, logout_all_sessions=False): def set_user_password(site, user, password, logout_all_sessions=False): import getpass + from frappe.utils.password import update_password try: @@ -881,15 +882,16 @@ def stop_recording(context): raise SiteNotSpecifiedError @click.command('ngrok') +@click.option('--bind-tls', is_flag=True, default=False, help='Returns a reference to the https tunnel.') @pass_context -def start_ngrok(context): +def start_ngrok(context, bind_tls): from pyngrok import ngrok site = get_site(context) frappe.init(site=site) port = frappe.conf.http_port or frappe.conf.webserver_port - tunnel = ngrok.connect(addr=str(port), host_header=site) + tunnel = ngrok.connect(addr=str(port), host_header=site, bind_tls=bind_tls) print(f'Public URL: {tunnel.public_url}') print('Inspect logs at http://localhost:4040') diff --git a/frappe/core/doctype/communication/communication.py b/frappe/core/doctype/communication/communication.py index f89f0d8765..475762f39d 100644 --- a/frappe/core/doctype/communication/communication.py +++ b/frappe/core/doctype/communication/communication.py @@ -18,6 +18,7 @@ from urllib.parse import unquote from frappe.utils.user import is_system_user from frappe.contacts.doctype.contact.contact import get_contact_name from frappe.automation.doctype.assignment_rule.assignment_rule import apply as apply_assignment_rule +from parse import compile exclude_from_linked_with = True @@ -114,6 +115,44 @@ class Communication(Document, CommunicationEmailMixin): frappe.publish_realtime('new_message', self.as_dict(), user=self.reference_name, after_commit=True) + def set_signature_in_email_content(self): + """Set sender's User.email_signature or default outgoing's EmailAccount.signature to the email + """ + if not self.content: + return + + quill_parser = compile('
{}
') + email_body = quill_parser.parse(self.content) + + if not email_body: + return + + email_body = email_body[0] + + user_email_signature = frappe.db.get_value( + "User", + self.sender, + "email_signature", + ) if self.sender else None + + signature = user_email_signature or frappe.db.get_value( + "Email Account", + {"default_outgoing": 1, "add_signature": 1}, + "signature", + ) + + if not signature: + return + + _signature = quill_parser.parse(signature)[0] if "ql-editor" in signature else None + + if (_signature or signature) not in self.content: + self.content = f'{self.content}


{signature}' + + def before_save(self): + if not self.flags.skip_add_signature: + self.set_signature_in_email_content() + def on_update(self): # add to _comment property of the doctype, so it shows up in # comments count for the list view diff --git a/frappe/core/doctype/communication/email.py b/frappe/core/doctype/communication/email.py index 46ef7bf5d2..b51749ccb7 100755 --- a/frappe/core/doctype/communication/email.py +++ b/frappe/core/doctype/communication/email.py @@ -22,12 +22,30 @@ OUTGOING_EMAIL_ACCOUNT_MISSING = _(""" @frappe.whitelist() -def make(doctype=None, name=None, content=None, subject=None, sent_or_received = "Sent", - sender=None, sender_full_name=None, recipients=None, communication_medium="Email", send_email=False, - print_html=None, print_format=None, attachments='[]', send_me_a_copy=False, cc=None, bcc=None, - flags=None, read_receipt=None, print_letterhead=True, email_template=None, communication_type=None, - ignore_permissions=False) -> Dict[str, str]: - """Make a new communication. +def make( + doctype=None, + name=None, + content=None, + subject=None, + sent_or_received="Sent", + sender=None, + sender_full_name=None, + recipients=None, + communication_medium="Email", + send_email=False, + print_html=None, + print_format=None, + attachments="[]", + send_me_a_copy=False, + cc=None, + bcc=None, + read_receipt=None, + print_letterhead=True, + email_template=None, + communication_type=None, + **kwargs, +) -> Dict[str, str]: + """Make a new communication. Checks for email permissions for specified Document. :param doctype: Reference DocType. :param name: Reference Document name. @@ -44,17 +62,71 @@ def make(doctype=None, name=None, content=None, subject=None, sent_or_received = :param send_me_a_copy: Send a copy to the sender (default **False**). :param email_template: Template which is used to compose mail . """ - is_error_report = (doctype=="User" and name==frappe.session.user and subject=="Error Report") - send_me_a_copy = cint(send_me_a_copy) + if kwargs: + from frappe.utils.commands import warn + warn( + f"Options {kwargs} used in frappe.core.doctype.communication.email.make " + "are deprecated or unsupported", + category=DeprecationWarning + ) + + if doctype and name and not frappe.has_permission(doctype=doctype, ptype="email", doc=name): + raise frappe.PermissionError( + f"You are not allowed to send emails related to: {doctype} {name}" + ) + + return _make( + doctype=doctype, + name=name, + content=content, + subject=subject, + sent_or_received=sent_or_received, + sender=sender, + sender_full_name=sender_full_name, + recipients=recipients, + communication_medium=communication_medium, + send_email=send_email, + print_html=print_html, + print_format=print_format, + attachments=attachments, + send_me_a_copy=cint(send_me_a_copy), + cc=cc, + bcc=bcc, + read_receipt=read_receipt, + print_letterhead=print_letterhead, + email_template=email_template, + communication_type=communication_type, + add_signature=False, + ) - if not ignore_permissions: - if doctype and name and not is_error_report and not frappe.has_permission(doctype, "email", name) and not (flags or {}).get('ignore_doctype_permissions'): - raise frappe.PermissionError("You are not allowed to send emails related to: {doctype} {name}".format( - doctype=doctype, name=name)) - if not sender: - sender = get_formatted_email(frappe.session.user) +def _make( + doctype=None, + name=None, + content=None, + subject=None, + sent_or_received="Sent", + sender=None, + sender_full_name=None, + recipients=None, + communication_medium="Email", + send_email=False, + print_html=None, + print_format=None, + attachments="[]", + send_me_a_copy=False, + cc=None, + bcc=None, + read_receipt=None, + print_letterhead=True, + email_template=None, + communication_type=None, + add_signature=True, +) -> Dict[str, str]: + """Internal method to make a new communication that ignores Permission checks. + """ + sender = sender or get_formatted_email(frappe.session.user) recipients = list_to_str(recipients) if isinstance(recipients, list) else recipients cc = list_to_str(cc) if isinstance(cc, list) else cc bcc = list_to_str(bcc) if isinstance(bcc, list) else bcc @@ -77,7 +149,9 @@ def make(doctype=None, name=None, content=None, subject=None, sent_or_received = "read_receipt":read_receipt, "has_attachment": 1 if attachments else 0, "communication_type": communication_type, - }).insert(ignore_permissions=True) + }) + comm.flags.skip_add_signature = not add_signature + comm.insert(ignore_permissions=True) # if not committed, delayed task doesn't find the communication if attachments: @@ -87,17 +161,21 @@ def make(doctype=None, name=None, content=None, subject=None, sent_or_received = if cint(send_email): if not comm.get_outgoing_email_account(): - frappe.throw(msg=OUTGOING_EMAIL_ACCOUNT_MISSING, exc=frappe.OutgoingEmailError) + frappe.throw( + msg=OUTGOING_EMAIL_ACCOUNT_MISSING, exc=frappe.OutgoingEmailError + ) - comm.send_email(print_html=print_html, print_format=print_format, - send_me_a_copy=send_me_a_copy, print_letterhead=print_letterhead) + comm.send_email( + print_html=print_html, + print_format=print_format, + send_me_a_copy=send_me_a_copy, + print_letterhead=print_letterhead, + ) emails_not_sent_to = comm.exclude_emails_list(include_sender=send_me_a_copy) - return { - "name": comm.name, - "emails_not_sent_to": ", ".join(emails_not_sent_to) - } + return {"name": comm.name, "emails_not_sent_to": ", ".join(emails_not_sent_to)} + def validate_email(doc: "Communication") -> None: """Validate Email Addresses of Recipients and CC""" 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 dca0a05281..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 @@ -732,9 +745,12 @@ class DocType(Document): frappe.throw(_("DocType's name should not start or end with whitespace"), frappe.NameError) # a DocType's name should not start with a number or underscore - # and should only contain letters, numbers and underscore - if not re.match(r"^(?![\W])[^\d_\s][\w ]+$", name, **flags): - frappe.throw(_("DocType's name should start with a letter and it can only consist of letters, numbers, spaces and underscores"), frappe.NameError) + # and should only contain letters, numbers, underscore, and hyphen + if not re.match(r"^(?![\W])[^\d_\s][\w -]+$", name, **flags): + frappe.throw(_( + "A DocType's name should start with a letter and can only " + "consist of letters, numbers, spaces, underscores and hyphens" + ), frappe.NameError, title="Invalid Name") validate_route_conflict(self.doctype, self.name) diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py index 9b4f733e7d..dc6d14b451 100644 --- a/frappe/core/doctype/doctype/test_doctype.py +++ b/frappe/core/doctype/doctype/test_doctype.py @@ -24,7 +24,7 @@ class TestDocType(unittest.TestCase): self.assertRaises(frappe.NameError, new_doctype("8Some DocType").insert) self.assertRaises(frappe.NameError, new_doctype("Some (DocType)").insert) self.assertRaises(frappe.NameError, new_doctype("Some Doctype with a name whose length is more than 61 characters").insert) - for name in ("Some DocType", "Some_DocType"): + for name in ("Some DocType", "Some_DocType", "Some-DocType"): if frappe.db.exists("DocType", name): frappe.delete_doc("DocType", name) @@ -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/role/role.py b/frappe/core/doctype/role/role.py index 389e18dd4c..f955c29462 100644 --- a/frappe/core/doctype/role/role.py +++ b/frappe/core/doctype/role/role.py @@ -61,7 +61,7 @@ class Role(Document): def get_info_based_on_role(role, field='email'): ''' Get information of all users that have been assigned this role ''' - users = frappe.get_list("Has Role", filters={"role": role, "parenttype": "User"}, + users = frappe.get_list("Has Role", filters={"role": role}, parent_doctype="User", fields=["parent as user_name"]) return get_user_info(users, field) 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.json b/frappe/core/doctype/user/user.json index a47f539466..9e9529cd5e 100644 --- a/frappe/core/doctype/user/user.json +++ b/frappe/core/doctype/user/user.json @@ -668,8 +668,7 @@ "link_fieldname": "user" } ], - "max_attachments": 5, - "modified": "2022-01-03 11:53:25.250822", + "modified": "2022-03-09 01:47:56.745069", "modified_by": "Administrator", "module": "Core", "name": "User", diff --git a/frappe/database/database.py b/frappe/database/database.py index dc9f20d8c2..1251a323d3 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -142,8 +142,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)): @@ -181,7 +179,7 @@ class Database(object): print(e) raise - if ignore_ddl and (self.is_missing_column(e) or self.is_missing_table(e) or self.cant_drop_field_or_key(e)): + if ignore_ddl and (self.is_missing_column(e) or self.is_table_missing(e) or self.cant_drop_field_or_key(e)): pass else: raise @@ -1028,7 +1026,7 @@ class Database(object): return [] def is_missing_table_or_column(self, e): - return self.is_missing_column(e) or self.is_missing_table(e) + return self.is_missing_column(e) or self.is_table_missing(e) def multisql(self, sql_dict, values=(), **kwargs): current_dialect = frappe.db.db_type or 'mariadb' diff --git a/frappe/database/mariadb/database.py b/frappe/database/mariadb/database.py index b5971e236e..a6d5e7b3f2 100644 --- a/frappe/database/mariadb/database.py +++ b/frappe/database/mariadb/database.py @@ -154,6 +154,10 @@ class MariaDBDatabase(Database): def is_table_missing(e): return e.args[0] == ER.NO_SUCH_TABLE + @staticmethod + def is_missing_table(e): + return MariaDBDatabase.is_table_missing(e) + @staticmethod def is_missing_column(e): return e.args[0] == ER.BAD_FIELD_ERROR 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 b0793fcbf0..eb3e33d39c 100644 --- a/frappe/database/postgres/database.py +++ b/frappe/database/postgres/database.py @@ -99,16 +99,13 @@ class PostgresDatabase(Database): return db_size[0].get('database_size') # pylint: disable=W0221 - def sql(self, *args, **kwargs): - if args: - # since tuple is immutable - args = list(args) - args[0] = modify_query(args[0]) - args = tuple(args) - elif kwargs.get('query'): - kwargs['query'] = modify_query(kwargs.get('query')) - - return super(PostgresDatabase, self).sql(*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 @@ -153,6 +150,10 @@ class PostgresDatabase(Database): def is_table_missing(e): return getattr(e, 'pgcode', None) == '42P01' + @staticmethod + def is_missing_table(e): + return PostgresDatabase.is_table_missing(e) + @staticmethod def is_missing_column(e): return getattr(e, 'pgcode', None) == '42703' @@ -335,12 +336,47 @@ def modify_query(query): query = replace_locate_with_strpos(query) # select from requires "" if re.search('from tab', query, flags=re.IGNORECASE): - query = re.sub('from tab([a-zA-Z]*)', r'from "tab\1"', 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/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/number_card/number_card.js b/frappe/desk/doctype/number_card/number_card.js index 6d1454a2cb..f548388a99 100644 --- a/frappe/desk/doctype/number_card/number_card.js +++ b/frappe/desk/doctype/number_card/number_card.js @@ -28,6 +28,7 @@ frappe.ui.form.on('Number Card', { frm.trigger('render_filters_table'); } frm.trigger('create_add_to_dashboard_button'); + frm.trigger('set_parent_document_type'); }, create_add_to_dashboard_button: function(frm) { @@ -141,7 +142,9 @@ frappe.ui.form.on('Number Card', { frm.set_value('filters_json', '[]'); frm.set_value('dynamic_filters_json', '[]'); frm.set_value('aggregate_function_based_on', ''); + frm.set_value('parent_document_type', ''); frm.trigger('set_options'); + frm.trigger('set_parent_document_type'); }, set_options: function(frm) { @@ -317,6 +320,7 @@ frappe.ui.form.on('Number Card', { frm.filter_group = new frappe.ui.FilterGroup({ parent: dialog.get_field('filter_area').$wrapper, doctype: frm.doc.document_type, + parent_doctype: frm.doc.parent_document_type, on_change: () => {}, }); filters && frm.filter_group.add_filters_to_filter_group(filters); @@ -436,6 +440,36 @@ frappe.ui.form.on('Number Card', { frm.dynamic_filter_table.find('tbody').html(filter_rows); } + }, + + set_parent_document_type: async function(frm) { + let document_type = frm.doc.document_type; + let doc_is_table = document_type && + (await frappe.db.get_value('DocType', document_type, 'istable')).message.istable; + + frm.set_df_property('parent_document_type', 'hidden', !doc_is_table); + + if (document_type && doc_is_table) { + let parent = await frappe.db.get_list('DocField', { + filters: { + 'fieldtype': 'Table', + 'options': document_type + }, + fields: ['parent'] + }); + + parent && frm.set_query('parent_document_type', function() { + return { + filters: { + "name": ['in', parent.map(({ parent }) => parent)] + } + }; + }); + + if (parent.length === 1) { + frm.set_value('parent_document_type', parent[0].parent); + } + } } }); diff --git a/frappe/desk/doctype/number_card/number_card.json b/frappe/desk/doctype/number_card/number_card.json index d3e9598eb7..7975d878ba 100644 --- a/frappe/desk/doctype/number_card/number_card.json +++ b/frappe/desk/doctype/number_card/number_card.json @@ -16,6 +16,7 @@ "aggregate_function_based_on", "column_break_2", "document_type", + "parent_document_type", "report_field", "report_function", "is_public", @@ -188,10 +189,17 @@ "label": "Function", "mandatory_depends_on": "eval: doc.type == 'Report'", "options": "Sum\nAverage\nMinimum\nMaximum" + }, + { + "description": "The document type selected is a child table, so the parent document type is required.", + "fieldname": "parent_document_type", + "fieldtype": "Link", + "label": "Parent Document Type", + "options": "DocType" } ], "links": [], - "modified": "2020-07-23 11:11:03.391719", + "modified": "2022-03-10 15:34:38.210910", "modified_by": "Administrator", "module": "Desk", "name": "Number Card", @@ -234,6 +242,7 @@ "search_fields": "label, document_type", "sort_field": "modified", "sort_order": "DESC", + "states": [], "title_field": "label", "track_changes": 1 } \ No newline at end of file diff --git a/frappe/desk/doctype/number_card/number_card.py b/frappe/desk/doctype/number_card/number_card.py index 5662523a9d..784f46bb19 100644 --- a/frappe/desk/doctype/number_card/number_card.py +++ b/frappe/desk/doctype/number_card/number_card.py @@ -3,6 +3,7 @@ # License: MIT. See LICENSE import frappe +from frappe import _ from frappe.model.document import Document from frappe.utils import cint from frappe.model.naming import append_number_if_name_exists @@ -17,6 +18,13 @@ class NumberCard(Document): if frappe.db.exists("Number Card", self.name): self.name = append_number_if_name_exists('Number Card', self.name) + def validate(self): + if not self.document_type: + frappe.throw(_("Document type is required to create a number card")) + + if self.document_type and frappe.get_meta(self.document_type).istable and not self.parent_document_type: + frappe.throw(_("Parent document type is required to create a number card")) + def on_update(self): if frappe.conf.developer_mode and self.is_standard: export_to_files(record_list=[['Number Card', self.name]], record_module=self.module) diff --git a/frappe/desk/doctype/system_console/system_console.js b/frappe/desk/doctype/system_console/system_console.js index fc83069fd2..7751ffe860 100644 --- a/frappe/desk/doctype/system_console/system_console.js +++ b/frappe/desk/doctype/system_console/system_console.js @@ -88,15 +88,16 @@ frappe.ui.form.on('System Console', { ${row.Progress} ` } + frm.get_field('processlist').html(`

Requested on: ${timestamp}

- ${rows}`); }); diff --git a/frappe/desk/doctype/system_console/system_console.py b/frappe/desk/doctype/system_console/system_console.py index 107ab2f932..bf0925e2d7 100644 --- a/frappe/desk/doctype/system_console/system_console.py +++ b/frappe/desk/doctype/system_console/system_console.py @@ -41,4 +41,14 @@ def execute_code(doc): @frappe.whitelist() def show_processlist(): frappe.only_for('System Manager') - return frappe.db.sql('show full processlist', as_dict=1) + + return frappe.db.multisql({ + "postgres": """ + SELECT pid AS "Id", + query_start AS "Time", + state AS "State", + query AS "Info", + wait_event AS "Progress" + FROM pg_stat_activity""", + "mariadb": "show full processlist" + }, as_dict=True) diff --git a/frappe/desk/doctype/workspace/workspace.py b/frappe/desk/doctype/workspace/workspace.py index f0a3531ae4..ba3319b591 100644 --- a/frappe/desk/doctype/workspace/workspace.py +++ b/frappe/desk/doctype/workspace/workspace.py @@ -277,6 +277,7 @@ def sort_page(workspace_pages, pages): doc = frappe.get_doc('Workspace', page.name) doc.sequence_id = seq + 1 doc.parent_page = d.get('parent_page') or "" + doc.flags.ignore_links = True doc.save(ignore_permissions=True) break diff --git a/frappe/desk/form/linked_with.py b/frappe/desk/form/linked_with.py index 572d3f2a94..010d65c95b 100644 --- a/frappe/desk/form/linked_with.py +++ b/frappe/desk/form/linked_with.py @@ -1,9 +1,10 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE + import json from collections import defaultdict import itertools -from typing import List +from typing import Dict, List, Optional import frappe import frappe.desk.form.load @@ -367,7 +368,7 @@ def get_exempted_doctypes(): @frappe.whitelist() -def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None): +def get_linked_docs(doctype: str, name: str, linkinfo: Optional[Dict] = None) -> Dict[str, List]: if isinstance(linkinfo, str): # additional fields are added in linkinfo linkinfo = json.loads(linkinfo) @@ -377,23 +378,21 @@ def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None): if not linkinfo: return results - if for_doctype: - links = frappe.get_doc(doctype, name).get_link_filters(for_doctype) - - if links: - linkinfo = links - - if for_doctype in linkinfo: - # only get linked with for this particular doctype - linkinfo = { for_doctype: linkinfo.get(for_doctype) } - else: - return results - for dt, link in linkinfo.items(): filters = [] link["doctype"] = dt - link_meta_bundle = frappe.desk.form.load.get_meta_bundle(dt) + try: + link_meta_bundle = frappe.desk.form.load.get_meta_bundle(dt) + except Exception as e: + if isinstance(e, frappe.DoesNotExistError): + if frappe.local.message_log: + frappe.local.message_log.pop() + continue linkmeta = link_meta_bundle[0] + + if not linkmeta.has_permission(): + continue + if not linkmeta.get("issingle"): fields = [d.fieldname for d in linkmeta.get("fields", { "in_list_view": 1, @@ -456,6 +455,13 @@ def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None): return results + +@frappe.whitelist() +def get(doctype, docname): + linked_doctypes = get_linked_doctypes(doctype=doctype) + return get_linked_docs(doctype=doctype, name=docname, linkinfo=linked_doctypes) + + @frappe.whitelist() def get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False): """add list of doctypes this doctype is 'linked' with. @@ -470,6 +476,7 @@ def get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False): else: return frappe.cache().hget("linked_doctypes", doctype, lambda: _get_linked_doctypes(doctype)) + def _get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False): ret = {} # find fields where this doctype is linked @@ -499,6 +506,7 @@ def _get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False) return ret + def get_linked_fields(doctype, without_ignore_user_permissions_enabled=False): filters = [['fieldtype','=', 'Link'], ['options', '=', doctype]] @@ -529,6 +537,7 @@ def get_linked_fields(doctype, without_ignore_user_permissions_enabled=False): return ret + def get_dynamic_linked_fields(doctype, without_ignore_user_permissions_enabled=False): ret = {} diff --git a/frappe/desk/form/load.py b/frappe/desk/form/load.py index b5dfacb1d6..0140157c9d 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 @@ -124,7 +125,6 @@ def get_docinfo(doc=None, doctype=None, name=None): update_user_info(docinfo) frappe.response["docinfo"] = docinfo - return docinfo def add_comments(doc, docinfo): # divide comments into separate lists @@ -356,7 +356,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") - - 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 +def format_duration_fields(data: frappe._dict) -> None: + for i, col in enumerate(data.columns): + if col.get("fieldtype") != "Duration": + continue - 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..b54ea46268 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -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..5ffde0c37b 100644 --- a/frappe/email/doctype/auto_email_report/auto_email_report.py +++ b/frappe/email/doctype/auto_email_report/auto_email_report.py @@ -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/newsletter/newsletter.json b/frappe/email/doctype/newsletter/newsletter.json index baabd4991e..b42f4755cb 100644 --- a/frappe/email/doctype/newsletter/newsletter.json +++ b/frappe/email/doctype/newsletter/newsletter.json @@ -236,8 +236,7 @@ "index_web_pages_for_search": 1, "is_published_field": "published", "links": [], - "max_attachments": 3, - "modified": "2021-12-06 20:09:37.963141", + "modified": "2022-03-09 01:48:16.741603", "modified_by": "Administrator", "module": "Email", "name": "Newsletter", 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/notification.py b/frappe/email/doctype/notification/notification.py index 2b62530847..bad32fb68f 100644 --- a/frappe/email/doctype/notification/notification.py +++ b/frappe/email/doctype/notification/notification.py @@ -186,7 +186,7 @@ def get_context(context): def send_an_email(self, doc, context): from email.utils import formataddr - from frappe.core.doctype.communication.email import make as make_communication + from frappe.core.doctype.communication.email import _make as make_communication subject = self.subject if "{" in subject: subject = frappe.render_template(self.subject, context) @@ -216,7 +216,8 @@ def get_context(context): # Add mail notification to communication list # No need to add if it is already a communication. if doc.doctype != 'Communication': - make_communication(doctype=doc.doctype, + make_communication( + doctype=doc.doctype, name=doc.name, content=message, subject=subject, @@ -228,7 +229,7 @@ def get_context(context): cc=cc, bcc=bcc, communication_type='Automated Message', - ignore_permissions=True) + ) def send_a_slack_msg(self, doc, context): send_slack_message( diff --git a/frappe/email/email_body.py b/frappe/email/email_body.py index c25e996bd3..0f45e42aac 100755 --- a/frappe/email/email_body.py +++ b/frappe/email/email_body.py @@ -259,17 +259,12 @@ def get_formatted_html(subject, message, footer=None, print_html=None, email_account = email_account or EmailAccount.find_outgoing(match_by_email=sender) - signature = None - if "" not in message: - signature = get_signature(email_account) - rendered_email = frappe.get_template("templates/emails/standard.html").render({ "brand_logo": get_brand_logo(email_account) if with_container or header else None, "with_container": with_container, "site_url": get_url(), "header": get_header(header), "content": message, - "signature": signature, "footer": get_footer(email_account, footer), "title": subject, "print_html": print_html, @@ -281,8 +276,7 @@ def get_formatted_html(subject, message, footer=None, print_html=None, if unsubscribe_link: html = html.replace("", unsubscribe_link.html) - html = inline_style_in_html(html) - return html + return inline_style_in_html(html) @frappe.whitelist() def get_email_html(template, args, subject, header=None, with_container=False): 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/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/installer.py b/frappe/installer.py index 6ebab95a7d..d10dc78286 100644 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -611,7 +611,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 8a81aa5610..3564b1ae11 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) 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/model/rename_doc.py b/frappe/model/rename_doc.py index faa3859c91..b4a53e3131 100644 --- a/frappe/model/rename_doc.py +++ b/frappe/model/rename_doc.py @@ -43,8 +43,8 @@ def update_document_title( title_field = doc.meta.get_title_field() - title_updated = (title_field != "name") and (updated_title != doc.get(title_field)) - name_updated = updated_name != doc.name + title_updated = updated_title and (title_field != "name") and (updated_title != doc.get(title_field)) + name_updated = updated_name and (updated_name != doc.name) if name_updated: docname = rename_doc(doctype=doctype, old=docname, new=updated_name, merge=merge) diff --git a/frappe/modules/import_file.py b/frappe/modules/import_file.py index 92e7523e6d..f9c7b55a99 100644 --- a/frappe/modules/import_file.py +++ b/frappe/modules/import_file.py @@ -11,7 +11,7 @@ from frappe.query_builder import DocType from frappe.utils import get_datetime, now -def caclulate_hash(path: str) -> str: +def calculate_hash(path: str) -> str: """Calculate md5 hash of the file in binary mode Args: @@ -99,7 +99,7 @@ def import_file_by_path(path: str,force: bool = False,data_import: bool = False, print(f"{path} missing") return - calculated_hash = caclulate_hash(path) + calculated_hash = calculate_hash(path) if docs: if not isinstance(docs, list): 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/form/controls/autocomplete.js b/frappe/public/js/frappe/form/controls/autocomplete.js index 4e66ed6642..a509af4121 100644 --- a/frappe/public/js/frappe/form/controls/autocomplete.js +++ b/frappe/public/js/frappe/form/controls/autocomplete.js @@ -166,6 +166,9 @@ frappe.ui.form.ControlAutocomplete = class ControlAutoComplete extends frappe.ui } parse_options(options) { + if (typeof options === 'string' && options[0] === '[') { + options = frappe.utils.parse_json(options); + } if (typeof options === 'string') { options = options.split('\n'); } diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index 56e909dd0c..269c54631f 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -248,7 +248,7 @@ frappe.ui.form.Form = class FrappeForm { // on main doc frappe.model.on(me.doctype, "*", function(fieldname, value, doc) { // set input - if(doc.name===me.docname) { + if (cstr(doc.name) === me.docname) { me.dirty(); let field = me.fields_dict[fieldname]; diff --git a/frappe/public/js/frappe/form/grid.js b/frappe/public/js/frappe/form/grid.js index ea90387922..e620caa244 100644 --- a/frappe/public/js/frappe/form/grid.js +++ b/frappe/public/js/frappe/form/grid.js @@ -501,9 +501,9 @@ export default class Grid { } set_column_disp(fieldname, show) { - if ($.isArray(fieldname)) { + if (Array.isArray(fieldname)) { for (let field of fieldname) { - this.update_docfield_property(field, "hidden", show); + this.update_docfield_property(field, "hidden", show ? 0 : 1); this.set_editable_grid_column_disp(field, show); } } else { diff --git a/frappe/public/js/frappe/form/linked_with.js b/frappe/public/js/frappe/form/linked_with.js index 20db7bdb7c..c47a6e0c86 100644 --- a/frappe/public/js/frappe/form/linked_with.js +++ b/frappe/public/js/frappe/form/linked_with.js @@ -1,9 +1,8 @@ -// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -// MIT License. See license.txt +// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors +// MIT License. See LICENSE frappe.ui.form.LinkedWith = class LinkedWith { - constructor(opts) { $.extend(this, opts); } @@ -21,29 +20,23 @@ frappe.ui.form.LinkedWith = class LinkedWith { } make_dialog() { - this.dialog = new frappe.ui.Dialog({ title: __("Linked With") }); this.dialog.on_page_show = () => { - // execute ajax calls sequentially - // 1. get linked doctypes - // 2. load all doctypes - // 3. load linked docs - this.get_linked_doctypes() - .then(() => this.load_doctypes()) - .then(() => this.links_not_permitted_or_missing()) - .then(() => this.get_linked_docs()) - .then(() => this.make_html()); + frappe.xcall( + "frappe.desk.form.linked_with.get", + {"doctype": cur_frm.doctype, "docname": cur_frm.docname}, + ).then(r => { + this.frm.__linked_docs = r; + }).then(() => this.make_html()); }; } make_html() { - const linked_docs = this.frm.__linked_docs; - let html = ''; - + const linked_docs = this.frm.__linked_docs; const linked_doctypes = Object.keys(linked_docs); if (linked_doctypes.length === 0) { @@ -63,88 +56,6 @@ frappe.ui.form.LinkedWith = class LinkedWith { $(this.dialog.body).html(html); } - load_doctypes() { - const already_loaded = Object.keys(locals.DocType); - let doctypes_to_load = []; - - if (this.frm.__linked_doctypes) { - doctypes_to_load = - Object.keys(this.frm.__linked_doctypes) - .filter(doctype => !already_loaded.includes(doctype)); - } - - // load all doctypes asynchronously using with_doctype - const promises = doctypes_to_load.map(dt => { - return frappe.model.with_doctype(dt, () => { - if(frappe.listview_settings[dt]) { - // add additional fields to __linked_doctypes - this.frm.__linked_doctypes[dt].add_fields = - frappe.listview_settings[dt].add_fields; - } - }); - }); - - return Promise.all(promises); - } - - links_not_permitted_or_missing() { - let links = null; - - if (this.frm.__linked_doctypes) { - links = - Object.keys(this.frm.__linked_doctypes) - .filter(frappe.model.can_get_report); - } - - let flag; - if(!links) { - $(this.dialog.body).html(`${this.frm.__linked_doctypes - ? __("Not enough permission to see links") - : __("Not Linked to any record")}`); - flag = true; - } - flag = false; - - // reject Promise if not_permitted or missing - return new Promise( - (resolve, reject) => flag ? reject() : resolve() - ); - } - - get_linked_doctypes() { - return new Promise((resolve) => { - if (this.frm.__linked_doctypes) { - resolve(); - } - - frappe.call({ - method: "frappe.desk.form.linked_with.get_linked_doctypes", - args: { - doctype: this.frm.doctype - }, - callback: (r) => { - this.frm.__linked_doctypes = r.message; - resolve(); - } - }); - }); - } - - get_linked_docs() { - return frappe.call({ - method: "frappe.desk.form.linked_with.get_linked_docs", - args: { - doctype: this.frm.doctype, - name: this.frm.docname, - linkinfo: this.frm.__linked_doctypes, - for_doctype: this.for_doctype - }, - callback: (r) => { - this.frm.__linked_docs = r.message || {}; - } - }); - } - make_doc_head(heading) { return `
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/list/base_list.js b/frappe/public/js/frappe/list/base_list.js index 75063cc53f..d5ee82acce 100644 --- a/frappe/public/js/frappe/list/base_list.js +++ b/frappe/public/js/frappe/list/base_list.js @@ -760,6 +760,10 @@ class FilterArea { const doctype_fields = this.list_view.meta.fields; const title_field = this.list_view.meta.title_field; + const has_existing_filters = ( + this.list_view.filters + && this.list_view.filters.length > 0 + ); fields = fields.concat( doctype_fields @@ -794,13 +798,17 @@ class FilterArea { options = options.join("\n"); } } - let default_value = - fieldtype === "Link" - ? frappe.defaults.get_user_default(options) - : null; + + let default_value; + + if (fieldtype === "Link" && !has_existing_filters) { + default_value = frappe.defaults.get_user_default(options); + } + if (["__default", "__global"].includes(default_value)) { default_value = null; } + return { fieldtype: fieldtype, label: __(df.label), diff --git a/frappe/public/js/frappe/list/list_factory.js b/frappe/public/js/frappe/list/list_factory.js index acad85fdcb..ef48af4937 100644 --- a/frappe/public/js/frappe/list/list_factory.js +++ b/frappe/public/js/frappe/list/list_factory.js @@ -6,8 +6,8 @@ frappe.provide('frappe.views.list_view'); window.cur_list = null; frappe.views.ListFactory = class ListFactory extends frappe.views.Factory { make (route) { - var me = this; - var doctype = route[1]; + const me = this; + const doctype = route[1]; // List / Gantt / Kanban / etc // File is a special view @@ -21,60 +21,58 @@ frappe.views.ListFactory = class ListFactory extends frappe.views.Factory { } frappe.provide('frappe.views.list_view.' + doctype); - const page_name = frappe.get_route_str(); - - if (!frappe.views.list_view[page_name]) { - frappe.views.list_view[page_name] = new view_class({ - doctype: doctype, - parent: me.make_page(true, page_name) - }); - } else { - frappe.container.change_to(page_name); - } - me.set_cur_list(); + frappe.views.list_view[me.page_name] = new view_class({ + doctype: doctype, + parent: me.make_page(true, me.page_name) + }); + me.set_cur_list(); } - show() { + before_show() { if (this.re_route_to_view()) { - return; + return false; } + this.set_module_breadcrumb(); - super.show(); + } + + on_show() { this.set_cur_list(); - cur_list && cur_list.show(); + if (cur_list) cur_list.show(); } re_route_to_view() { - var route = frappe.get_route(); - var doctype = route[1]; - var last_route = frappe.route_history.slice(-2)[0]; - if (route[0] === 'List' && route.length === 2 && frappe.views.list_view[doctype]) { - if(last_route && last_route[0]==='List' && last_route[1]===doctype) { - // last route same as this route, so going back. - // this happens because /app/List/Item will redirect to /app/List/Item/List - // while coming from back button, the last 2 routes will be same, so - // we know user is coming in the reverse direction (via back button) + const doctype = this.route[1]; + const last_route = frappe.route_history.slice(-2)[0]; + if ( + this.route[0] === 'List' && + this.route.length === 2 && + frappe.views.list_view[doctype] && + last_route && + last_route[0] === 'List' && + last_route[1] === doctype + ) { + // last route same as this route, so going back. + // this happens because /app/List/Item will redirect to /app/List/Item/List + // while coming from back button, the last 2 routes will be same, so + // we know user is coming in the reverse direction (via back button) - // example: - // Step 1: /app/List/Item redirects to /app/List/Item/List - // Step 2: User hits "back" comes back to /app/List/Item - // Step 3: Now we cannot send the user back to /app/List/Item/List so go back one more step - window.history.go(-1); - return true; - } else { - return false; - } + // example: + // Step 1: /app/List/Item redirects to /app/List/Item/List + // Step 2: User hits "back" comes back to /app/List/Item + // Step 3: Now we cannot send the user back to /app/List/Item/List so go back one more step + window.history.go(-1); + return true; } } set_module_breadcrumb() { if (frappe.route_history.length > 1) { - var prev_route = frappe.route_history[frappe.route_history.length - 2]; + const prev_route = frappe.route_history[frappe.route_history.length - 2]; if (prev_route[0] === 'modules') { - var doctype = frappe.get_route()[1], - module = prev_route[1]; + const doctype = this.route[1], module = prev_route[1]; if (frappe.module_links[module] && frappe.module_links[module].includes(doctype)) { // save the last page from the breadcrumb was accessed frappe.breadcrumbs.set_doctype_module(doctype, module); @@ -84,10 +82,8 @@ frappe.views.ListFactory = class ListFactory extends frappe.views.Factory { } set_cur_list() { - var route = frappe.get_route(); - var page_name = frappe.get_route_str(); - cur_list = frappe.views.list_view[page_name]; - if (cur_list && cur_list.doctype !== route[1]) { + cur_list = frappe.views.list_view[this.page_name]; + if (cur_list && cur_list.doctype !== this.route[1]) { // changing... window.cur_list = null; } diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index 5cfc7c75d4..069f353368 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -83,32 +83,15 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { this.sort_by = this.view_user_settings.sort_by || "modified"; this.sort_order = this.view_user_settings.sort_order || "desc"; - // set filters from user_settings or list_settings - if ( - this.view_user_settings.filters && - this.view_user_settings.filters.length - ) { - // Priority 1: user_settings - const saved_filters = this.view_user_settings.filters; - this.filters = this.validate_filters(saved_filters); - } else { - // Priority 2: filters in listview_settings - this.filters = (this.settings.filters || []).map((f) => { - if (f.length === 3) { - f = [this.doctype, f[0], f[1], f[2]]; - } - return f; - }); - } - // build menu items this.menu_items = this.menu_items.concat(this.get_menu_items()); + // set filters from view_user_settings or list_settings if ( this.view_user_settings.filters && this.view_user_settings.filters.length ) { - // Priority 1: saved filters + // Priority 1: view_user_settings const saved_filters = this.view_user_settings.filters; this.filters = this.validate_filters(saved_filters); } else { @@ -932,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; @@ -1757,8 +1740,12 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { const docnames = this.get_checked_items(true).map( (docname) => docname.toString() ); + let message = __("Delete {0} item permanently?", [docnames.length], "Title of confirmation dialog"); + if (docnames.length > 1) { + message = __("Delete {0} items permanently?", [docnames.length], "Title of confirmation dialog"); + } frappe.confirm( - __("Delete {0} items permanently?", [docnames.length], "Title of confirmation dialog"), + message, () => { this.disable_list_update = true; bulk_operations.delete(docnames, () => { 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 62f8ec2f1e..fe959b259d 100644 --- a/frappe/public/js/frappe/model/model.js +++ b/frappe/public/js/frappe/model/model.js @@ -577,13 +577,15 @@ $.extend(frappe.model, { }, delete_doc: function(doctype, docname, callback) { - var title = docname; - var title_field = frappe.get_meta(doctype).title_field; + let title = docname; + const title_field = frappe.get_meta(doctype).title_field; if (frappe.get_meta(doctype).autoname == "hash" && title_field) { - var title = frappe.model.get_value(doctype, docname, title_field); - title += " (" + docname + ")"; + const value = frappe.model.get_value(doctype, docname, title_field); + if (value) { + title = `${value} (${docname})`; + } } - frappe.confirm(__("Permanently delete {0}?", [title]), function() { + frappe.confirm(__("Permanently delete {0}?", [title.bold()]), function() { return frappe.call({ method: 'frappe.client.delete', args: { diff --git a/frappe/public/js/frappe/ui/messages.js b/frappe/public/js/frappe/ui/messages.js index f0d03f0743..504c534665 100644 --- a/frappe/public/js/frappe/ui/messages.js +++ b/frappe/public/js/frappe/ui/messages.js @@ -134,7 +134,17 @@ frappe.msgprint = function(msg, title, is_minimizable) { } if(data.message instanceof Array) { - data.message.forEach(function(m) { + let messages = data.message; + const exceptions = messages + .map(m => JSON.parse(m)) + .filter(m => m.raise_exception); + + // only show exceptions if any exceptions exist + if (exceptions.length) { + messages = exceptions; + } + + messages.forEach(function(m) { frappe.msgprint(m); }); return; diff --git a/frappe/public/js/frappe/utils/utils.js b/frappe/public/js/frappe/utils/utils.js index ff55f5578f..6971d3bc20 100644 --- a/frappe/public/js/frappe/utils/utils.js +++ b/frappe/public/js/frappe/utils/utils.js @@ -196,6 +196,15 @@ Object.assign(frappe.utils, { } return true; }, + parse_json: function(str) { + let parsed_json = ''; + try { + parsed_json = JSON.parse(str); + } catch (e) { + return str; + } + return parsed_json; + }, strip_whitespace: function(html) { return (html || "").replace(/

\s*<\/p>/g, "").replace(/
(\s*
\s*)+/g, "

"); }, diff --git a/frappe/public/js/frappe/views/factory.js b/frappe/public/js/frappe/views/factory.js index ef7f403541..e6a4212af0 100644 --- a/frappe/public/js/frappe/views/factory.js +++ b/frappe/public/js/frappe/views/factory.js @@ -10,20 +10,21 @@ frappe.views.Factory = class Factory { } show() { - var page_name = frappe.get_route_str(), - me = this; + this.route = frappe.get_route(); + this.page_name = frappe.get_route_str(); - if (frappe.pages[page_name]) { - frappe.container.change_to(page_name); - if(me.on_show) { - me.on_show(); + if (this.before_show && this.before_show() === false) return; + + if (frappe.pages[this.page_name]) { + frappe.container.change_to(this.page_name); + if (this.on_show) { + this.on_show(); } } else { - var route = frappe.get_route(); - if(route[1]) { - me.make(route); + if (this.route[1]) { + this.make(this.route); } else { - frappe.show_not_found(route); + frappe.show_not_found(this.route); } } } @@ -34,15 +35,17 @@ frappe.views.Factory = class Factory { } frappe.make_page = function(double_column, page_name) { - if(!page_name) { - var page_name = frappe.get_route_str(); + if (!page_name) { + page_name = frappe.get_route_str(); } - var page = frappe.container.add_page(page_name); + + const page = frappe.container.add_page(page_name); frappe.ui.make_app_page({ parent: page, single_column: !double_column }); + frappe.container.change_to(page_name); return page; } 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 }} - - - - - + - - -

Id + Id Time State Info - Progress + Progress / Wait Event

{{ content }}

-

{{ signature }}

diff --git a/frappe/tests/test_db.py b/frappe/tests/test_db.py index bbd09590be..ab85f28af3 100644 --- a/frappe/tests/test_db.py +++ b/frappe/tests/test_db.py @@ -562,3 +562,50 @@ class TestDDLCommandsPost(unittest.TestCase): """, ) self.assertEquals(len(indexs_in_table), 1) + + @run_only_if(db_type_is.POSTGRES) + def test_modify_query(self): + from frappe.database.postgres.database import modify_query + + query = "select * from `tabtree b` where lft > 13 and rgt <= 16 and name =1.0 and parent = 4134qrsdc and isgroup = 1.00045" + self.assertEqual( + "select * from \"tabtree b\" where lft > \'13\' and rgt <= '16' and name = '1' and parent = 4134qrsdc and isgroup = 1.00045", + modify_query(query) + ) + + query = "select locate(\".io\", \"frappe.io\"), locate(\"3\", cast(3 as varchar)), locate(\"3\", 3::varchar)" + self.assertEqual( + "select strpos( \"frappe.io\", \".io\"), strpos( cast(3 as varchar), \"3\"), strpos( 3::varchar, \"3\")", + modify_query(query) + ) + + @run_only_if(db_type_is.POSTGRES) + def test_modify_values(self): + from frappe.database.postgres.database import modify_values + + self.assertEqual( + {"abcd": "23", "efgh": "23", "ijkl": 23.0345, "mnop": "wow"}, + modify_values({"abcd": 23, "efgh": 23.0, "ijkl": 23.0345, "mnop": "wow"}) + ) + self.assertEqual( + ["23", "23", 23.00004345, "wow"], + modify_values((23, 23.0, 23.00004345, "wow")) + ) + + def test_sequence_table_creation(self): + from frappe.core.doctype.doctype.test_doctype import new_doctype + + dt = new_doctype("autoinc_dt_seq_test", autoincremented=True).insert(ignore_permissions=True) + + if frappe.db.db_type == "postgres": + self.assertTrue( + frappe.db.sql("""select sequence_name FROM information_schema.sequences + where sequence_name ilike 'autoinc_dt_seq_test%'""")[0][0] + ) + else: + self.assertTrue( + frappe.db.sql("""select data_type FROM information_schema.tables + where table_type = 'SEQUENCE' and table_name like 'autoinc_dt_seq_test%'""")[0][0] + ) + + dt.delete(ignore_permissions=True) diff --git a/frappe/tests/test_db_query.py b/frappe/tests/test_db_query.py index a53134064e..b4c7c7cce7 100644 --- a/frappe/tests/test_db_query.py +++ b/frappe/tests/test_db_query.py @@ -494,6 +494,27 @@ class TestReportview(unittest.TestCase): response = execute_cmd("frappe.desk.reportview.get") self.assertListEqual(response["keys"], ["field_label", "field_name", "_aggregate_column", 'columns']) + def test_cast_name(self): + from frappe.core.doctype.doctype.test_doctype import new_doctype + + dt = new_doctype("autoinc_dt_test", autoincremented=True).insert(ignore_permissions=True) + + query = DatabaseQuery("autoinc_dt_test").execute( + fields=["locate('1', `tabautoinc_dt_test`.`name`)", "`tabautoinc_dt_test`.`name`"], + filters={"name": 1}, + run=False + ) + + if frappe.db.db_type == "postgres": + self.assertTrue("strpos( cast( \"tabautoinc_dt_test\".\"name\" as varchar), \'1\')" in query) + self.assertTrue("where cast(\"tabautoinc_dt_test\".name as varchar) = \'1\'" in query) + else: + self.assertTrue("locate(\'1\', `tabautoinc_dt_test`.`name`)" in query) + self.assertTrue("where `tabautoinc_dt_test`.name = 1" in query) + + dt.delete(ignore_permissions=True) + + def add_child_table_to_blog_post(): child_table = frappe.get_doc({ 'doctype': 'DocType', diff --git a/frappe/tests/test_form_load.py b/frappe/tests/test_form_load.py index b10d5eb796..92694cf022 100644 --- a/frappe/tests/test_form_load.py +++ b/frappe/tests/test_form_load.py @@ -168,8 +168,8 @@ class TestFormLoad(unittest.TestCase): "reference_name": note.name, }).insert() - - docinfo = get_docinfo(note) + get_docinfo(note) + docinfo = frappe.response["docinfo"] self.assertEqual(len(docinfo.comments), 1) self.assertIn("test", docinfo.comments[0].content) diff --git a/frappe/tests/test_naming.py b/frappe/tests/test_naming.py index 3e1120dc79..0c5387ccf2 100644 --- a/frappe/tests/test_naming.py +++ b/frappe/tests/test_naming.py @@ -245,6 +245,17 @@ class TestNaming(unittest.TestCase): }) self.assertRaises(frappe.ValidationError, tag.insert) + def test_autoincremented_naming(self): + from frappe.core.doctype.doctype.test_doctype import new_doctype + + doctype = "autoinc_doctype" + frappe.generate_hash(length=5) + dt = new_doctype(doctype, autoincremented=True).insert(ignore_permissions=True) + + for i in range(1, 20): + self.assertEqual(frappe.new_doc(doctype).save(ignore_permissions=True).name, i) + + dt.delete(ignore_permissions=True) + def make_invalid_todo(): frappe.get_doc({ diff --git a/frappe/tests/test_query_builder.py b/frappe/tests/test_query_builder.py index ea700b183e..6b13da067e 100644 --- a/frappe/tests/test_query_builder.py +++ b/frappe/tests/test_query_builder.py @@ -3,7 +3,7 @@ from typing import Callable import frappe from frappe.query_builder.custom import ConstantColumn -from frappe.query_builder.functions import Coalesce, GroupConcat, Match +from frappe.query_builder.functions import Coalesce, GroupConcat, Match, CombineDatetime from frappe.query_builder.utils import db_type_is from frappe.query_builder import Case @@ -32,6 +32,27 @@ class TestCustomFunctionsMariaDB(unittest.TestCase): query.get_sql(), "SELECT `name`,'John' `User` FROM `tabDocType`" ) + def test_timestamp(self): + note = frappe.qb.DocType("Note") + self.assertEqual("TIMESTAMP(posting_date,posting_time)", CombineDatetime(note.posting_date, note.posting_time).get_sql()) + self.assertEqual("TIMESTAMP('2021-01-01','00:00:21')", CombineDatetime("2021-01-01", "00:00:21").get_sql()) + + todo = frappe.qb.DocType("ToDo") + select_query = (frappe.qb + .from_(note) + .join(todo).on(todo.refernce_name == note.name) + .select(CombineDatetime(note.posting_date, note.posting_time))) + self.assertIn("select timestamp(`tabnote`.`posting_date`,`tabnote`.`posting_time`)", str(select_query).lower()) + + select_query = select_query.orderby(CombineDatetime(note.posting_date, note.posting_time)) + self.assertIn("order by timestamp(`tabnote`.`posting_date`,`tabnote`.`posting_time`)", str(select_query).lower()) + + select_query = select_query.where(CombineDatetime(note.posting_date, note.posting_time) >= CombineDatetime("2021-01-01", "00:00:01")) + self.assertIn("timestamp(`tabnote`.`posting_date`,`tabnote`.`posting_time`)>=timestamp('2021-01-01','00:00:01')", str(select_query).lower()) + + select_query = select_query.select(CombineDatetime(note.posting_date, note.posting_time, alias="timestamp")) + self.assertIn("timestamp(`tabnote`.`posting_date`,`tabnote`.`posting_time`) `timestamp`", str(select_query).lower()) + @run_only_if(db_type_is.POSTGRES) class TestCustomFunctionsPostgres(unittest.TestCase): @@ -52,6 +73,30 @@ class TestCustomFunctionsPostgres(unittest.TestCase): query.get_sql(), 'SELECT "name",\'John\' "User" FROM "tabDocType"' ) + def test_timestamp(self): + note = frappe.qb.DocType("Note") + self.assertEqual("posting_date+posting_time", CombineDatetime(note.posting_date, note.posting_time).get_sql()) + self.assertEqual("CAST('2021-01-01' AS DATE)+CAST('00:00:21' AS TIME)", CombineDatetime("2021-01-01", "00:00:21").get_sql()) + + todo = frappe.qb.DocType("ToDo") + select_query = (frappe.qb + .from_(note) + .join(todo).on(todo.refernce_name == note.name) + .select(CombineDatetime(note.posting_date, note.posting_time))) + self.assertIn('select "tabnote"."posting_date"+"tabnote"."posting_time"', str(select_query).lower()) + + select_query = select_query.orderby(CombineDatetime(note.posting_date, note.posting_time)) + self.assertIn('order by "tabnote"."posting_date"+"tabnote"."posting_time"', str(select_query).lower()) + + select_query = select_query.where( + CombineDatetime(note.posting_date, note.posting_time) >= CombineDatetime('2021-01-01', '00:00:01') + ) + self.assertIn("""where "tabnote"."posting_date"+"tabnote"."posting_time">=cast('2021-01-01' as date)+cast('00:00:01' as time)""", + str(select_query).lower()) + + select_query = select_query.select(CombineDatetime(note.posting_date, note.posting_time, alias="timestamp")) + self.assertIn('"tabnote"."posting_date"+"tabnote"."posting_time" "timestamp"', str(select_query).lower()) + class TestBuilderBase(object): def test_adding_tabs(self): diff --git a/frappe/tests/test_query_report.py b/frappe/tests/test_query_report.py index 656894fc9b..2117bc830e 100644 --- a/frappe/tests/test_query_report.py +++ b/frappe/tests/test_query_report.py @@ -12,37 +12,30 @@ class TestQueryReport(unittest.TestCase): def test_xlsx_data_with_multiple_datatypes(self): """Test exporting report using rows with multiple datatypes (list, dict)""" - # Describe the columns - columns = { - 0: {"label": "Column A", "fieldname": "column_a"}, - 1: {"label": "Column B", "fieldname": "column_b"}, - 2: {"label": "Column C", "fieldname": "column_c"} - } - # Create mock data data = frappe._dict() data.columns = [ - {"label": "Column A", "fieldname": "column_a"}, - {"label": "Column B", "fieldname": "column_b", "width": 150}, - {"label": "Column C", "fieldname": "column_c", "width": 100} + {"label": "Column A", "fieldname": "column_a", "fieldtype": "Float"}, + {"label": "Column B", "fieldname": "column_b", "width": 100, "fieldtype": "Float"}, + {"label": "Column C", "fieldname": "column_c", "width": 150, "fieldtype": "Duration"}, ] data.result = [ - [1.0, 3.0, 5.5], - {"column_a": 22.1, "column_b": 21.8, "column_c": 30.2}, - {"column_b": 5.1, "column_c": 9.5, "column_a": 11.1}, - [3.0, 1.5, 7.5], + [1.0, 3.0, 600], + {"column_a": 22.1, "column_b": 21.8, "column_c": 86412}, + {"column_b": 5.1, "column_c": 53234, "column_a": 11.1}, + [3.0, 1.5, 333], ] # Define the visible rows visible_idx = [0, 2, 3] # Build the result - xlsx_data, column_widths = build_xlsx_data(columns, data, visible_idx, include_indentation=0) + xlsx_data, column_widths = build_xlsx_data(data, visible_idx, include_indentation=0) self.assertEqual(type(xlsx_data), list) self.assertEqual(len(xlsx_data), 4) # columns + data # column widths are divided by 10 to match the scale that is supported by openpyxl - self.assertListEqual(column_widths, [0, 15, 10]) + self.assertListEqual(column_widths, [0, 10, 15]) for row in xlsx_data: self.assertEqual(type(row), list) diff --git a/frappe/tests/ui_test_helpers.py b/frappe/tests/ui_test_helpers.py index 26c20f3d18..473f9b22d3 100644 --- a/frappe/tests/ui_test_helpers.py +++ b/frappe/tests/ui_test_helpers.py @@ -135,11 +135,14 @@ def create_contact_records(): insert_contact('Test Form Contact 3', '12345') @frappe.whitelist() -def create_multiple_contact_records(): - if frappe.db.get_all('Contact', {'first_name': 'Multiple Contact 1'}): +def create_multiple_todo_records(): + if frappe.db.get_all('ToDo', {'description': 'Multiple ToDo 1'}): return - for index in range(1001): - insert_contact('Multiple Contact {}'.format(index+1), '12345{}'.format(index+1)) + for index in range(501): + frappe.get_doc({ + 'doctype': 'ToDo', + 'description': 'Multiple ToDo {}'.format(index+1) + }).insert() def insert_contact(first_name, phone_number): doc = frappe.get_doc({ diff --git a/frappe/translations/de.csv b/frappe/translations/de.csv index afd1b101d6..1131a8b5d7 100644 --- a/frappe/translations/de.csv +++ b/frappe/translations/de.csv @@ -146,7 +146,7 @@ Monthly,Monatlich, More,Weiter, More Information,Mehr Informationen, More...,Mehr..., -Move,Bewegen, +Move,Verschieben, My Account,Mein Konto, My Profile,Mein Profil, My Settings,Meine Einstellungen, @@ -175,7 +175,7 @@ Payment Gateway,Zahlungs-Gateways, Payment Gateway Name,Name des Zahlungsgateways, Payments,Zahlungen, Period,Periode, -Pincode,Postleitzahl (PLZ), +Pincode,Postleitzahl, Plan Name,Planname, Please enable pop-ups,Bitte Pop-ups aktivieren, Please select Company,Bitte Unternehmen auswählen, @@ -1486,7 +1486,7 @@ Linked,Verknüpft, Linked With,Verknüpft mit, Linked with {0},Verknüpft mit {0}, Links,Verknüpfungen, -List,Listenansicht, +List,Liste, List Filter,Listenfilter, List View,Listenansicht, List View Setting,Einstellungen zu Listenansicht, @@ -2427,7 +2427,7 @@ Sum,Summe, Sum of {0},Summe von {0}, Support Email Address Not Specified,Support-E-Mail-Adresse nicht angegeben, Suspend Sending,Senden unterbrechen, -Switch To Desk,Switch To Desk, +Switch To Desk,Zum Desk wechseln, Symbol,Symbol, Sync,Synchronisieren, Sync on Migrate,Sync auf Migrate, @@ -2870,8 +2870,8 @@ bullhorn,Megafon, ca-central-1,ca-central-1, camera,Kamera, cancelled this document,brach die Arbeit an diesem Dokument ab, -changed value of {0},Wert von {0} geändert, -changed values for {0},Werte von {0} geändert, +changed value of {0},hat den Wert von {0} geändert, +changed values for {0},hat die Werte von {0} geändert, chevron-down,Winkel nach unten, chevron-left,Winkel nach links, chevron-right,Winkel nach rechts, @@ -3431,7 +3431,7 @@ Mandatory Depends On,Obligatorisch Hängt von ab, Map Columns,Spalten zuordnen, Map columns from {0} to fields in {1},Ordnen Sie Spalten von {0} Feldern in {1} zu., Mapping column {0} to field {1},Spalte {0} dem Feld {1} zuordnen, -Mark all as Read,Markiere alle als gelesen, +Mark all as Read,Alle als gelesen markieren, Maximum Points,Maximale Punkte, Maximum points allowed after multiplying points with the multiplier value\n(Note: For no limit leave this field empty or set 0),Maximal zulässige Punkte nach Multiplikation der Punkte mit dem Multiplikatorwert (Hinweis: Für unbegrenzte Anzahl lassen Sie dieses Feld leer oder setzen Sie 0), Me,Mir, @@ -3485,7 +3485,7 @@ Page Shortcuts,Seitenkürzel, Parent Field (Tree),Elternfeld (Baum), Parent Field must be a valid fieldname,Das übergeordnete Feld muss ein gültiger Feldname sein, Pin Globally,Global anheften, -Places,Setzt, +Places,Orte, Please check the filter values set for Dashboard Chart: {},Bitte überprüfen Sie die für das Dashboard-Diagramm festgelegten Filterwerte: {}, Please enable pop-ups in your browser,Bitte aktivieren Sie Popups in Ihrem Browser, Please find attached {0}: {1},Im Anhang finden Sie {0}: {1}, @@ -3541,7 +3541,7 @@ Select Filters,Wählen Sie Filter, Select Google Calendar to which event should be synced.,"Wählen Sie Google Kalender aus, mit dem das Ereignis synchronisiert werden soll.", Select Google Contacts to which contact should be synced.,"Wählen Sie Google-Kontakte aus, mit denen der Kontakt synchronisiert werden soll.", Select Group By...,Wählen Sie Gruppieren nach ..., -Select Mandatory,Wählen Pflicht, +Select Mandatory,Verpflichtende auswählen, Select atleast 2 actions,Wählen Sie mindestens 2 Aktionen aus, Select list item,Listenelement auswählen, Select multiple list items,Wählen Sie mehrere Listenelemente aus, @@ -3664,8 +3664,8 @@ You need to install pycups to use this feature!,"Sie müssen Pycups installieren Your Target,Dein Ziel, "browse,","Durchsuche,", cancelled this document {0},stornierte dieses Dokument {0}, -changed value of {0} {1},geänderter Wert von {0} {1}, -changed values for {0} {1},geänderte Werte für {0} {1}, +changed value of {0} {1},hat den Wert von {0} {1} geändert, +changed values for {0} {1},hat die Werte von {0} {1} geändert, choose an,wähle ein, empty,leeren, of,von, @@ -3789,14 +3789,14 @@ Reset,Zurücksetzen, Review,Rezension, Room,Zimmer, Room Type,Zimmertyp, -Save,speichern, +Save,Speichern, Search results for,Suchergebnisse für, Select All,Alles auswählen, Send,Absenden, Sending,Versand, Server Error,Serverfehler, Set,Menge, -Setup,Einstellungen, +Setup,Einrichtung, Setup Wizard,Setup-Assistent, Size,Größe, Sr,Pos, @@ -3819,7 +3819,7 @@ Warehouse,Lager, Welcome to {0},Willkommen auf {0}, Year,Jahr, Yearly,Jährlich, -You,Benutzer, +You,Sie, You can also copy-paste this link in your browser,Sie können diese Verknüpfung in Ihren Browser kopieren, and,und, {0} Name,{0} Name, @@ -3953,7 +3953,7 @@ lock,sperren, logged in,Angemeldet, message,Mitteilung, module,Modul, -move,Bewegung, +move,verschieben, music,Musik, new,Neu, now,jetzt, @@ -4135,9 +4135,9 @@ Using this console may allow attackers to impersonate you and steal your informa yesterday,gestern, {0} years ago,Vor {0} Jahren, New Chart,Neues Diagramm, -New Shortcut,Neue Verknüpfung, +New Shortcut,Neuer Schnellzugriff, Edit Chart,Diagramm bearbeiten, -Edit Shortcut,Verknüpfung bearbeiten, +Edit Shortcut,Schnellzugriff bearbeiten, Couldn't Load Desk,Schreibtisch konnte nicht geladen werden, "Something went wrong while loading Desk. Please relaod the page. If the problem persists, contact the Administrator","Beim Laden von Desk ist ein Fehler aufgetreten. Bitte überarbeiten Sie die Seite . Wenn das Problem weiterhin besteht, wenden Sie sich an den Administrator", Customize Workspace,Arbeitsbereich anpassen, @@ -4228,7 +4228,7 @@ since last month,seit letztem Monat, since last year,seit letztem Jahr, Show,Show, New Number Card,Neue Zahlenkarte, -Your Shortcuts,Ihre Verknüpfungen, +Your Shortcuts,Ihre Schnellzugriffe, You haven't added any Dashboard Charts or Number Cards yet.,Sie haben noch keine Dashboard-Diagramme oder Zahlenkarten hinzugefügt., Click On Customize to add your first widget,"Klicken Sie auf Anpassen, um Ihr erstes Widget hinzuzufügen", Are you sure you want to reset all customizations?,Möchten Sie wirklich alle Anpassungen zurücksetzen?, @@ -4650,7 +4650,7 @@ Not permitted to view {0},{0} darf nicht angezeigt werden, Camera,Kamera, Invalid filter: {0},Ungültiger Filter: {0}, Let's Get Started,Lass uns anfangen, -Reports & Masters,Berichte & Meister, +Reports & Masters,Berichte & Stammdaten, New {0} {1} added to Dashboard {2},Neues {0} {1} zum Dashboard hinzugefügt {2}, New {0} {1} created,Neue {0} {1} erstellt, New {0} Created,Neu {0} erstellt, @@ -4715,3 +4715,67 @@ Reset sorting,Sortierung zurücksetzen, Sort Ascending,Aufsteigend sortieren, Sort Descending,Absteigend sortieren, Remove column,Spalte entfernen, +Set all public,Alle als öffentlich setzen, +Set all private,Alle als privat setzen, +Library,Bibliothek, +My Device,Mein Gerät, +Drag and drop files here or upload from,Ziehen Sie Dateien hierher oder laden Sie sie von, +days,Tage, +seconds,Sekunden, +minutes,Minuten, +Copy,Kopieren, +{} Assigned,{} Zugewiesen, +Hide Saved,Gespeicherte ausblenden, +Show Saved,Gespeicherte anzeigen, +{0} created this {1},{0} erstellte dies {1}, +{0} edited this {1},{0} bearbeitete dies {1}, +Toggle Full Width,Toggle Volle Breite, +Documentation,Dokumentation, +About,Über, +Search or type a command (Ctrl + G),Suchen oder Befehl eingeben (Strg + G), +{} Pending,{} Ausstehend, +{} Available,{} Verfügbar, +{} Open,{} Offen, +Password set,Passwort gesetzt, +Your new password has been set successfully.,Ihr Passwort wurde erfolgreich aktualisiert., +You hit the rate limit because of too many requests. Please try after sometime.,Sie haben die maximale Anzahl an Anfragen erreicht. Bitte versuchen Sie es später noch einmal., +"You need {0} permission to fetch values from {1} {2}","Sie benötigen eine {0}-Berechtigung, um die Werte von {1} {2} abzurufen", +Cannot Fetch Values,Werte können nicht abgerufen werden, +You do not have Read or Select Permissions for {},Sie haben keine Lese- oder Auswahlberechtigung für {}, +Or,Oder, +{0} changed values for {1},{0} hat die Werte von {1} geändert, +{0} changed values for {1} {2},{0} hat die Werte von {1} {2} geändert, +{0} cancelled this document,{0} dieses Dokument storniert, +{0} cancelled this document {1},{0} dieses Dokument storniert {1}, +{0} submitted this document,{0} hat dieses Dokument eingereicht, +{0} submitted this document {1},{0} hat das Dokument {1} eingereicht, +Customizations Discarded,Anpassungen verworfen, +No filters selected,Keine Filter ausgewählt, +You haven't created a {0} yet,Sie haben noch kein(en) {0} erstellt, +No Data...,Keine Daten..., +Don't have an account?,Sie haben noch kein Benutzerkonto?, +{0} changed value of {1},{0} hat den Wert von {1} geändert, +Basic Info,Grundlegende Informationen, +No.,Nr.,number +No.,Nein.,opposite of yes +There are no upcoming events for you.,Es sind keine Termine für Sie geplant., +No Upcoming Events,Keine bevorstehenden Termine, +"Looks like you haven’t received any notifications.","Sieht aus, als hätten Sie keine Benachrichtigungen erhalten.", +No New notifications,Keine neuen Benachrichtigungen, +Overview,Übersicht, +Connections,Verknüpfungen, +Save Customizations,Anpassungen speichern, +Apply Filters,Filter anwenden, +Add a Filter,Filter hinzufügen, +Reset Customizations,Anpassungen zurücksetzen, +{} wants to access the following details from your account,{} möchte Zugriff auf die folgenden Angaben von Ihrem Account, +{0} is not a field of doctype {1},{0} ist kein Feld in Doctype {1}, +{0} from {1} to {2} in row #{3},{0} von {1} zu/bis {2} in Zeile #{3}, +{0} from {1} to {2},{0} von {1} zu/bis {2}, +{0} changed {1} to {2},{0} wurde von {1} zu {2} geändert, +{0} Map,{0} Karte, +Use HTML,HTML verwenden, +Submit on Creation,Nach Erstellung buchen, +Show Absolute Values,Absolutwerte anzeigen, +Row #{0}: Could not find field {1} in {2} DocType,Zeile #{0}: Feld {1} existiert nicht in DocType {2}, +Repeat on Days,An Tagen wiederholen, diff --git a/frappe/utils/background_jobs.py b/frappe/utils/background_jobs.py index 58029dbc5f..0a68cf22c4 100755 --- a/frappe/utils/background_jobs.py +++ b/frappe/utils/background_jobs.py @@ -57,7 +57,11 @@ def enqueue(method, queue='default', timeout=None, event=None, # To handle older implementations is_async = kwargs.pop('async', is_async) - if now or frappe.flags.in_migrate: + if not is_async and not frappe.flags.in_test: + print(_("Using enqueue with is_async=False outside of tests is not recommended, use now=True instead.")) + + call_directly = now or frappe.flags.in_migrate or (not is_async and not frappe.flags.in_test) + if call_directly: return frappe.call(method, **kwargs) q = get_queue(queue, is_async=is_async) diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index d23804bef4..3a0c337042 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -183,8 +183,6 @@ class BackupGenerator: False, ) - self.todays_date = now_datetime().strftime("%Y%m%d_%H%M%S") - if not ( self.backup_path_conf and self.backup_path_db @@ -212,7 +210,7 @@ class BackupGenerator: partial = "-partial" if self.partial else "" ext = "tgz" if self.compress_files else "tar" enc = "-enc" if frappe.get_system_settings("encrypt_backup") else "" - + self.todays_date = now_datetime().strftime("%Y%m%d_%H%M%S") for_conf = f"{self.todays_date}-{self.site_slug}-site_config_backup{enc}.json" for_db = f"{self.todays_date}-{self.site_slug}{partial}-database{enc}.sql.gz" diff --git a/frappe/utils/data.py b/frappe/utils/data.py index 50c71bdc2e..212ae8eba6 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -1494,7 +1494,7 @@ def expand_relative_urls(html): return html def quoted(url): - return cstr(quote(encode(url), safe=b"~@#$&()*!+=:;,.?/'")) + return cstr(quote(encode(cstr(url)), safe=b"~@#$&()*!+=:;,.?/'")) def quote_urls(html): def _quote_url(match): diff --git a/frappe/utils/diff.py b/frappe/utils/diff.py index ac0e1b7439..2574f47fbd 100644 --- a/frappe/utils/diff.py +++ b/frappe/utils/diff.py @@ -1,14 +1,15 @@ import json from difflib import unified_diff -from typing import List +from typing import List, Union import frappe from frappe.utils import pretty_date +from frappe.utils.data import cstr @frappe.whitelist() def get_version_diff( - from_version: str, to_version: str, fieldname: str = "script" + from_version: Union[int, str], to_version: Union[int, str], fieldname: str = "script" ) -> List[str]: before, before_timestamp = _get_value_from_version(from_version, fieldname) @@ -23,15 +24,15 @@ def get_version_diff( diff = unified_diff( before, after, - fromfile=from_version, - tofile=to_version, + fromfile=cstr(from_version), + tofile=cstr(to_version), fromfiledate=before_timestamp, tofiledate=after_timestamp, ) return list(diff) -def _get_value_from_version(version_name: str, fieldname: str): +def _get_value_from_version(version_name: Union[int, str], fieldname: str): version = frappe.get_list( "Version", fields=["data", "modified"], filters={"name": version_name} ) diff --git a/frappe/utils/global_search.py b/frappe/utils/global_search.py index 7b591dff45..22938671a6 100644 --- a/frappe/utils/global_search.py +++ b/frappe/utils/global_search.py @@ -9,6 +9,8 @@ import os from frappe.utils import cint, strip_html_tags from frappe.utils.html_utils import unescape_html from frappe.model.base_document import get_controller +from frappe.utils.data import cstr + def setup_global_search_table(): """ @@ -251,7 +253,7 @@ def update_global_search(doc): if hasattr(doc, 'is_website_published') and doc.meta.allow_guest_to_view: published = 1 if doc.is_website_published() else 0 - title = (doc.get_title() or '')[:int(frappe.db.VARCHAR_LEN)] + title = (cstr(doc.get_title()) or '')[:int(frappe.db.VARCHAR_LEN)] route = doc.get('route') if doc else '' value = dict( diff --git a/frappe/website/doctype/blog_post/blog_post.json b/frappe/website/doctype/blog_post/blog_post.json index b05293f28b..5e3cc78d70 100644 --- a/frappe/website/doctype/blog_post/blog_post.json +++ b/frappe/website/doctype/blog_post/blog_post.json @@ -213,8 +213,7 @@ "index_web_pages_for_search": 1, "is_published_field": "published", "links": [], - "max_attachments": 5, - "modified": "2021-11-23 10:42:01.759723", + "modified": "2022-03-09 01:48:25.227295", "modified_by": "Administrator", "module": "Website", "name": "Blog Post", diff --git a/frappe/website/doctype/web_form/templates/web_form.html b/frappe/website/doctype/web_form/templates/web_form.html index 72cdf07c59..9bcb036ec6 100644 --- a/frappe/website/doctype/web_form/templates/web_form.html +++ b/frappe/website/doctype/web_form/templates/web_form.html @@ -82,7 +82,8 @@ frappe.boot = { time_zone: { system: "{{ frappe.utils.get_time_zone() }}", user: "{{ frappe.db.get_value('User', frappe.session.user, 'time_zone') or frappe.utils.get_time_zone() }}" - } + }, + link_title_doctypes: `{{ frappe.call('frappe.boot.get_link_title_doctypes') }}` }; // for backward compatibility of some libs frappe.sys_defaults = frappe.boot.sysdefaults; diff --git a/frappe/website/doctype/web_form/web_form.py b/frappe/website/doctype/web_form/web_form.py index 8727443136..d891ceb205 100644 --- a/frappe/website/doctype/web_form/web_form.py +++ b/frappe/website/doctype/web_form/web_form.py @@ -598,13 +598,24 @@ def get_link_options(web_form_name, doctype, allow_read_on_all_link_options=Fals break if doctype_validated: - link_options = [] + link_options, filters = [], {} + if limited_to_user: - link_options = "\n".join([doc.name for doc in frappe.get_all(doctype, filters = {"owner":frappe.session.user})]) - else: - link_options = "\n".join([doc.name for doc in frappe.get_all(doctype)]) + filters = {"owner":frappe.session.user} + + fields = ['name as value'] - return link_options + title_field = frappe.db.get_value('DocType', doctype, 'title_field', cache=1) + show_title_field_in_link = frappe.db.get_value('DocType', doctype, 'show_title_field_in_link', cache=1) == 1 + if title_field and show_title_field_in_link: + fields.append(f'{title_field} as label') + + link_options = frappe.get_all(doctype, filters, fields) + + if title_field and show_title_field_in_link: + return json.dumps(link_options, default=str) + else: + return "\n".join([doc.value for doc in link_options]) else: raise frappe.PermissionError('Not Allowed, {0}'.format(doctype)) diff --git a/frappe/website/doctype/web_page/web_page.json b/frappe/website/doctype/web_page/web_page.json index b1fdd02af7..e7bd705272 100644 --- a/frappe/website/doctype/web_page/web_page.json +++ b/frappe/website/doctype/web_page/web_page.json @@ -338,8 +338,7 @@ "index_web_pages_for_search": 1, "is_published_field": "published", "links": [], - "max_attachments": 20, - "modified": "2022-01-03 13:01:48.182645", + "modified": "2022-03-09 01:45:28.548671", "modified_by": "Administrator", "module": "Website", "name": "Web Page", diff --git a/frappe/website/doctype/website_settings/website_settings.json b/frappe/website/doctype/website_settings/website_settings.json index 3b199a4b58..b628437315 100644 --- a/frappe/website/doctype/website_settings/website_settings.json +++ b/frappe/website/doctype/website_settings/website_settings.json @@ -420,8 +420,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "max_attachments": 10, - "modified": "2022-02-24 15:37:22.360138", + "modified": "2022-03-09 01:47:31.094462", "modified_by": "Administrator", "module": "Website", "name": "Website Settings", diff --git a/frappe/www/error.py b/frappe/www/error.py index e535bebe12..513af881fa 100644 --- a/frappe/www/error.py +++ b/frappe/www/error.py @@ -8,5 +8,5 @@ def get_context(context): if frappe.flags.in_migrate: return context.http_status_code = 500 - print(frappe.get_traceback().encode("utf-8")) + print(frappe.get_traceback()) return {"error": frappe.get_traceback().replace("<", "<").replace(">", ">") } diff --git a/package.json b/package.json index 259a157311..293871d15c 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,6 @@ "@editorjs/editorjs": "2.20.0", "ace-builds": "^1.4.8", "air-datepicker": "github:frappe/air-datepicker", - "autoprefixer": "^9.8.6", "awesomplete": "^1.1.5", "bootstrap": "4.5.0", "cliui": "^7.0.4", @@ -66,14 +65,17 @@ "vuedraggable": "^2.24.3" }, "devDependencies": { + "@frappe/esbuild-plugin-postcss2": "^0.1.3", + "autoprefixer": "10", "chalk": "^2.3.2", "esbuild": "^0.11.21", - "esbuild-plugin-postcss2": "^0.0.9", "esbuild-vue": "^0.2.0", "fast-glob": "^3.2.5", "launch-editor": "^2.2.1", "md5": "^2.3.0", + "postcss": "8", "rtlcss": "^3.2.1", + "sass": "^1.49.9", "yargs": "^16.2.0" }, "snyk": true, diff --git a/requirements.txt b/requirements.txt index ba4a1a598b..c77ab1d424 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,6 +29,7 @@ maxminddb-geolite2==2018.703 num2words~=0.5.10 oauthlib~=3.1.0 openpyxl~=3.0.7 +parse~=1.19.0 passlib~=1.7.4 paytmchecksum~=1.7.0 pdfkit~=0.6.1 diff --git a/yarn.lock b/yarn.lock index 65cc9d045f..faf2664dcb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -36,6 +36,20 @@ codex-notifier "^1.1.2" codex-tooltip "^1.0.1" +"@frappe/esbuild-plugin-postcss2@^0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@frappe/esbuild-plugin-postcss2/-/esbuild-plugin-postcss2-0.1.3.tgz#523a5cc32788f184bb78c7b946c9f132ef386508" + integrity sha512-/kPz/NJki2GFFtcgTnvdkkjgPEU1uHmaN7/OI2Ysc2tEZ7dcL7FYEEV72a5Fv8cniJbmH8UUjItZmHixFCT1Dg== + dependencies: + autoprefixer "^10.2.5" + fs-extra "^9.1.0" + less "^4.x" + postcss-modules "^4.0.0" + resolve-file "^0.3.0" + sass "^1.x" + stylus "^0.x" + tmp "^0.2.1" + "@nodelib/fs.scandir@2.1.4": version "2.1.4" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz#d4b3549a5db5de2683e0c1071ab4f140904bbf69" @@ -344,6 +358,18 @@ atob@^2.1.2: resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== +autoprefixer@10: + version "10.4.2" + resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.2.tgz#25e1df09a31a9fba5c40b578936b90d35c9d4d3b" + integrity sha512-9fOPpHKuDW1w/0EKfRmVnxTDt8166MAnLI3mgZ1JCnhNtYWxcJ6Ud5CO/AVOZi/AvFa8DY9RTy3h3+tFBlrrdQ== + dependencies: + browserslist "^4.19.1" + caniuse-lite "^1.0.30001297" + fraction.js "^4.1.2" + normalize-range "^0.1.2" + picocolors "^1.0.0" + postcss-value-parser "^4.2.0" + autoprefixer@^10.2.5: version "10.2.5" resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.2.5.tgz#096a0337dbc96c0873526d7fef5de4428d05382d" @@ -356,19 +382,6 @@ autoprefixer@^10.2.5: normalize-range "^0.1.2" postcss-value-parser "^4.1.0" -autoprefixer@^9.8.6: - version "9.8.6" - resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-9.8.6.tgz#3b73594ca1bf9266320c5acf1588d74dea74210f" - integrity sha512-XrvP4VVHdRBCdX1S3WXVD8+RyG9qeb1D5Sn1DeLiG2xfSpzellk5k54xbUERJ3M5DggQxes39UGOTP8CFrEGbg== - dependencies: - browserslist "^4.12.0" - caniuse-lite "^1.0.30001109" - colorette "^1.2.1" - normalize-range "^0.1.2" - num2fraction "^1.2.2" - postcss "^7.0.32" - postcss-value-parser "^4.1.0" - available-typed-arrays@^1.0.0, available-typed-arrays@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.2.tgz#6b098ca9d8039079ee3f77f7b783c4480ba513f5" @@ -512,7 +525,7 @@ braces@^3.0.1, braces@~3.0.2: dependencies: fill-range "^7.0.1" -browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.16.0, browserslist@^4.16.3: +browserslist@^4.0.0, browserslist@^4.16.0, browserslist@^4.16.3: version "4.16.6" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.6.tgz#d7901277a5a88e554ed305b183ec9b0c08f66fa2" integrity sha512-Wspk/PqO+4W9qp5iUTJsa1B/QrYn1keNCcEP5OvP7WBwT4KaDly0uONYmC6Xa3Z5IqnUgS0KcgLYu1l74x0ZXQ== @@ -523,6 +536,17 @@ browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.16.0, browserslist@^4 escalade "^3.1.1" node-releases "^1.1.71" +browserslist@^4.19.1: + version "4.20.0" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.20.0.tgz#35951e3541078c125d36df76056e94738a52ebe9" + integrity sha512-bnpOoa+DownbciXj0jVGENf8VYQnE2LNWomhYuCsMmmx9Jd9lwq0WXODuwpSsp8AVdKM2/HorrzxAfbKvWTByQ== + dependencies: + caniuse-lite "^1.0.30001313" + electron-to-chromium "^1.4.76" + escalade "^3.1.1" + node-releases "^2.0.2" + picocolors "^1.0.0" + bytes@3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6" @@ -570,11 +594,16 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001196, caniuse-lite@^1.0.30001219: +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001196, caniuse-lite@^1.0.30001219: version "1.0.30001296" resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001296.tgz" integrity sha512-WfrtPEoNSoeATDlf4y3QvkwiELl9GyPLISV5GejTbbQRtQx4LhsXmc9IQ6XCL2d7UxCyEzToEZNMeqR79OUw8Q== +caniuse-lite@^1.0.30001297, caniuse-lite@^1.0.30001313: + version "1.0.30001316" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001316.tgz#b44a1f419f82d2e119aa0bbdab5ec15471796358" + integrity sha512-JgUdNoZKxPZFzbzJwy4hDSyGuH/gXz2rN51QmoR8cBQsVo58llD3A0vlRKKRt8FGf5u69P9eQyIH8/z9vN/S0Q== + caseless@~0.12.0: version "0.12.0" resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" @@ -736,7 +765,7 @@ colord@^2.0.0: resolved "https://registry.yarnpkg.com/colord/-/colord-2.0.0.tgz#f8c19f2526b7dc5b22d6e57ef102f03a2a43a3d8" integrity sha512-WMDFJfoY3wqPZNpKUFdse3HhD5BHCbE9JCdxRzoVH+ywRITGOeWAHNkGEmyxLlErEpN9OLMWgdM9dWQtDk5dog== -colorette@^1.2.1, colorette@^1.2.2: +colorette@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94" integrity sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w== @@ -1220,6 +1249,11 @@ electron-to-chromium@^1.3.723: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.736.tgz#f632d900a1f788dab22fec9c62ec5c9c8f0c4052" integrity sha512-DY8dA7gR51MSo66DqitEQoUMQ0Z+A2DSXFi7tK304bdTVqczCAfUuyQw6Wdg8hIoo5zIxkU1L24RQtUce1Ioig== +electron-to-chromium@^1.4.76: + version "1.4.84" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.84.tgz#2700befbcb49c42c4ee162e137ff392c07658249" + integrity sha512-b+DdcyOiZtLXHdgEG8lncYJdxbdJWJvclPNMg0eLUDcSOSO876WA/pYjdSblUTd7eJdIs4YdIxHWGazx7UPSJw== + emoji-regex@^7.0.1: version "7.0.3" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" @@ -1348,21 +1382,6 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" -esbuild-plugin-postcss2@^0.0.9: - version "0.0.9" - resolved "https://registry.yarnpkg.com/esbuild-plugin-postcss2/-/esbuild-plugin-postcss2-0.0.9.tgz#b889af46f703990988d47885632108901948673e" - integrity sha512-iDKxWohm9aD2s+++ihb6GJVcddebsxOaC+Oz8TV0xJnKy0yHz/xazX96HyP45cS6+SFvZwr+SzG+QHbMOuXfMg== - dependencies: - autoprefixer "^10.2.5" - fs-extra "^9.1.0" - less "^4.x" - postcss "8.x" - postcss-modules "^4.0.0" - resolve-file "^0.3.0" - sass "^1.x" - stylus "^0.x" - tmp "^0.2.1" - esbuild-vue@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/esbuild-vue/-/esbuild-vue-0.2.0.tgz#8a3fde404bda57fe32b80e24917d14036e242bd3" @@ -1630,6 +1649,11 @@ fraction.js@^4.0.13: resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.0.13.tgz#3c1c315fa16b35c85fffa95725a36fa729c69dfe" integrity sha512-E1fz2Xs9ltlUp+qbiyx9wmt2n9dRzPsS11Jtdb8D2o+cC7wr9xkkKsVKJuBX0ST+LVS+LhLO+SbLJNtfWcJvXA== +fraction.js@^4.1.2: + version "4.2.0" + resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.2.0.tgz#448e5109a313a3527f5a3ab2119ec4cf0e0e2950" + integrity sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA== + frappe-charts@^2.0.0-rc13: version "2.0.0-rc13" resolved "https://registry.yarnpkg.com/frappe-charts/-/frappe-charts-2.0.0-rc13.tgz#fdb251d7ae311c41e38f90a3ae108070ec6b9072" @@ -2082,6 +2106,11 @@ immediate@~3.0.5: resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps= +immutable@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.0.0.tgz#b86f78de6adef3608395efb269a91462797e2c23" + integrity sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw== + import-fresh@^3.2.1: version "3.3.0" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" @@ -2873,11 +2902,16 @@ nan@^2.13.2: resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01" integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw== -nanoid@^3.1.22, nanoid@^3.1.23: +nanoid@^3.1.23: version "3.2.0" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.2.0.tgz#62667522da6673971cca916a6d3eff3f415ff80c" integrity sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA== +nanoid@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.1.tgz#6347a18cac88af88f58af0b3594b723d5e99bb35" + integrity sha512-n6Vs/3KGyxPQd6uO0eH4Bv0ojGSUvuLlIHtC3Y0kEO23YRge8H9x1GCzLn28YX0H66pMkxuaeESFq4tKISKwdw== + native-request@^1.0.5: version "1.0.8" resolved "https://registry.yarnpkg.com/native-request/-/native-request-1.0.8.tgz#8f66bf606e0f7ea27c0e5995eb2f5d03e33ae6fb" @@ -2962,6 +2996,11 @@ node-releases@^1.1.71: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.72.tgz#14802ab6b1039a79a0c7d662b610a5bbd76eacbe" integrity sha512-LLUo+PpH3dU6XizX3iVoubUNheF/owjXCZZ5yACDxNnPtgFuludV1ZL3ayK1kVep42Rmm0+R9/Y60NQbZ2bifw== +node-releases@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.2.tgz#7139fe71e2f4f11b47d4d2986aaf8c48699e0c01" + integrity sha512-XxYDdcQ6eKqp/YjI+tb2C5WM2LgjnZrfYg4vgQt49EK268b6gYCHsBLrK2qvJo4FmCtqmKezb0WZFK4fkrZNsg== + node-sass@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-7.0.0.tgz#33ee7c2df299d51f682f13d79f3d2a562225788e" @@ -3059,11 +3098,6 @@ nth-check@^2.0.0: dependencies: boolbase "^1.0.0" -num2fraction@^1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/num2fraction/-/num2fraction-1.2.2.tgz#6f682b6a027a4e9ddfa4564cd2589d1d4e669ede" - integrity sha1-b2gragJ6Tp3fpFZM0lidHU5mnt4= - number-is-nan@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" @@ -3280,6 +3314,11 @@ performance-now@^2.1.0: resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= +picocolors@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" + integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== + picomatch@^2.0.4: version "2.2.3" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.3.tgz#465547f359ccc206d3c48e46a1bcb89bf7ee619d" @@ -3627,14 +3666,19 @@ postcss-value-parser@^4.0.2, postcss-value-parser@^4.1.0: resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz#443f6a20ced6481a2bda4fa8532a6e55d789a2cb" integrity sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ== -postcss@8.x: - version "8.2.10" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.2.10.tgz#ca7a042aa8aff494b334d0ff3e9e77079f6f702b" - integrity sha512-b/h7CPV7QEdrqIxtAf2j31U5ef05uBDuvoXv6L51Q4rcS1jdlXAVKJv+atCFdUXYl9dyTHGyoMzIepwowRJjFw== +postcss-value-parser@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" + integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== + +postcss@8: + version "8.4.8" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.8.tgz#dad963a76e82c081a0657d3a2f3602ce10c2e032" + integrity sha512-2tXEqGxrjvAO6U+CJzDL2Fk2kPHTv1jQsYkSoMeOis2SsYaXRO2COxTdQp99cYvif9JTXaAk9lYGc3VhJt7JPQ== dependencies: - colorette "^1.2.2" - nanoid "^3.1.22" - source-map "^0.6.1" + nanoid "^3.3.1" + picocolors "^1.0.0" + source-map-js "^1.0.2" postcss@^5.2.5: version "5.2.18" @@ -3664,15 +3708,6 @@ postcss@^7.0.14: source-map "^0.6.1" supports-color "^6.1.0" -postcss@^7.0.32: - version "7.0.32" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.32.tgz#4310d6ee347053da3433db2be492883d62cec59d" - integrity sha512-03eXong5NLnNCD05xscnGKGDZ98CyzoqPSMjOe6SuoQY7Z2hIj0Ld1g/O/UQRuOle2aRtiIRDg9tDcTGAkLfKw== - dependencies: - chalk "^2.4.2" - source-map "^0.6.1" - supports-color "^6.1.0" - postcss@^8.2.4: version "8.3.5" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.3.5.tgz#982216b113412bc20a86289e91eb994952a5b709" @@ -4242,6 +4277,15 @@ sass@^1.18.0, sass@^1.x: dependencies: chokidar ">=3.0.0 <4.0.0" +sass@^1.49.9: + version "1.49.9" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.49.9.tgz#b15a189ecb0ca9e24634bae5d1ebc191809712f9" + integrity sha512-YlYWkkHP9fbwaFRZQRXgDi3mXZShslVmmo+FVK3kHLUELHHEYrCmL1x6IUjC7wLS6VuJSAFXRQS/DxdsC4xL1A== + dependencies: + chokidar ">=3.0.0 <4.0.0" + immutable "^4.0.0" + source-map-js ">=0.6.2 <2.0.0" + sax@^1.2.4, sax@~1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" @@ -4422,6 +4466,11 @@ sortablejs@^1.7.0: resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.8.3.tgz#5ae908ef96300966e95440a143340f5dd565a0df" integrity sha512-AftvD4hdKcR5QlGi7L/JST506zGNGrysE8/QohDpwKXJarHWqCt+TUlrtoMk/wkECB607Q019/OZlJViyWiD6A== +"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" + integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== + source-map-js@^0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-0.6.2.tgz#0bb5de631b41cfbda6cfba8bd05a80efdfd2385e"