瀏覽代碼

Merge branch 'develop' into fix-theme-switcher

version-14
Suraj Shetty 3 年之前
committed by GitHub
父節點
當前提交
ba92f04420
沒有發現已知的金鑰在資料庫的簽署中 GPG Key ID: 4AEE18F83AFDEB23
共有 89 個文件被更改,包括 1357 次插入614 次删除
  1. +35
    -0
      cypress/integration/list_paging.js
  2. +22
    -0
      cypress/integration/number_card.js
  3. +0
    -31
      cypress/integration/report_view.js
  4. +1
    -1
      esbuild/esbuild.js
  5. +2
    -1
      esbuild/frappe-html.js
  6. +22
    -6
      frappe/__init__.py
  7. +1
    -0
      frappe/boot.py
  8. +9
    -7
      frappe/commands/site.py
  9. +39
    -0
      frappe/core/doctype/communication/communication.py
  10. +100
    -22
      frappe/core/doctype/communication/email.py
  11. +1
    -1
      frappe/core/doctype/data_export/exporter.py
  12. +14
    -0
      frappe/core/doctype/doctype/doctype.js
  13. +8
    -3
      frappe/core/doctype/doctype/doctype.json
  14. +19
    -3
      frappe/core/doctype/doctype/doctype.py
  15. +20
    -3
      frappe/core/doctype/doctype/test_doctype.py
  16. +1
    -1
      frappe/core/doctype/role/role.py
  17. +4
    -1
      frappe/core/doctype/server_script/test_server_script.py
  18. +1
    -2
      frappe/core/doctype/user/user.json
  19. +2
    -4
      frappe/database/database.py
  20. +4
    -0
      frappe/database/mariadb/database.py
  21. +23
    -1
      frappe/database/mariadb/schema.py
  22. +48
    -12
      frappe/database/postgres/database.py
  23. +17
    -1
      frappe/database/postgres/schema.py
  24. +80
    -0
      frappe/database/sequence.py
  25. +34
    -0
      frappe/desk/doctype/number_card/number_card.js
  26. +10
    -1
      frappe/desk/doctype/number_card/number_card.json
  27. +8
    -0
      frappe/desk/doctype/number_card/number_card.py
  28. +3
    -2
      frappe/desk/doctype/system_console/system_console.js
  29. +11
    -1
      frappe/desk/doctype/system_console/system_console.py
  30. +1
    -0
      frappe/desk/doctype/workspace/workspace.py
  31. +25
    -16
      frappe/desk/form/linked_with.py
  32. +2
    -2
      frappe/desk/form/load.py
  33. +11
    -36
      frappe/desk/query_report.py
  34. +1
    -1
      frappe/desk/search.py
  35. +2
    -2
      frappe/email/doctype/auto_email_report/auto_email_report.py
  36. +1
    -2
      frappe/email/doctype/newsletter/newsletter.json
  37. +1
    -1
      frappe/email/doctype/newsletter/test_newsletter.py
  38. +4
    -3
      frappe/email/doctype/notification/notification.py
  39. +1
    -7
      frappe/email/email_body.py
  40. +7
    -2
      frappe/event_streaming/doctype/event_update_log/event_update_log.py
  41. +3
    -2
      frappe/frappeclient.py
  42. +1
    -1
      frappe/installer.py
  43. +1
    -1
      frappe/model/base_document.py
  44. +63
    -22
      frappe/model/db_query.py
  45. +1
    -1
      frappe/model/delete_doc.py
  46. +51
    -4
      frappe/model/naming.py
  47. +2
    -2
      frappe/model/rename_doc.py
  48. +2
    -2
      frappe/modules/import_file.py
  49. +1
    -1
      frappe/public/css/tree.css
  50. +3
    -0
      frappe/public/js/frappe/form/controls/autocomplete.js
  51. +1
    -1
      frappe/public/js/frappe/form/form.js
  52. +2
    -2
      frappe/public/js/frappe/form/grid.js
  53. +9
    -98
      frappe/public/js/frappe/form/linked_with.js
  54. +24
    -3
      frappe/public/js/frappe/form/sidebar/attachments.js
  55. +12
    -4
      frappe/public/js/frappe/list/base_list.js
  56. +38
    -42
      frappe/public/js/frappe/list/list_factory.js
  57. +8
    -21
      frappe/public/js/frappe/list/list_view.js
  58. +1
    -0
      frappe/public/js/frappe/microtemplate.js
  59. +7
    -5
      frappe/public/js/frappe/model/model.js
  60. +11
    -1
      frappe/public/js/frappe/ui/messages.js
  61. +9
    -0
      frappe/public/js/frappe/utils/utils.js
  62. +16
    -13
      frappe/public/js/frappe/views/factory.js
  63. +99
    -84
      frappe/public/js/frappe/views/reports/print_tree.html
  64. +4
    -3
      frappe/public/js/frappe/views/reports/report_view.js
  65. +20
    -2
      frappe/query_builder/functions.py
  66. +2
    -1
      frappe/realtime.py
  67. +0
    -1
      frappe/templates/emails/standard.html
  68. +47
    -0
      frappe/tests/test_db.py
  69. +21
    -0
      frappe/tests/test_db_query.py
  70. +2
    -2
      frappe/tests/test_form_load.py
  71. +11
    -0
      frappe/tests/test_naming.py
  72. +46
    -1
      frappe/tests/test_query_builder.py
  73. +9
    -16
      frappe/tests/test_query_report.py
  74. +7
    -4
      frappe/tests/ui_test_helpers.py
  75. +83
    -19
      frappe/translations/de.csv
  76. +5
    -1
      frappe/utils/background_jobs.py
  77. +1
    -3
      frappe/utils/backups.py
  78. +1
    -1
      frappe/utils/data.py
  79. +6
    -5
      frappe/utils/diff.py
  80. +3
    -1
      frappe/utils/global_search.py
  81. +1
    -2
      frappe/website/doctype/blog_post/blog_post.json
  82. +2
    -1
      frappe/website/doctype/web_form/templates/web_form.html
  83. +16
    -5
      frappe/website/doctype/web_form/web_form.py
  84. +1
    -2
      frappe/website/doctype/web_page/web_page.json
  85. +1
    -2
      frappe/website/doctype/website_settings/website_settings.json
  86. +1
    -1
      frappe/www/error.py
  87. +4
    -2
      package.json
  88. +1
    -0
      requirements.txt
  89. +102
    -53
      yarn.lock

+ 35
- 0
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');
});
});

+ 22
- 0
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();
});
});

+ 0
- 31
cypress/integration/report_view.js 查看文件

@@ -13,9 +13,6 @@ context('Report View', () => {
'enabled': 0, 'enabled': 0,
'docstatus': 1 // submit document 'docstatus': 1 // submit document
}, true); }, 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.', () => { it('Field with enabled allow_on_submit should be editable.', () => {
@@ -43,32 +40,4 @@ context('Report View', () => {
expect(r.message.enabled).to.equals(1); 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');
});
}); });

+ 1
- 1
esbuild/esbuild.js 查看文件

@@ -9,7 +9,7 @@ const cliui = require("cliui")();
const chalk = require("chalk"); const chalk = require("chalk");
const html_plugin = require("./frappe-html"); const html_plugin = require("./frappe-html");
const rtlcss = require('rtlcss'); 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 ignore_assets = require("./ignore-assets");
const sass_options = require("./sass_options"); const sass_options = require("./sass_options");
const build_cleanup_plugin = require("./build-cleanup"); const build_cleanup_plugin = require("./build-cleanup");


+ 2
- 1
esbuild/frappe-html.js 查看文件

@@ -20,7 +20,8 @@ module.exports = {
.then(content => { .then(content => {
content = scrub_html_template(content); content = scrub_html_template(content);
return { return {
contents: `\n\tfrappe.templates['${filename}'] = \`${content}\`;\n`
contents: `\n\tfrappe.templates['${filename}'] = \`${content}\`;\n`,
watchFiles: [filepath]
}; };
}) })
.catch(() => { .catch(() => {


+ 22
- 6
frappe/__init__.py 查看文件

@@ -35,6 +35,7 @@ from frappe.query_builder import (
patch_query_execute, patch_query_execute,
patch_query_aggregation, patch_query_aggregation,
) )
from frappe.utils.data import cstr


__version__ = '14.0.0-dev' __version__ = '14.0.0-dev'


@@ -214,6 +215,7 @@ def init(site, sites_path=None, new_site=False):
local.cache = {} local.cache = {}
local.document_cache = {} local.document_cache = {}
local.meta_cache = {} local.meta_cache = {}
local.autoincremented_status_map = {site: -1}
local.form_dict = _dict() local.form_dict = _dict()
local.session = _dict() local.session = _dict()
local.dev_server = _dev_server 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) return frappe.client.set_value(doctype, docname, fieldname, value)


def get_cached_doc(*args, **kwargs): 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 # local cache
doc = local.document_cache.get(key) doc = local.document_cache.get(key)
if doc: if doc:
@@ -869,8 +870,24 @@ def get_cached_doc(*args, **kwargs):


return doc 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): def get_document_cache_key(doctype, name):
return '{0}::{1}'.format(doctype, name)
return f'{doctype}::{name}'


def clear_document_cache(doctype, name): def clear_document_cache(doctype, name):
cache().hdel("last_modified", doctype) cache().hdel("last_modified", doctype)
@@ -911,8 +928,7 @@ def get_doc(*args, **kwargs):
doc = frappe.model.document.get_doc(*args, **kwargs) doc = frappe.model.document.get_doc(*args, **kwargs)


# set in cache # 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 local.document_cache[key] = doc
cache().hset('document_cache', key, doc.as_dict()) cache().hset('document_cache', key, doc.as_dict())


@@ -1001,7 +1017,7 @@ def get_module(modulename):


def scrub(txt): def scrub(txt):
"""Returns sluggified string. e.g. `Sales Order` becomes `sales_order`.""" """Returns sluggified string. e.g. `Sales Order` becomes `sales_order`."""
return txt.replace(' ', '_').replace('-', '_').lower()
return cstr(txt).replace(' ', '_').replace('-', '_').lower()


def unscrub(txt): def unscrub(txt):
"""Returns titlified string. e.g. `sales_order` becomes `Sales Order`.""" """Returns titlified string. e.g. `sales_order` becomes `Sales Order`."""


+ 1
- 0
frappe/boot.py 查看文件

@@ -325,6 +325,7 @@ def get_desk_settings():
def get_notification_settings(): def get_notification_settings():
return frappe.get_cached_doc('Notification Settings', frappe.session.user) return frappe.get_cached_doc('Notification Settings', frappe.session.user)


@frappe.whitelist()
def get_link_title_doctypes(): def get_link_title_doctypes():
dts = frappe.get_all("DocType", {"show_title_field_in_link": 1}) dts = frappe.get_all("DocType", {"show_title_field_in_link": 1})
custom_dts = frappe.get_all( custom_dts = frappe.get_all(


+ 9
- 7
frappe/commands/site.py 查看文件

@@ -1,7 +1,7 @@
# imports - standard imports # imports - standard imports
import os import os
import sys
import shutil import shutil
import sys


# imports - third party imports # imports - third party imports
import click 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" "Restore site database from an sql file"
from frappe.installer import ( from frappe.installer import (
_new_site, _new_site,
extract_sql_from_archive,
extract_files, extract_files,
extract_sql_from_archive,
is_downgrade, is_downgrade,
is_partial, is_partial,
validate_database_sql
validate_database_sql,
) )
from frappe.utils.backups import Backup from frappe.utils.backups import Backup
if not os.path.exists(sql_file_path): 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') @click.option('--encryption-key', help='Backup encryption key')
@pass_context @pass_context
def partial_restore(context, sql_file_path, verbose, encryption_key=None): 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 from frappe.utils.backups import Backup


if not os.path.exists(sql_file_path): if not os.path.exists(sql_file_path):
@@ -545,7 +545,7 @@ def _use(site, sites_path='.'):


def use(site, sites_path='.'): def use(site, sites_path='.'):
if os.path.exists(os.path.join(sites_path, site)): 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) sitefile.write(site)
print("Current Site set to {}".format(site)) print("Current Site set to {}".format(site))
else: 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): def set_user_password(site, user, password, logout_all_sessions=False):
import getpass import getpass

from frappe.utils.password import update_password from frappe.utils.password import update_password


try: try:
@@ -881,15 +882,16 @@ def stop_recording(context):
raise SiteNotSpecifiedError raise SiteNotSpecifiedError


@click.command('ngrok') @click.command('ngrok')
@click.option('--bind-tls', is_flag=True, default=False, help='Returns a reference to the https tunnel.')
@pass_context @pass_context
def start_ngrok(context):
def start_ngrok(context, bind_tls):
from pyngrok import ngrok from pyngrok import ngrok


site = get_site(context) site = get_site(context)
frappe.init(site=site) frappe.init(site=site)


port = frappe.conf.http_port or frappe.conf.webserver_port 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(f'Public URL: {tunnel.public_url}')
print('Inspect logs at http://localhost:4040') print('Inspect logs at http://localhost:4040')




+ 39
- 0
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.utils.user import is_system_user
from frappe.contacts.doctype.contact.contact import get_contact_name 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 frappe.automation.doctype.assignment_rule.assignment_rule import apply as apply_assignment_rule
from parse import compile


exclude_from_linked_with = True exclude_from_linked_with = True


@@ -114,6 +115,44 @@ class Communication(Document, CommunicationEmailMixin):
frappe.publish_realtime('new_message', self.as_dict(), frappe.publish_realtime('new_message', self.as_dict(),
user=self.reference_name, after_commit=True) 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('<div class="ql-editor read-mode">{}</div>')
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}</p><br><p class="signature">{signature}'

def before_save(self):
if not self.flags.skip_add_signature:
self.set_signature_in_email_content()

def on_update(self): def on_update(self):
# add to _comment property of the doctype, so it shows up in # add to _comment property of the doctype, so it shows up in
# comments count for the list view # comments count for the list view


+ 100
- 22
frappe/core/doctype/communication/email.py 查看文件

@@ -22,12 +22,30 @@ OUTGOING_EMAIL_ACCOUNT_MISSING = _("""




@frappe.whitelist() @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 doctype: Reference DocType.
:param name: Reference Document name. :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 send_me_a_copy: Send a copy to the sender (default **False**).
:param email_template: Template which is used to compose mail . :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 recipients = list_to_str(recipients) if isinstance(recipients, list) else recipients
cc = list_to_str(cc) if isinstance(cc, list) else cc cc = list_to_str(cc) if isinstance(cc, list) else cc
bcc = list_to_str(bcc) if isinstance(bcc, list) else bcc 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, "read_receipt":read_receipt,
"has_attachment": 1 if attachments else 0, "has_attachment": 1 if attachments else 0,
"communication_type": communication_type, "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 not committed, delayed task doesn't find the communication
if attachments: if attachments:
@@ -87,17 +161,21 @@ def make(doctype=None, name=None, content=None, subject=None, sent_or_received =


if cint(send_email): if cint(send_email):
if not comm.get_outgoing_email_account(): 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) 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: def validate_email(doc: "Communication") -> None:
"""Validate Email Addresses of Recipients and CC""" """Validate Email Addresses of Recipients and CC"""


+ 1
- 1
frappe/core/doctype/data_export/exporter.py 查看文件

@@ -324,7 +324,7 @@ class DataExporter:
d = doc.copy() d = doc.copy()
meta = frappe.get_meta(dt) meta = frappe.get_meta(dt)
if self.all_doctypes: if self.all_doctypes:
d.name = '"'+ d.name+'"'
d.name = f'"{d.name}"'


if len(rows) < rowidx + 1: if len(rows) < rowidx + 1:
rows.append([""] * (len(self.columns) + 1)) rows.append([""] * (len(self.columns) + 1))


+ 14
- 0
frappe/core/doctype/doctype/doctype.js 查看文件

@@ -61,6 +61,13 @@ frappe.ui.form.on('DocType', {
frm.events.set_naming_rule_description(frm); 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) { naming_rule: function(frm) {
// set the "autoname" property based on naming_rule // set the "autoname" property based on naming_rule
if (frm.doc.naming_rule && !frm.__from_autoname) { 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') { if (frm.doc.naming_rule=='Set by user') {
frm.set_value('autoname', 'Prompt'); 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') { } else if (frm.doc.naming_rule=='By fieldname') {
frm.set_value('autoname', 'field:'); frm.set_value('autoname', 'field:');
} else if (frm.doc.naming_rule=='By "Naming Series" 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) { set_naming_rule_description(frm) {
let naming_rule_description = { let naming_rule_description = {
'Set by user': '', 'Set by user': '',
'Autoincrement': 'Uses Auto Increment feature of database.<br><b>WARNING: After using this option, any other naming option will not be accessible.</b>',
'By fieldname': 'Format: <code>field:[fieldname]</code>. Valid fieldname must exist', 'By fieldname': 'Format: <code>field:[fieldname]</code>. Valid fieldname must exist',
'By "Naming Series" field': 'Format: <code>naming_series:[fieldname]</code>. Fieldname called <code>naming_series</code> must exist', 'By "Naming Series" field': 'Format: <code>naming_series:[fieldname]</code>. Fieldname called <code>naming_series</code> must exist',
'Expression': 'Format: <code>format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####}</code> - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.', 'Expression': 'Format: <code>format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####}</code> - 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; frm.__from_autoname = true;
if (frm.doc.autoname.toLowerCase() === 'prompt') { if (frm.doc.autoname.toLowerCase() === 'prompt') {
frm.set_value('naming_rule', 'Set by user'); 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:')) { } else if (frm.doc.autoname.startsWith('field:')) {
frm.set_value('naming_rule', 'By fieldname'); frm.set_value('naming_rule', 'By fieldname');
} else if (frm.doc.autoname.startsWith('naming_series:')) { } else if (frm.doc.autoname.startsWith('naming_series:')) {


+ 8
- 3
frappe/core/doctype/doctype/doctype.json 查看文件

@@ -208,7 +208,7 @@
"label": "Naming" "label": "Naming"
}, },
{ {
"description": "Naming Options:\n<ol><li><b>field:[fieldname]</b> - By Field</li><li><b>naming_series:</b> - By Naming Series (field called naming_series must be present</li><li><b>Prompt</b> - Prompt user for a name</li><li><b>[series]</b> - Series by prefix (separated by a dot); for example PRE.#####</li>\n<li><b>format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####}</b> - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.</li></ol>",
"description": "Naming Options:\n<ol><li><b>field:[fieldname]</b> - By Field</li><li><b>autoincrement</b> - Uses Databases' Auto Increment feature</li><li><b>naming_series:</b> - By Naming Series (field called naming_series must be present</li><li><b>Prompt</b> - Prompt user for a name</li><li><b>[series]</b> - Series by prefix (separated by a dot); for example PRE.#####</li>\n<li><b>format:EXAMPLE-{MM}morewords{fieldname1}-{fieldname2}-{#####}</b> - Replace all braced words (fieldnames, date words (DD, MM, YY), series) with their value. Outside braces, any characters can be used.</li></ol>",
"fieldname": "autoname", "fieldname": "autoname",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Auto Name", "label": "Auto Name",
@@ -216,6 +216,7 @@
"oldfieldtype": "Data" "oldfieldtype": "Data"
}, },
{ {
"depends_on": "eval:doc.naming_rule !== \"Autoincrement\"",
"fieldname": "name_case", "fieldname": "name_case",
"fieldtype": "Select", "fieldtype": "Select",
"label": "Name Case", "label": "Name Case",
@@ -282,6 +283,7 @@
}, },
{ {
"default": "1", "default": "1",
"depends_on": "eval:doc.naming_rule !== \"Autoincrement\"",
"fieldname": "allow_rename", "fieldname": "allow_rename",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Allow Rename", "label": "Allow Rename",
@@ -565,7 +567,7 @@
"fieldtype": "Select", "fieldtype": "Select",
"label": "Naming Rule", "label": "Naming Rule",
"length": 40, "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", "fieldname": "migration_hash",
@@ -593,6 +595,7 @@
], ],
"icon": "fa fa-bolt", "icon": "fa fa-bolt",
"idx": 6, "idx": 6,
"index_web_pages_for_search": 1,
"links": [ "links": [
{ {
"group": "Views", "group": "Views",
@@ -670,10 +673,11 @@
"link_fieldname": "reference_doctype" "link_fieldname": "reference_doctype"
} }
], ],
"modified": "2022-01-07 16:07:06.196534",
"modified": "2022-02-15 21:47:16.467217",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Core", "module": "Core",
"name": "DocType", "name": "DocType",
"naming_rule": "Set by user",
"owner": "Administrator", "owner": "Administrator",
"permissions": [ "permissions": [
{ {
@@ -703,5 +707,6 @@
"show_name_in_global_search": 1, "show_name_in_global_search": 1,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [],
"track_changes": 1 "track_changes": 1
} }

+ 19
- 3
frappe/core/doctype/doctype/doctype.py 查看文件

@@ -60,6 +60,7 @@ class DocType(Document):


self.check_developer_mode() self.check_developer_mode()


self.validate_autoname()
self.validate_name() self.validate_name()


self.set_defaults_for_single_and_table() self.set_defaults_for_single_and_table()
@@ -714,6 +715,18 @@ class DocType(Document):
self.name) self.name)
return max_idx and max_idx[0][0] or 0 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): def validate_name(self, name=None):
if not name: if not name:
name = self.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) 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 # 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) validate_route_conflict(self.doctype, self.name)




+ 20
- 3
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("8Some DocType").insert)
self.assertRaises(frappe.NameError, new_doctype("Some (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) 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): if frappe.db.exists("DocType", name):
frappe.delete_doc("DocType", name) frappe.delete_doc("DocType", name)


@@ -505,7 +505,23 @@ class TestDocType(unittest.TestCase):


dt.delete() 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({ doc = frappe.get_doc({
"doctype": "DocType", "doctype": "DocType",
"module": "Core", "module": "Core",
@@ -521,7 +537,8 @@ def new_doctype(name, unique=0, depends_on='', fields=None):
"role": "System Manager", "role": "System Manager",
"read": 1, "read": 1,
}], }],
"name": name
"name": name,
"autoname": "autoincrement" if autoincremented else ""
}) })


if fields: if fields:


+ 1
- 1
frappe/core/doctype/role/role.py 查看文件

@@ -61,7 +61,7 @@ class Role(Document):


def get_info_based_on_role(role, field='email'): def get_info_based_on_role(role, field='email'):
''' Get information of all users that have been assigned this role ''' ''' 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"]) fields=["parent as user_name"])


return get_user_info(users, field) return get_user_info(users, field)


+ 4
- 1
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') self.assertEqual(frappe.get_doc('Server Script', 'test_return_value').execute_method(), 'hello')


def test_permission_query(self): 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)) self.assertTrue(isinstance(frappe.db.get_list('ToDo'), list))


def test_attribute_error(self): def test_attribute_error(self):


+ 1
- 2
frappe/core/doctype/user/user.json 查看文件

@@ -668,8 +668,7 @@
"link_fieldname": "user" "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", "modified_by": "Administrator",
"module": "Core", "module": "Core",
"name": "User", "name": "User",


+ 2
- 4
frappe/database/database.py 查看文件

@@ -142,8 +142,6 @@ class Database(object):
self.log_query(query, values, debug, explain) self.log_query(query, values, debug, explain)


if values!=(): if values!=():
if isinstance(values, dict):
values = dict(values)


# MySQL-python==1.2.5 hack! # MySQL-python==1.2.5 hack!
if not isinstance(values, (dict, tuple, list)): if not isinstance(values, (dict, tuple, list)):
@@ -181,7 +179,7 @@ class Database(object):
print(e) print(e)
raise 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 pass
else: else:
raise raise
@@ -1028,7 +1026,7 @@ class Database(object):
return [] return []


def is_missing_table_or_column(self, e): 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): def multisql(self, sql_dict, values=(), **kwargs):
current_dialect = frappe.db.db_type or 'mariadb' current_dialect = frappe.db.db_type or 'mariadb'


+ 4
- 0
frappe/database/mariadb/database.py 查看文件

@@ -154,6 +154,10 @@ class MariaDBDatabase(Database):
def is_table_missing(e): def is_table_missing(e):
return e.args[0] == ER.NO_SUCH_TABLE return e.args[0] == ER.NO_SUCH_TABLE


@staticmethod
def is_missing_table(e):
return MariaDBDatabase.is_table_missing(e)

@staticmethod @staticmethod
def is_missing_column(e): def is_missing_column(e):
return e.args[0] == ER.BAD_FIELD_ERROR return e.args[0] == ER.BAD_FIELD_ERROR


+ 23
- 1
frappe/database/mariadb/schema.py 查看文件

@@ -1,12 +1,16 @@
import frappe import frappe
from frappe import _ from frappe import _
from frappe.database.schema import DBTable from frappe.database.schema import DBTable
from frappe.database.sequence import create_sequence
from frappe.model import log_types



class MariaDBTable(DBTable): class MariaDBTable(DBTable):
def create(self): def create(self):
additional_definitions = "" additional_definitions = ""
engine = self.meta.get("engine") or "InnoDB" engine = self.meta.get("engine") or "InnoDB"
varchar_len = frappe.db.VARCHAR_LEN varchar_len = frappe.db.VARCHAR_LEN
name_column = f"name varchar({varchar_len}) primary key"


# columns # columns
column_defs = self.get_column_definitions() column_defs = self.get_column_definitions()
@@ -29,9 +33,27 @@ class MariaDBTable(DBTable):
) )
) + ',\n' ) + ',\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 # create table
query = f"""create table `{self.table_name}` ( query = f"""create table `{self.table_name}` (
name varchar({varchar_len}) not null primary key,
{name_column},
creation datetime(6), creation datetime(6),
modified datetime(6), modified datetime(6),
modified_by varchar({varchar_len}), modified_by varchar({varchar_len}),


+ 48
- 12
frappe/database/postgres/database.py 查看文件

@@ -99,16 +99,13 @@ class PostgresDatabase(Database):
return db_size[0].get('database_size') return db_size[0].get('database_size')


# pylint: disable=W0221 # 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): def get_tables(self, cached=True):
return [d[0] for d in self.sql("""select table_name return [d[0] for d in self.sql("""select table_name
@@ -153,6 +150,10 @@ class PostgresDatabase(Database):
def is_table_missing(e): def is_table_missing(e):
return getattr(e, 'pgcode', None) == '42P01' return getattr(e, 'pgcode', None) == '42P01'


@staticmethod
def is_missing_table(e):
return PostgresDatabase.is_table_missing(e)

@staticmethod @staticmethod
def is_missing_column(e): def is_missing_column(e):
return getattr(e, 'pgcode', None) == '42703' return getattr(e, 'pgcode', None) == '42703'
@@ -335,12 +336,47 @@ def modify_query(query):
query = replace_locate_with_strpos(query) query = replace_locate_with_strpos(query)
# select from requires "" # select from requires ""
if re.search('from tab', query, flags=re.IGNORECASE): 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 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): def replace_locate_with_strpos(query):
# strpos is the locate equivalent in postgres # strpos is the locate equivalent in postgres
if re.search(r'locate\(', query, flags=re.IGNORECASE): 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 return query

+ 17
- 1
frappe/database/postgres/schema.py 查看文件

@@ -2,10 +2,14 @@ import frappe
from frappe import _ from frappe import _
from frappe.utils import cint, flt from frappe.utils import cint, flt
from frappe.database.schema import DBTable, get_definition from frappe.database.schema import DBTable, get_definition
from frappe.database.sequence import create_sequence
from frappe.model import log_types



class PostgresTable(DBTable): class PostgresTable(DBTable):
def create(self): def create(self):
varchar_len = frappe.db.VARCHAR_LEN varchar_len = frappe.db.VARCHAR_LEN
name_column = f"name varchar({varchar_len}) primary key"


additional_definitions = "" additional_definitions = ""
# columns # 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 # create table
frappe.db.sql(f"""create table `{self.table_name}` ( frappe.db.sql(f"""create table `{self.table_name}` (
name varchar({varchar_len}) not null primary key,
{name_column},
creation timestamp(6), creation timestamp(6),
modified timestamp(6), modified timestamp(6),
modified_by varchar({varchar_len}), modified_by varchar({varchar_len}),


+ 80
- 0
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})")

+ 34
- 0
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('render_filters_table');
} }
frm.trigger('create_add_to_dashboard_button'); frm.trigger('create_add_to_dashboard_button');
frm.trigger('set_parent_document_type');
}, },


create_add_to_dashboard_button: function(frm) { 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('filters_json', '[]');
frm.set_value('dynamic_filters_json', '[]'); frm.set_value('dynamic_filters_json', '[]');
frm.set_value('aggregate_function_based_on', ''); frm.set_value('aggregate_function_based_on', '');
frm.set_value('parent_document_type', '');
frm.trigger('set_options'); frm.trigger('set_options');
frm.trigger('set_parent_document_type');
}, },


set_options: function(frm) { set_options: function(frm) {
@@ -317,6 +320,7 @@ frappe.ui.form.on('Number Card', {
frm.filter_group = new frappe.ui.FilterGroup({ frm.filter_group = new frappe.ui.FilterGroup({
parent: dialog.get_field('filter_area').$wrapper, parent: dialog.get_field('filter_area').$wrapper,
doctype: frm.doc.document_type, doctype: frm.doc.document_type,
parent_doctype: frm.doc.parent_document_type,
on_change: () => {}, on_change: () => {},
}); });
filters && frm.filter_group.add_filters_to_filter_group(filters); 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); 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);
}
}
} }


}); });

+ 10
- 1
frappe/desk/doctype/number_card/number_card.json 查看文件

@@ -16,6 +16,7 @@
"aggregate_function_based_on", "aggregate_function_based_on",
"column_break_2", "column_break_2",
"document_type", "document_type",
"parent_document_type",
"report_field", "report_field",
"report_function", "report_function",
"is_public", "is_public",
@@ -188,10 +189,17 @@
"label": "Function", "label": "Function",
"mandatory_depends_on": "eval: doc.type == 'Report'", "mandatory_depends_on": "eval: doc.type == 'Report'",
"options": "Sum\nAverage\nMinimum\nMaximum" "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": [], "links": [],
"modified": "2020-07-23 11:11:03.391719",
"modified": "2022-03-10 15:34:38.210910",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Desk", "module": "Desk",
"name": "Number Card", "name": "Number Card",
@@ -234,6 +242,7 @@
"search_fields": "label, document_type", "search_fields": "label, document_type",
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [],
"title_field": "label", "title_field": "label",
"track_changes": 1 "track_changes": 1
} }

+ 8
- 0
frappe/desk/doctype/number_card/number_card.py 查看文件

@@ -3,6 +3,7 @@
# License: MIT. See LICENSE # License: MIT. See LICENSE


import frappe import frappe
from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import cint from frappe.utils import cint
from frappe.model.naming import append_number_if_name_exists 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): if frappe.db.exists("Number Card", self.name):
self.name = append_number_if_name_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): def on_update(self):
if frappe.conf.developer_mode and self.is_standard: if frappe.conf.developer_mode and self.is_standard:
export_to_files(record_list=[['Number Card', self.name]], record_module=self.module) export_to_files(record_list=[['Number Card', self.name]], record_module=self.module)


+ 3
- 2
frappe/desk/doctype/system_console/system_console.js 查看文件

@@ -88,15 +88,16 @@ frappe.ui.form.on('System Console', {
<td>${row.Progress}</td> <td>${row.Progress}</td>
</tr>` </tr>`
} }

frm.get_field('processlist').html(` frm.get_field('processlist').html(`
<p class='text-muted'>Requested on: ${timestamp}</p> <p class='text-muted'>Requested on: ${timestamp}</p>
<table class='table-bordered' style='width: 100%'> <table class='table-bordered' style='width: 100%'>
<thead><tr> <thead><tr>
<th width='10%'>Id</ht>
<th width='5%'>Id</ht>
<th width='10%'>Time</ht> <th width='10%'>Time</ht>
<th width='10%'>State</ht> <th width='10%'>State</ht>
<th width='60%'>Info</ht> <th width='60%'>Info</ht>
<th width='10%'>Progress</ht>
<th width='15%'>Progress / Wait Event</ht>
</tr></thead> </tr></thead>
<tbody>${rows}</thead>`); <tbody>${rows}</thead>`);
}); });


+ 11
- 1
frappe/desk/doctype/system_console/system_console.py 查看文件

@@ -41,4 +41,14 @@ def execute_code(doc):
@frappe.whitelist() @frappe.whitelist()
def show_processlist(): def show_processlist():
frappe.only_for('System Manager') 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)

+ 1
- 0
frappe/desk/doctype/workspace/workspace.py 查看文件

@@ -277,6 +277,7 @@ def sort_page(workspace_pages, pages):
doc = frappe.get_doc('Workspace', page.name) doc = frappe.get_doc('Workspace', page.name)
doc.sequence_id = seq + 1 doc.sequence_id = seq + 1
doc.parent_page = d.get('parent_page') or "" doc.parent_page = d.get('parent_page') or ""
doc.flags.ignore_links = True
doc.save(ignore_permissions=True) doc.save(ignore_permissions=True)
break break




+ 25
- 16
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 # License: MIT. See LICENSE

import json import json
from collections import defaultdict from collections import defaultdict
import itertools import itertools
from typing import List
from typing import Dict, List, Optional


import frappe import frappe
import frappe.desk.form.load import frappe.desk.form.load
@@ -367,7 +368,7 @@ def get_exempted_doctypes():




@frappe.whitelist() @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): if isinstance(linkinfo, str):
# additional fields are added in linkinfo # additional fields are added in linkinfo
linkinfo = json.loads(linkinfo) linkinfo = json.loads(linkinfo)
@@ -377,23 +378,21 @@ def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None):
if not linkinfo: if not linkinfo:
return results 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(): for dt, link in linkinfo.items():
filters = [] filters = []
link["doctype"] = dt 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] linkmeta = link_meta_bundle[0]

if not linkmeta.has_permission():
continue

if not linkmeta.get("issingle"): if not linkmeta.get("issingle"):
fields = [d.fieldname for d in linkmeta.get("fields", { fields = [d.fieldname for d in linkmeta.get("fields", {
"in_list_view": 1, "in_list_view": 1,
@@ -456,6 +455,13 @@ def get_linked_docs(doctype, name, linkinfo=None, for_doctype=None):


return results 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() @frappe.whitelist()
def get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False): def get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False):
"""add list of doctypes this doctype is 'linked' with. """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: else:
return frappe.cache().hget("linked_doctypes", doctype, lambda: _get_linked_doctypes(doctype)) return frappe.cache().hget("linked_doctypes", doctype, lambda: _get_linked_doctypes(doctype))



def _get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False): def _get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False):
ret = {} ret = {}
# find fields where this doctype is linked # find fields where this doctype is linked
@@ -499,6 +506,7 @@ def _get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False)


return ret return ret



def get_linked_fields(doctype, without_ignore_user_permissions_enabled=False): def get_linked_fields(doctype, without_ignore_user_permissions_enabled=False):


filters = [['fieldtype','=', 'Link'], ['options', '=', doctype]] filters = [['fieldtype','=', 'Link'], ['options', '=', doctype]]
@@ -529,6 +537,7 @@ def get_linked_fields(doctype, without_ignore_user_permissions_enabled=False):


return ret return ret



def get_dynamic_linked_fields(doctype, without_ignore_user_permissions_enabled=False): def get_dynamic_linked_fields(doctype, without_ignore_user_permissions_enabled=False):
ret = {} ret = {}




+ 2
- 2
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.model.utils.user_settings import get_user_settings
from frappe.permissions import get_doc_permissions from frappe.permissions import get_doc_permissions
from frappe.desk.form.document_follow import is_document_followed from frappe.desk.form.document_follow import is_document_followed
from frappe.utils.data import cstr
from frappe import _ from frappe import _
from frappe import _dict from frappe import _dict
from urllib.parse import quote from urllib.parse import quote
@@ -124,7 +125,6 @@ def get_docinfo(doc=None, doctype=None, name=None):
update_user_info(docinfo) update_user_info(docinfo)


frappe.response["docinfo"] = docinfo frappe.response["docinfo"] = docinfo
return docinfo


def add_comments(doc, docinfo): def add_comments(doc, docinfo):
# divide comments into separate lists # divide comments into separate lists
@@ -356,7 +356,7 @@ def get_document_email(doctype, name):
return None return None


email = email.split("@") 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(): def get_automatic_email_link():
return frappe.db.get_value("Email Account", {"enable_incoming": 1, "enable_automatic_linking": 1}, "email_id") return frappe.db.get_value("Email Account", {"enable_incoming": 1, "enable_automatic_linking": 1}, "email_id")


+ 11
- 36
frappe/desk/query_report.py 查看文件

@@ -352,14 +352,10 @@ def export_query():
) )
return return


columns = get_columns_dict(data.columns)

from frappe.utils.xlsxutils import make_xlsx 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) xlsx_file = make_xlsx(xlsx_data, "Query Report", column_widths=column_widths)


frappe.response["filename"] = report_name + ".xlsx" frappe.response["filename"] = report_name + ".xlsx"
@@ -367,39 +363,18 @@ def export_query():
frappe.response["type"] = "binary" 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 = [[]] result = [[]]
column_widths = [] column_widths = []




+ 1
- 1
frappe/desk/search.py 查看文件

@@ -257,7 +257,7 @@ def scrub_custom_query(query, key, txt):
def relevance_sorter(key, query, as_dict): def relevance_sorter(key, query, as_dict):
value = _(key.name if as_dict else key[0]) value = _(key.name if as_dict else key[0])
return ( return (
value.lower().startswith(query.lower()) is not True,
cstr(value).lower().startswith(query.lower()) is not True,
value value
) )




+ 2
- 2
frappe/email/doctype/auto_email_report/auto_email_report.py 查看文件

@@ -104,7 +104,7 @@ class AutoEmailReport(Document):
report_data['columns'] = columns report_data['columns'] = columns
report_data['result'] = data 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) xlsx_file = make_xlsx(xlsx_data, "Auto Email Report", column_widths=column_widths)
return xlsx_file.getvalue() return xlsx_file.getvalue()


@@ -113,7 +113,7 @@ class AutoEmailReport(Document):
report_data['columns'] = columns report_data['columns'] = columns
report_data['result'] = data 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) return to_csv(xlsx_data)


else: else:


+ 1
- 2
frappe/email/doctype/newsletter/newsletter.json 查看文件

@@ -236,8 +236,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_published_field": "published", "is_published_field": "published",
"links": [], "links": [],
"max_attachments": 3,
"modified": "2021-12-06 20:09:37.963141",
"modified": "2022-03-09 01:48:16.741603",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Email", "module": "Email",
"name": "Newsletter", "name": "Newsletter",


+ 1
- 1
frappe/email/doctype/newsletter/test_newsletter.py 查看文件

@@ -51,7 +51,7 @@ class TestNewsletterMixin:
"reference_name": newsletter, "reference_name": newsletter,
}) })
frappe.delete_doc("Newsletter", newsletter) frappe.delete_doc("Newsletter", newsletter)
frappe.db.delete("Newsletter Email Group", newsletter)
frappe.db.delete("Newsletter Email Group", {"parent": newsletter})
newsletters.remove(newsletter) newsletters.remove(newsletter)


def setup_email_group(self): def setup_email_group(self):


+ 4
- 3
frappe/email/doctype/notification/notification.py 查看文件

@@ -186,7 +186,7 @@ def get_context(context):


def send_an_email(self, doc, context): def send_an_email(self, doc, context):
from email.utils import formataddr 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 subject = self.subject
if "{" in subject: if "{" in subject:
subject = frappe.render_template(self.subject, context) subject = frappe.render_template(self.subject, context)
@@ -216,7 +216,8 @@ def get_context(context):
# Add mail notification to communication list # Add mail notification to communication list
# No need to add if it is already a communication. # No need to add if it is already a communication.
if doc.doctype != 'Communication': if doc.doctype != 'Communication':
make_communication(doctype=doc.doctype,
make_communication(
doctype=doc.doctype,
name=doc.name, name=doc.name,
content=message, content=message,
subject=subject, subject=subject,
@@ -228,7 +229,7 @@ def get_context(context):
cc=cc, cc=cc,
bcc=bcc, bcc=bcc,
communication_type='Automated Message', communication_type='Automated Message',
ignore_permissions=True)
)


def send_a_slack_msg(self, doc, context): def send_a_slack_msg(self, doc, context):
send_slack_message( send_slack_message(


+ 1
- 7
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) email_account = email_account or EmailAccount.find_outgoing(match_by_email=sender)


signature = None
if "<!-- signature-included -->" not in message:
signature = get_signature(email_account)

rendered_email = frappe.get_template("templates/emails/standard.html").render({ rendered_email = frappe.get_template("templates/emails/standard.html").render({
"brand_logo": get_brand_logo(email_account) if with_container or header else None, "brand_logo": get_brand_logo(email_account) if with_container or header else None,
"with_container": with_container, "with_container": with_container,
"site_url": get_url(), "site_url": get_url(),
"header": get_header(header), "header": get_header(header),
"content": message, "content": message,
"signature": signature,
"footer": get_footer(email_account, footer), "footer": get_footer(email_account, footer),
"title": subject, "title": subject,
"print_html": print_html, "print_html": print_html,
@@ -281,8 +276,7 @@ def get_formatted_html(subject, message, footer=None, print_html=None,
if unsubscribe_link: if unsubscribe_link:
html = html.replace("<!--unsubscribe link here-->", unsubscribe_link.html) html = html.replace("<!--unsubscribe link here-->", unsubscribe_link.html)


html = inline_style_in_html(html)
return html
return inline_style_in_html(html)


@frappe.whitelist() @frappe.whitelist()
def get_email_html(template, args, subject, header=None, with_container=False): def get_email_html(template, args, subject, header=None, with_container=False):


+ 7
- 2
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 SELECT
update_log.name update_log.name
FROM `tabEvent Update Log` update_log 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 WHERE
consumer.consumer = %(consumer)s consumer.consumer = %(consumer)s
AND update_log.ref_doctype = %(dt)s AND update_log.ref_doctype = %(dt)s
AND update_log.docname = %(dn)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( logs = frappe.get_all(
'Event Update Log', 'Event Update Log',


+ 3
- 2
frappe/frappeclient.py 查看文件

@@ -7,6 +7,7 @@ import json
import requests import requests


import frappe import frappe
from frappe.utils.data import cstr




class AuthError(Exception): class AuthError(Exception):
@@ -122,7 +123,7 @@ class FrappeClient(object):
'''Update a remote document '''Update a remote document


:param doc: dict or Document object to be updated remotely. `name` is mandatory for this''' :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) res = self.session.put(url, data={"data":frappe.as_json(doc)}, verify=self.verify, headers=self.headers)
return frappe._dict(self.post_process(res)) return frappe._dict(self.post_process(res))


@@ -207,7 +208,7 @@ class FrappeClient(object):
if fields: if fields:
params["fields"] = json.dumps(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) params=params, verify=self.verify, headers=self.headers)


return self.post_process(res) return self.post_process(res)


+ 1
- 1
frappe/installer.py 查看文件

@@ -611,7 +611,7 @@ def is_downgrade(sql_file_path, verbose=False):
downgrade = backup_version > current_version downgrade = backup_version > current_version


if verbose and downgrade: 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 return downgrade




+ 1
- 1
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) 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 # don't update name, as case might've been changed
name = d['name']
name = cstr(d['name'])
del d['name'] del d['name']


columns = list(d) columns = list(d)


+ 63
- 22
frappe/model/db_query.py 查看文件

@@ -164,7 +164,8 @@ class DatabaseQuery(object):


# left join parent, child tables # left join parent, child tables
for child in self.tables[1:]: 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: if self.grouped_or_conditions:
self.conditions.append(f"({' or '.join(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 # add tables from fields
if self.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 continue


table_name = field.split('.')[0] table_name = field.split('.')[0]


if table_name.lower().startswith('group_concat('): if table_name.lower().startswith('group_concat('):
table_name = table_name[13:] table_name = table_name[13:]
if table_name.lower().startswith('ifnull('):
table_name = table_name[7:]
if not table_name[0]=='`': if not table_name[0]=='`':
table_name = f"`{table_name}`" table_name = f"`{table_name}`"
if table_name not in self.tables: if table_name not in self.tables:
self.append_table(table_name) 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): def append_table(self, table_name):
self.tables.append(table_name) self.tables.append(table_name)
doctype = table_name[4:-1] doctype = table_name[4:-1]
@@ -423,6 +463,8 @@ class DatabaseQuery(object):
ifnull(`tabDocType`.`fieldname`, fallback) operator "value" ifnull(`tabDocType`.`fieldname`, fallback) operator "value"
""" """


# TODO: refactor

from frappe.boot import get_additional_filters_from_hooks from frappe.boot import get_additional_filters_from_hooks
additional_filters_config = get_additional_filters_from_hooks() additional_filters_config = get_additional_filters_from_hooks()
f = get_filter(self.doctype, f, additional_filters_config) f = get_filter(self.doctype, f, additional_filters_config)
@@ -432,15 +474,16 @@ class DatabaseQuery(object):
self.append_table(tname) self.append_table(tname)


if 'ifnull(' in f.fieldname: if 'ifnull(' in f.fieldname:
column_name = f.fieldname
column_name = self.cast_name(f.fieldname, "ifnull(")
else: 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: if f.operator.lower() in additional_filters_config:
f.update(get_additional_filter_field(additional_filters_config, f, f.value)) 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 # prepare in condition
if f.operator.lower() in ('ancestors of', 'descendants of', 'not ancestors of', 'not descendants of'): if f.operator.lower() in ('ancestors of', 'descendants of', 'not ancestors of', 'not descendants of'):
values = f.value or '' values = f.value or ''
@@ -449,12 +492,8 @@ class DatabaseQuery(object):
# if not isinstance(values, (list, tuple)): # if not isinstance(values, (list, tuple)):
# values = values.split(",") # 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 = '', '' lft, rgt = '', ''
if f.value: if f.value:
@@ -474,29 +513,30 @@ class DatabaseQuery(object):
}, order_by='`lft` DESC') }, order_by='`lft` DESC')


fallback = "''" 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): if len(value):
value = f"({', '.join(value)})" value = f"({', '.join(value)})"
else: else:
value = "('')" value = "('')"

# changing operator to IN as the above code fetches all the parent / child values and convert into tuple # 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. # 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' 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'): elif f.operator.lower() in ('in', 'not in'):
values = f.value or '' values = f.value or ''
if isinstance(values, str): if isinstance(values, str):
values = values.split(",") values = values.split(",")


fallback = "''" 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): if len(value):
value = f"({', '.join(value)})" value = f"({', '.join(value)})"
else: else:
value = "('')" value = "('')"

else: 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 df = df[0] if df else None


if df and df.fieldtype in ("Check", "Float", "Int", "Currency", "Percent"): 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'" fallback = "'0001-01-01 00:00:00'"


elif f.operator.lower() in ('between') and \ 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) value = get_between_date_filter(f.value, df)
fallback = "'0001-01-01 00:00:00'" fallback = "'0001-01-01 00:00:00'"
@@ -528,7 +569,7 @@ class DatabaseQuery(object):
fallback = "''" fallback = "''"
can_be_null = True can_be_null = True


if 'ifnull' not in column_name:
if 'ifnull' not in column_name.lower():
column_name = f'ifnull({column_name}, {fallback})' column_name = f'ifnull({column_name}, {fallback})'


elif df and df.fieldtype=="Date": elif df and df.fieldtype=="Date":
@@ -570,7 +611,7 @@ class DatabaseQuery(object):
value = f"{tname}.{quote}{f.value.name}{quote}" value = f"{tname}.{quote}{f.value.name}{quote}"


# escape value # 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)}" value = f"{frappe.db.escape(value, percent=False)}"


if ( if (


+ 1
- 1
frappe/model/delete_doc.py 查看文件

@@ -158,7 +158,7 @@ def update_naming_series(doc):
and getattr(doc, "naming_series", None): and getattr(doc, "naming_series", None):
revert_series_if_last(doc.naming_series, doc.name, doc) 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) revert_series_if_last(doc.meta.autoname, doc.name, doc)


def delete_from_table(doctype, name, ignore_doctypes, doc): def delete_from_table(doctype, name, ignore_doctypes, doc):


+ 51
- 4
frappe/model/naming.py 查看文件

@@ -1,14 +1,18 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE # License: MIT. See LICENSE


from typing import Optional
from typing import Optional, TYPE_CHECKING, Union
import frappe import frappe
from frappe import _ from frappe import _
from frappe.database.sequence import get_next_val, set_next_val
from frappe.utils import now_datetime, cint, cstr from frappe.utils import now_datetime, cint, cstr
import re import re
from frappe.model import log_types from frappe.model import log_types
from frappe.query_builder import DocType from frappe.query_builder import DocType


if TYPE_CHECKING:
from frappe.model.meta import Meta



def set_new_name(doc): def set_new_name(doc):
""" """
@@ -24,11 +28,16 @@ def set_new_name(doc):


doc.run_method("before_naming") 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: if autoname.lower() != "prompt" and not frappe.flags.in_import:
doc.name = None doc.name = None


if is_autoincremented(doc.doctype, meta):
doc.name = get_next_val(doc.doctype)
return

if getattr(doc, "amended_from", None): if getattr(doc, "amended_from", None):
_set_amended_name(doc) _set_amended_name(doc)
return return
@@ -64,9 +73,37 @@ def set_new_name(doc):
doc.name = validate_name( doc.name = validate_name(
doc.doctype, doc.doctype,
doc.name, 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): def set_name_from_naming_options(autoname, doc):
""" """
Get a name based on the autoname field option Get a name based on the autoname field option
@@ -284,9 +321,19 @@ def get_default_naming_series(doctype):
return None 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: if not name:
frappe.throw(_("No Name Specified for {0}").format(doctype)) 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): if name.startswith("New "+doctype):
frappe.throw(_("There were some errors setting the name, please contact the administrator"), frappe.NameError) frappe.throw(_("There were some errors setting the name, please contact the administrator"), frappe.NameError)
if case == "Title Case": if case == "Title Case":


+ 2
- 2
frappe/model/rename_doc.py 查看文件

@@ -43,8 +43,8 @@ def update_document_title(


title_field = doc.meta.get_title_field() 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: if name_updated:
docname = rename_doc(doctype=doctype, old=docname, new=updated_name, merge=merge) docname = rename_doc(doctype=doctype, old=docname, new=updated_name, merge=merge)


+ 2
- 2
frappe/modules/import_file.py 查看文件

@@ -11,7 +11,7 @@ from frappe.query_builder import DocType
from frappe.utils import get_datetime, now 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 """Calculate md5 hash of the file in binary mode


Args: Args:
@@ -99,7 +99,7 @@ def import_file_by_path(path: str,force: bool = False,data_import: bool = False,
print(f"{path} missing") print(f"{path} missing")
return return


calculated_hash = caclulate_hash(path)
calculated_hash = calculate_hash(path)


if docs: if docs:
if not isinstance(docs, list): if not isinstance(docs, list):


+ 1
- 1
frappe/public/css/tree.css 查看文件

@@ -24,7 +24,7 @@ ul.tree-children {
} }
.tree-link .node-parent, .tree-link .node-parent,
.tree-link .node-leaf { .tree-link .node-leaf {
margin-right: 5px;
margin-right: 8px;
} }
.tree-link.active i { .tree-link.active i {
color: #5e64ff; color: #5e64ff;


+ 3
- 0
frappe/public/js/frappe/form/controls/autocomplete.js 查看文件

@@ -166,6 +166,9 @@ frappe.ui.form.ControlAutocomplete = class ControlAutoComplete extends frappe.ui
} }


parse_options(options) { parse_options(options) {
if (typeof options === 'string' && options[0] === '[') {
options = frappe.utils.parse_json(options);
}
if (typeof options === 'string') { if (typeof options === 'string') {
options = options.split('\n'); options = options.split('\n');
} }


+ 1
- 1
frappe/public/js/frappe/form/form.js 查看文件

@@ -248,7 +248,7 @@ frappe.ui.form.Form = class FrappeForm {
// on main doc // on main doc
frappe.model.on(me.doctype, "*", function(fieldname, value, doc) { frappe.model.on(me.doctype, "*", function(fieldname, value, doc) {
// set input // set input
if(doc.name===me.docname) {
if (cstr(doc.name) === me.docname) {
me.dirty(); me.dirty();


let field = me.fields_dict[fieldname]; let field = me.fields_dict[fieldname];


+ 2
- 2
frappe/public/js/frappe/form/grid.js 查看文件

@@ -501,9 +501,9 @@ export default class Grid {
} }


set_column_disp(fieldname, show) { set_column_disp(fieldname, show) {
if ($.isArray(fieldname)) {
if (Array.isArray(fieldname)) {
for (let field of 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); this.set_editable_grid_column_disp(field, show);
} }
} else { } else {


+ 9
- 98
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 { frappe.ui.form.LinkedWith = class LinkedWith {

constructor(opts) { constructor(opts) {
$.extend(this, opts); $.extend(this, opts);
} }
@@ -21,29 +20,23 @@ frappe.ui.form.LinkedWith = class LinkedWith {
} }


make_dialog() { make_dialog() {

this.dialog = new frappe.ui.Dialog({ this.dialog = new frappe.ui.Dialog({
title: __("Linked With") title: __("Linked With")
}); });


this.dialog.on_page_show = () => { 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() { make_html() {
const linked_docs = this.frm.__linked_docs;

let html = ''; let html = '';
const linked_docs = this.frm.__linked_docs;
const linked_doctypes = Object.keys(linked_docs); const linked_doctypes = Object.keys(linked_docs);


if (linked_doctypes.length === 0) { if (linked_doctypes.length === 0) {
@@ -63,88 +56,6 @@ frappe.ui.form.LinkedWith = class LinkedWith {
$(this.dialog.body).html(html); $(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) { make_doc_head(heading) {
return ` return `
<header class="level list-row list-row-head text-muted small"> <header class="level list-row list-row-head text-muted small">


+ 24
- 3
frappe/public/js/frappe/form/sidebar/attachments.js 查看文件

@@ -44,8 +44,17 @@ frappe.ui.form.Attachments = class Attachments {
// add attachment objects // add attachment objects
var attachments = this.get_attachments(); var attachments = this.get_attachments();
if(attachments.length) { 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 { } else {
this.attachments_label.removeClass("has-attachments"); this.attachments_label.removeClass("has-attachments");
@@ -75,7 +84,19 @@ frappe.ui.form.Attachments = class Attachments {
remove_action = function(target_id) { remove_action = function(target_id) {
frappe.confirm(__("Are you sure you want to delete the attachment?"), frappe.confirm(__("Are you sure you want to delete the attachment?"),
function() { 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; return false;


+ 12
- 4
frappe/public/js/frappe/list/base_list.js 查看文件

@@ -760,6 +760,10 @@ class FilterArea {


const doctype_fields = this.list_view.meta.fields; const doctype_fields = this.list_view.meta.fields;
const title_field = this.list_view.meta.title_field; 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( fields = fields.concat(
doctype_fields doctype_fields
@@ -794,13 +798,17 @@ class FilterArea {
options = options.join("\n"); 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)) { if (["__default", "__global"].includes(default_value)) {
default_value = null; default_value = null;
} }

return { return {
fieldtype: fieldtype, fieldtype: fieldtype,
label: __(df.label), label: __(df.label),


+ 38
- 42
frappe/public/js/frappe/list/list_factory.js 查看文件

@@ -6,8 +6,8 @@ frappe.provide('frappe.views.list_view');
window.cur_list = null; window.cur_list = null;
frappe.views.ListFactory = class ListFactory extends frappe.views.Factory { frappe.views.ListFactory = class ListFactory extends frappe.views.Factory {
make (route) { make (route) {
var me = this;
var doctype = route[1];
const me = this;
const doctype = route[1];


// List / Gantt / Kanban / etc // List / Gantt / Kanban / etc
// File is a special view // 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); 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()) { if (this.re_route_to_view()) {
return;
return false;
} }

this.set_module_breadcrumb(); this.set_module_breadcrumb();
super.show();
}

on_show() {
this.set_cur_list(); this.set_cur_list();
cur_list && cur_list.show();
if (cur_list) cur_list.show();
} }


re_route_to_view() { 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() { set_module_breadcrumb() {
if (frappe.route_history.length > 1) { 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') { 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)) { if (frappe.module_links[module] && frappe.module_links[module].includes(doctype)) {
// save the last page from the breadcrumb was accessed // save the last page from the breadcrumb was accessed
frappe.breadcrumbs.set_doctype_module(doctype, module); frappe.breadcrumbs.set_doctype_module(doctype, module);
@@ -84,10 +82,8 @@ frappe.views.ListFactory = class ListFactory extends frappe.views.Factory {
} }


set_cur_list() { 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... // changing...
window.cur_list = null; window.cur_list = null;
} }


+ 8
- 21
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_by = this.view_user_settings.sort_by || "modified";
this.sort_order = this.view_user_settings.sort_order || "desc"; 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 // build menu items
this.menu_items = this.menu_items.concat(this.get_menu_items()); this.menu_items = this.menu_items.concat(this.get_menu_items());


// set filters from view_user_settings or list_settings
if ( if (
this.view_user_settings.filters && this.view_user_settings.filters &&
this.view_user_settings.filters.length this.view_user_settings.filters.length
) { ) {
// Priority 1: saved filters
// Priority 1: view_user_settings
const saved_filters = this.view_user_settings.filters; const saved_filters = this.view_user_settings.filters;
this.filters = this.validate_filters(saved_filters); this.filters = this.validate_filters(saved_filters);
} else { } else {
@@ -932,7 +915,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
return this.settings.get_form_link(doc); return this.settings.get_form_link(doc);
} }


const docname = doc.name.match(/[%'"#\s]/)
const docname = cstr(doc.name).match(/[%'"#\s]/)
? encodeURIComponent(doc.name) ? encodeURIComponent(doc.name)
: 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( const docnames = this.get_checked_items(true).map(
(docname) => docname.toString() (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( frappe.confirm(
__("Delete {0} items permanently?", [docnames.length], "Title of confirmation dialog"),
message,
() => { () => {
this.disable_list_update = true; this.disable_list_update = true;
bulk_operations.delete(docnames, () => { bulk_operations.delete(docnames, () => {


+ 1
- 0
frappe/public/js/frappe/microtemplate.js 查看文件

@@ -138,6 +138,7 @@ frappe.render_tree = function(opts) {
opts.base_url = frappe.urllib.get_base_url(); opts.base_url = frappe.urllib.get_base_url();
opts.landscape = false; opts.landscape = false;
opts.print_css = frappe.boot.print_css; 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 tree = frappe.render_template("print_tree", opts);
var w = window.open(); var w = window.open();




+ 7
- 5
frappe/public/js/frappe/model/model.js 查看文件

@@ -577,13 +577,15 @@ $.extend(frappe.model, {
}, },


delete_doc: function(doctype, docname, callback) { 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) { 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({ return frappe.call({
method: 'frappe.client.delete', method: 'frappe.client.delete',
args: { args: {


+ 11
- 1
frappe/public/js/frappe/ui/messages.js 查看文件

@@ -134,7 +134,17 @@ frappe.msgprint = function(msg, title, is_minimizable) {
} }


if(data.message instanceof Array) { 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); frappe.msgprint(m);
}); });
return; return;


+ 9
- 0
frappe/public/js/frappe/utils/utils.js 查看文件

@@ -196,6 +196,15 @@ Object.assign(frappe.utils, {
} }
return true; 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) { strip_whitespace: function(html) {
return (html || "").replace(/<p>\s*<\/p>/g, "").replace(/<br>(\s*<br>\s*)+/g, "<br><br>"); return (html || "").replace(/<p>\s*<\/p>/g, "").replace(/<br>(\s*<br>\s*)+/g, "<br><br>");
}, },


+ 16
- 13
frappe/public/js/frappe/views/factory.js 查看文件

@@ -10,20 +10,21 @@ frappe.views.Factory = class Factory {
} }


show() { 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 { } else {
var route = frappe.get_route();
if(route[1]) {
me.make(route);
if (this.route[1]) {
this.make(this.route);
} else { } 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) { 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({ frappe.ui.make_app_page({
parent: page, parent: page,
single_column: !double_column single_column: !double_column
}); });

frappe.container.change_to(page_name); frappe.container.change_to(page_name);
return page; return page;
} }

+ 99
- 84
frappe/public/js/frappe/views/reports/print_tree.html 查看文件

@@ -1,91 +1,106 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="">
<meta name="author" content="">
<title>{{ title }}</title>
<link href="{{ base_url }}/assets/frappe/css/bootstrap.css" rel="stylesheet">
<link type="text/css" rel="stylesheet"
href="{{ base_url }}/assets/frappe/css/font-awesome.css">
<link rel="stylesheet" type="text/css" href="{{ base_url }}/assets/frappe/css/tree.css">
<style>
{{ print_css }}
</style>
<style>
.tree.opened::before,
.tree-node.opened::before,
.tree:last-child::after,
.tree-node:last-child::after {
z-index: 1;
border-left: 1px solid #d1d8dd;
background: none;
}
.tree a,
.tree-link {
text-decoration: none;
cursor: default;
}
.tree.opened > .tree-children > .tree-node > .tree-link::before,
.tree-node.opened > .tree-children > .tree-node > .tree-link::before {
border-top: 1px solid #d1d8dd;
z-index: 1;
background: none;
}
i.fa.fa-fw.fa-folder {
z-index: 2;
position: relative;
}
.tree:last-child::after, .tree-node:last-child::after {
display: none;
}
.tree-node-toolbar {
display: none;
}
i.octicon.octicon-primitive-dot.text-extra-muted {
width: 7px;
height: 7px;
border-radius: 50%;
background: #d1d8dd;
display: inline-block;
position: relative;
z-index: 2;
}
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="">
<meta name="author" content="">
<title>{{ title }}</title>
<link href="{{ base_url }}/assets/frappe/css/bootstrap.css" rel="stylesheet">
<link type="text/css" rel="stylesheet"
href="{{ base_url }}/assets/frappe/css/font-awesome.css">
<link rel="stylesheet" type="text/css" href="{{ base_url }}/assets/frappe/css/tree.css">
<link rel="stylesheet" type="text/css" href="{{ base_url }}{{ print_format_css_path }}">
<style>
{{ print_css }}
</style>
<style>
.tree.opened::before,
.tree-node.opened::before,
.tree:last-child::after,
.tree-node:last-child::after {
z-index: 1;
border-left: 1px solid #d1d8dd;
background: none;
}
.tree a,
.tree-link {
text-decoration: none;
cursor: default;
}
.tree.opened > .tree-children > .tree-node > .tree-link::before,
.tree-node.opened > .tree-children > .tree-node > .tree-link::before {
border-top: 1px solid #d1d8dd;
z-index: 1;
background: none;
}
i.fa.fa-fw.fa-folder {
z-index: 2;
position: relative;
}
.tree:last-child::after, .tree-node:last-child::after {
display: none;
}
.tree-node-toolbar {
display: none;
}
i.octicon.octicon-primitive-dot.text-extra-muted {
width: 7px;
height: 7px;
border-radius: 50%;
background: #d1d8dd;
display: inline-block;
position: relative;
z-index: 2;
}


@media (max-width: 767px) {
ul.tree-children {
padding-left: 20px;
@media (max-width: 767px) {
ul.tree-children {
padding-left: 20px;
}
} }
}
</style>
</head>
<body>
<div class="print-format-gutter">
{% if print_settings.repeat_header_footer %}
<div id="footer-html" class="visible-pdf">
{% if print_settings.letter_head && print_settings.letter_head.footer %}
<div class="letter-head-footer">
{{ print_settings.letter_head.footer }}
</div>
{% endif %}
<p class="text-center small page-number visible-pdf">
{{ __("Page {0} of {1}", [`<span class="page"></span>`, `<span class="topage"></span>`]) }}
</p>
</div>
{% endif %}
</style>
</head>
<body>
<svg id="frappe-symbols" aria-hidden="true" style="position: absolute; width: 0; height: 0; overflow: hidden;" class="d-block" xmlns="http://www.w3.org/2000/svg">
<symbol viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg" id="icon-primitive-dot">
<path d="M9.5 6a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0z"></path>
</symbol>


<div class="print-format {% if landscape %} landscape {% endif %}">
{% if print_settings.letter_head %}
<div {% if print_settings.repeat_header_footer %} id="header-html" class="hidden-pdf" {% endif %}>
<div class="letter-head">{{ print_settings.letter_head.header }}</div>
</div>
{% endif %}
<div class="tree opened">
{{ tree }}
<symbol viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" id="icon-folder-open">
<path d="M8.024 6.5H3a.5.5 0 0 0-.5.5v8a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V9.5A.5.5 0 0 0 17 9h-6.783a.5.5 0 0 1-.417-.224L8.441 6.724a.5.5 0 0 0-.417-.224z" stroke="var(--icon-stroke)" stroke-miterlimit="10" stroke-linecap="square"></path>
<path d="M3.88 4.5v-1a.5.5 0 0 1 .5-.5h11.24a.5.5 0 0 1 .5.5V7" stroke="var(--icon-stroke)" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"></path>
</symbol>

<symbol viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" id="icon-folder-normal">
<path d="M2.5 4v10a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V6.5a1 1 0 0 0-1-1h-6.283a.5.5 0 0 1-.417-.224L8.441 3.224A.5.5 0 0 0 8.024 3H3.5a1 1 0 0 0-1 1z" stroke="var(--icon-stroke)" stroke-miterlimit="10" stroke-linecap="square"></path>
</symbol>
</svg>
<div class="print-format-gutter">
{% if print_settings.repeat_header_footer %}
<div id="footer-html" class="visible-pdf">
{% if print_settings.letter_head && print_settings.letter_head.footer %}
<div class="letter-head-footer">
{{ print_settings.letter_head.footer }}
</div>
{% endif %}
<p class="text-center small page-number visible-pdf">
{{ __("Page {0} of {1}", [`<span class="page"></span>`, `<span class="topage"></span>`]) }}
</p>
</div> </div>
</div>
</div>
</body>
{% endif %}

<div class="print-format {% if landscape %} landscape {% endif %}">
{% if print_settings.letter_head %}
<div {% if print_settings.repeat_header_footer %} id="header-html" class="hidden-pdf" {% endif %}>
<div class="letter-head">{{ print_settings.letter_head.header }}</div>
</div>
{% endif %}
<div class="tree opened">
{{ tree }}
</div>
</div>
</div>
</body>
</html> </html>

+ 4
- 3
frappe/public/js/frappe/views/reports/report_view.js 查看文件

@@ -125,11 +125,12 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView {
} }


after_render() { after_render() {
if (this.report_doc) {
this.set_dirty_state_for_custom_report();
} else {
if (!this.report_doc) {
this.save_report_settings(); this.save_report_settings();
} else if (!$.isEmptyObject(this.report_doc.json)) {
this.set_dirty_state_for_custom_report();
} }

if (!this.group_by) { if (!this.group_by) {
this.init_chart(); this.init_chart();
} }


+ 20
- 2
frappe/query_builder/functions.py 查看文件

@@ -1,5 +1,5 @@
from pypika.functions import * from pypika.functions import *
from pypika.terms import Function
from pypika.terms import Function, CustomFunction, ArithmeticExpression, Arithmetic
from frappe.query_builder.utils import ImportMapper, db_type_is from frappe.query_builder.utils import ImportMapper, db_type_is
from frappe.query_builder.custom import GROUP_CONCAT, STRING_AGG, MATCH, TO_TSVECTOR from frappe.query_builder.custom import GROUP_CONCAT, STRING_AGG, MATCH, TO_TSVECTOR
from frappe.database.query import Query from frappe.database.query import Query
@@ -25,6 +25,24 @@ Match = ImportMapper(
} }
) )


class _PostgresTimestamp(ArithmeticExpression):
def __init__(self, datepart, timepart, alias=None):
if isinstance(datepart, str):
datepart = Cast(datepart, "date")
if isinstance(timepart, str):
timepart = Cast(timepart, "time")

super().__init__(operator=Arithmetic.add,
left=datepart, right=timepart, alias=alias)


CombineDatetime = ImportMapper(
{
db_type_is.MARIADB: CustomFunction("TIMESTAMP", ["date", "time"]),
db_type_is.POSTGRES: _PostgresTimestamp,
}
)



def _aggregate(function, dt, fieldname, filters, **kwargs): def _aggregate(function, dt, fieldname, filters, **kwargs):
return ( return (
@@ -46,4 +64,4 @@ def _avg(dt, fieldname, filters=None, **kwargs):
return _aggregate(Avg, dt, fieldname, filters, **kwargs) return _aggregate(Avg, dt, fieldname, filters, **kwargs)


def _sum(dt, fieldname, filters=None, **kwargs): def _sum(dt, fieldname, filters=None, **kwargs):
return _aggregate(Sum, dt, fieldname, filters, **kwargs)
return _aggregate(Sum, dt, fieldname, filters, **kwargs)

+ 2
- 1
frappe/realtime.py 查看文件

@@ -2,6 +2,7 @@
# License: MIT. See LICENSE # License: MIT. See LICENSE


import frappe import frappe
from frappe.utils.data import cstr
import os import os
import redis import redis


@@ -118,7 +119,7 @@ def get_user_info():
} }


def get_doc_room(doctype, docname): def get_doc_room(doctype, docname):
return ''.join([frappe.local.site, ':doc:', doctype, '/', docname])
return ''.join([frappe.local.site, ':doc:', doctype, '/', cstr(docname)])


def get_user_room(user): def get_user_room(user):
return ''.join([frappe.local.site, ':user:', user]) return ''.join([frappe.local.site, ':user:', user])


+ 0
- 1
frappe/templates/emails/standard.html 查看文件

@@ -37,7 +37,6 @@
<tr> <tr>
<td valign="top"> <td valign="top">
<p>{{ content }}</p> <p>{{ content }}</p>
<p class="signature">{{ signature }}</p>
</td> </td>
</tr> </tr>
</table> </table>


+ 47
- 0
frappe/tests/test_db.py 查看文件

@@ -562,3 +562,50 @@ class TestDDLCommandsPost(unittest.TestCase):
""", """,
) )
self.assertEquals(len(indexs_in_table), 1) 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)

+ 21
- 0
frappe/tests/test_db_query.py 查看文件

@@ -494,6 +494,27 @@ class TestReportview(unittest.TestCase):
response = execute_cmd("frappe.desk.reportview.get") response = execute_cmd("frappe.desk.reportview.get")
self.assertListEqual(response["keys"], ["field_label", "field_name", "_aggregate_column", 'columns']) 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(): def add_child_table_to_blog_post():
child_table = frappe.get_doc({ child_table = frappe.get_doc({
'doctype': 'DocType', 'doctype': 'DocType',


+ 2
- 2
frappe/tests/test_form_load.py 查看文件

@@ -168,8 +168,8 @@ class TestFormLoad(unittest.TestCase):
"reference_name": note.name, "reference_name": note.name,
}).insert() }).insert()


docinfo = get_docinfo(note)
get_docinfo(note)
docinfo = frappe.response["docinfo"]


self.assertEqual(len(docinfo.comments), 1) self.assertEqual(len(docinfo.comments), 1)
self.assertIn("test", docinfo.comments[0].content) self.assertIn("test", docinfo.comments[0].content)


+ 11
- 0
frappe/tests/test_naming.py 查看文件

@@ -245,6 +245,17 @@ class TestNaming(unittest.TestCase):
}) })
self.assertRaises(frappe.ValidationError, tag.insert) 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(): def make_invalid_todo():
frappe.get_doc({ frappe.get_doc({


+ 46
- 1
frappe/tests/test_query_builder.py 查看文件

@@ -3,7 +3,7 @@ from typing import Callable


import frappe import frappe
from frappe.query_builder.custom import ConstantColumn 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.utils import db_type_is
from frappe.query_builder import Case from frappe.query_builder import Case


@@ -32,6 +32,27 @@ class TestCustomFunctionsMariaDB(unittest.TestCase):
query.get_sql(), "SELECT `name`,'John' `User` FROM `tabDocType`" 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) @run_only_if(db_type_is.POSTGRES)
class TestCustomFunctionsPostgres(unittest.TestCase): class TestCustomFunctionsPostgres(unittest.TestCase):
@@ -52,6 +73,30 @@ class TestCustomFunctionsPostgres(unittest.TestCase):
query.get_sql(), 'SELECT "name",\'John\' "User" FROM "tabDocType"' 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): class TestBuilderBase(object):
def test_adding_tabs(self): def test_adding_tabs(self):


+ 9
- 16
frappe/tests/test_query_report.py 查看文件

@@ -12,37 +12,30 @@ class TestQueryReport(unittest.TestCase):
def test_xlsx_data_with_multiple_datatypes(self): def test_xlsx_data_with_multiple_datatypes(self):
"""Test exporting report using rows with multiple datatypes (list, dict)""" """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 # Create mock data
data = frappe._dict() data = frappe._dict()
data.columns = [ 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 = [ 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 # Define the visible rows
visible_idx = [0, 2, 3] visible_idx = [0, 2, 3]
# Build the result # 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(type(xlsx_data), list)
self.assertEqual(len(xlsx_data), 4) # columns + data self.assertEqual(len(xlsx_data), 4) # columns + data
# column widths are divided by 10 to match the scale that is supported by openpyxl # 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: for row in xlsx_data:
self.assertEqual(type(row), list) self.assertEqual(type(row), list)

+ 7
- 4
frappe/tests/ui_test_helpers.py 查看文件

@@ -135,11 +135,14 @@ def create_contact_records():
insert_contact('Test Form Contact 3', '12345') insert_contact('Test Form Contact 3', '12345')


@frappe.whitelist() @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 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): def insert_contact(first_name, phone_number):
doc = frappe.get_doc({ doc = frappe.get_doc({


+ 83
- 19
frappe/translations/de.csv 查看文件

@@ -146,7 +146,7 @@ Monthly,Monatlich,
More,Weiter, More,Weiter,
More Information,Mehr Informationen, More Information,Mehr Informationen,
More...,Mehr..., More...,Mehr...,
Move,Bewegen,
Move,Verschieben,
My Account,Mein Konto, My Account,Mein Konto,
My Profile,Mein Profil, My Profile,Mein Profil,
My Settings,Meine Einstellungen, My Settings,Meine Einstellungen,
@@ -175,7 +175,7 @@ Payment Gateway,Zahlungs-Gateways,
Payment Gateway Name,Name des Zahlungsgateways, Payment Gateway Name,Name des Zahlungsgateways,
Payments,Zahlungen, Payments,Zahlungen,
Period,Periode, Period,Periode,
Pincode,Postleitzahl (PLZ),
Pincode,Postleitzahl,
Plan Name,Planname, Plan Name,Planname,
Please enable pop-ups,Bitte Pop-ups aktivieren, Please enable pop-ups,Bitte Pop-ups aktivieren,
Please select Company,Bitte Unternehmen auswählen, Please select Company,Bitte Unternehmen auswählen,
@@ -1486,7 +1486,7 @@ Linked,Verknüpft,
Linked With,Verknüpft mit, Linked With,Verknüpft mit,
Linked with {0},Verknüpft mit {0}, Linked with {0},Verknüpft mit {0},
Links,Verknüpfungen, Links,Verknüpfungen,
List,Listenansicht,
List,Liste,
List Filter,Listenfilter, List Filter,Listenfilter,
List View,Listenansicht, List View,Listenansicht,
List View Setting,Einstellungen zu Listenansicht, List View Setting,Einstellungen zu Listenansicht,
@@ -2427,7 +2427,7 @@ Sum,Summe,
Sum of {0},Summe von {0}, Sum of {0},Summe von {0},
Support Email Address Not Specified,Support-E-Mail-Adresse nicht angegeben, Support Email Address Not Specified,Support-E-Mail-Adresse nicht angegeben,
Suspend Sending,Senden unterbrechen, Suspend Sending,Senden unterbrechen,
Switch To Desk,Switch To Desk,
Switch To Desk,Zum Desk wechseln,
Symbol,Symbol, Symbol,Symbol,
Sync,Synchronisieren, Sync,Synchronisieren,
Sync on Migrate,Sync auf Migrate, Sync on Migrate,Sync auf Migrate,
@@ -2870,8 +2870,8 @@ bullhorn,Megafon,
ca-central-1,ca-central-1, ca-central-1,ca-central-1,
camera,Kamera, camera,Kamera,
cancelled this document,brach die Arbeit an diesem Dokument ab, 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-down,Winkel nach unten,
chevron-left,Winkel nach links, chevron-left,Winkel nach links,
chevron-right,Winkel nach rechts, chevron-right,Winkel nach rechts,
@@ -3431,7 +3431,7 @@ Mandatory Depends On,Obligatorisch Hängt von ab,
Map Columns,Spalten zuordnen, Map Columns,Spalten zuordnen,
Map columns from {0} to fields in {1},Ordnen Sie Spalten von {0} Feldern in {1} zu., 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, 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,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), 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, Me,Mir,
@@ -3485,7 +3485,7 @@ Page Shortcuts,Seitenkürzel,
Parent Field (Tree),Elternfeld (Baum), Parent Field (Tree),Elternfeld (Baum),
Parent Field must be a valid fieldname,Das übergeordnete Feld muss ein gültiger Feldname sein, Parent Field must be a valid fieldname,Das übergeordnete Feld muss ein gültiger Feldname sein,
Pin Globally,Global anheften, 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 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 enable pop-ups in your browser,Bitte aktivieren Sie Popups in Ihrem Browser,
Please find attached {0}: {1},Im Anhang finden Sie {0}: {1}, 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 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 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 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 atleast 2 actions,Wählen Sie mindestens 2 Aktionen aus,
Select list item,Listenelement auswählen, Select list item,Listenelement auswählen,
Select multiple list items,Wählen Sie mehrere Listenelemente aus, 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, Your Target,Dein Ziel,
"browse,","Durchsuche,", "browse,","Durchsuche,",
cancelled this document {0},stornierte dieses Dokument {0}, 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, choose an,wähle ein,
empty,leeren, empty,leeren,
of,von, of,von,
@@ -3789,14 +3789,14 @@ Reset,Zurücksetzen,
Review,Rezension, Review,Rezension,
Room,Zimmer, Room,Zimmer,
Room Type,Zimmertyp, Room Type,Zimmertyp,
Save,speichern,
Save,Speichern,
Search results for,Suchergebnisse für, Search results for,Suchergebnisse für,
Select All,Alles auswählen, Select All,Alles auswählen,
Send,Absenden, Send,Absenden,
Sending,Versand, Sending,Versand,
Server Error,Serverfehler, Server Error,Serverfehler,
Set,Menge, Set,Menge,
Setup,Einstellungen,
Setup,Einrichtung,
Setup Wizard,Setup-Assistent, Setup Wizard,Setup-Assistent,
Size,Größe, Size,Größe,
Sr,Pos, Sr,Pos,
@@ -3819,7 +3819,7 @@ Warehouse,Lager,
Welcome to {0},Willkommen auf {0}, Welcome to {0},Willkommen auf {0},
Year,Jahr, Year,Jahr,
Yearly,Jährlich, 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, You can also copy-paste this link in your browser,Sie können diese Verknüpfung in Ihren Browser kopieren,
and,und, and,und,
{0} Name,{0} Name, {0} Name,{0} Name,
@@ -3953,7 +3953,7 @@ lock,sperren,
logged in,Angemeldet, logged in,Angemeldet,
message,Mitteilung, message,Mitteilung,
module,Modul, module,Modul,
move,Bewegung,
move,verschieben,
music,Musik, music,Musik,
new,Neu, new,Neu,
now,jetzt, now,jetzt,
@@ -4135,9 +4135,9 @@ Using this console may allow attackers to impersonate you and steal your informa
yesterday,gestern, yesterday,gestern,
{0} years ago,Vor {0} Jahren, {0} years ago,Vor {0} Jahren,
New Chart,Neues Diagramm, New Chart,Neues Diagramm,
New Shortcut,Neue Verknüpfung,
New Shortcut,Neuer Schnellzugriff,
Edit Chart,Diagramm bearbeiten, Edit Chart,Diagramm bearbeiten,
Edit Shortcut,Verknüpfung bearbeiten,
Edit Shortcut,Schnellzugriff bearbeiten,
Couldn't Load Desk,Schreibtisch konnte nicht geladen werden, Couldn't Load Desk,Schreibtisch konnte nicht geladen werden,
"Something went wrong while loading Desk. <b>Please relaod the page</b>. If the problem persists, contact the Administrator","Beim Laden von Desk ist ein Fehler aufgetreten. <b>Bitte überarbeiten Sie die Seite</b> . Wenn das Problem weiterhin besteht, wenden Sie sich an den Administrator", "Something went wrong while loading Desk. <b>Please relaod the page</b>. If the problem persists, contact the Administrator","Beim Laden von Desk ist ein Fehler aufgetreten. <b>Bitte überarbeiten Sie die Seite</b> . Wenn das Problem weiterhin besteht, wenden Sie sich an den Administrator",
Customize Workspace,Arbeitsbereich anpassen, Customize Workspace,Arbeitsbereich anpassen,
@@ -4228,7 +4228,7 @@ since last month,seit letztem Monat,
since last year,seit letztem Jahr, since last year,seit letztem Jahr,
Show,Show, Show,Show,
New Number Card,Neue Zahlenkarte, 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., 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", 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?, 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, Camera,Kamera,
Invalid filter: {0},Ungültiger Filter: {0}, Invalid filter: {0},Ungültiger Filter: {0},
Let's Get Started,Lass uns anfangen, Let's Get Started,Lass uns anfangen,
Reports & Masters,Berichte &amp; Meister,
Reports & Masters,Berichte & Stammdaten,
New {0} {1} added to Dashboard {2},Neues {0} {1} zum Dashboard hinzugefügt {2}, 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} {1} created,Neue {0} {1} erstellt,
New {0} Created,Neu {0} erstellt, New {0} Created,Neu {0} erstellt,
@@ -4715,3 +4715,67 @@ Reset sorting,Sortierung zurücksetzen,
Sort Ascending,Aufsteigend sortieren, Sort Ascending,Aufsteigend sortieren,
Sort Descending,Absteigend sortieren, Sort Descending,Absteigend sortieren,
Remove column,Spalte entfernen, 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,

+ 5
- 1
frappe/utils/background_jobs.py 查看文件

@@ -57,7 +57,11 @@ def enqueue(method, queue='default', timeout=None, event=None,
# To handle older implementations # To handle older implementations
is_async = kwargs.pop('async', is_async) 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) return frappe.call(method, **kwargs)


q = get_queue(queue, is_async=is_async) q = get_queue(queue, is_async=is_async)


+ 1
- 3
frappe/utils/backups.py 查看文件

@@ -183,8 +183,6 @@ class BackupGenerator:
False, False,
) )


self.todays_date = now_datetime().strftime("%Y%m%d_%H%M%S")

if not ( if not (
self.backup_path_conf self.backup_path_conf
and self.backup_path_db and self.backup_path_db
@@ -212,7 +210,7 @@ class BackupGenerator:
partial = "-partial" if self.partial else "" partial = "-partial" if self.partial else ""
ext = "tgz" if self.compress_files else "tar" ext = "tgz" if self.compress_files else "tar"
enc = "-enc" if frappe.get_system_settings("encrypt_backup") else "" 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_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" for_db = f"{self.todays_date}-{self.site_slug}{partial}-database{enc}.sql.gz"


+ 1
- 1
frappe/utils/data.py 查看文件

@@ -1494,7 +1494,7 @@ def expand_relative_urls(html):
return html return html


def quoted(url): def quoted(url):
return cstr(quote(encode(url), safe=b"~@#$&()*!+=:;,.?/'"))
return cstr(quote(encode(cstr(url)), safe=b"~@#$&()*!+=:;,.?/'"))


def quote_urls(html): def quote_urls(html):
def _quote_url(match): def _quote_url(match):


+ 6
- 5
frappe/utils/diff.py 查看文件

@@ -1,14 +1,15 @@
import json import json
from difflib import unified_diff from difflib import unified_diff
from typing import List
from typing import List, Union


import frappe import frappe
from frappe.utils import pretty_date from frappe.utils import pretty_date
from frappe.utils.data import cstr




@frappe.whitelist() @frappe.whitelist()
def get_version_diff( 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]: ) -> List[str]:


before, before_timestamp = _get_value_from_version(from_version, fieldname) before, before_timestamp = _get_value_from_version(from_version, fieldname)
@@ -23,15 +24,15 @@ def get_version_diff(
diff = unified_diff( diff = unified_diff(
before, before,
after, after,
fromfile=from_version,
tofile=to_version,
fromfile=cstr(from_version),
tofile=cstr(to_version),
fromfiledate=before_timestamp, fromfiledate=before_timestamp,
tofiledate=after_timestamp, tofiledate=after_timestamp,
) )
return list(diff) 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 = frappe.get_list(
"Version", fields=["data", "modified"], filters={"name": version_name} "Version", fields=["data", "modified"], filters={"name": version_name}
) )


+ 3
- 1
frappe/utils/global_search.py 查看文件

@@ -9,6 +9,8 @@ import os
from frappe.utils import cint, strip_html_tags from frappe.utils import cint, strip_html_tags
from frappe.utils.html_utils import unescape_html from frappe.utils.html_utils import unescape_html
from frappe.model.base_document import get_controller from frappe.model.base_document import get_controller
from frappe.utils.data import cstr



def setup_global_search_table(): 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: if hasattr(doc, 'is_website_published') and doc.meta.allow_guest_to_view:
published = 1 if doc.is_website_published() else 0 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 '' route = doc.get('route') if doc else ''


value = dict( value = dict(


+ 1
- 2
frappe/website/doctype/blog_post/blog_post.json 查看文件

@@ -213,8 +213,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_published_field": "published", "is_published_field": "published",
"links": [], "links": [],
"max_attachments": 5,
"modified": "2021-11-23 10:42:01.759723",
"modified": "2022-03-09 01:48:25.227295",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Website", "module": "Website",
"name": "Blog Post", "name": "Blog Post",


+ 2
- 1
frappe/website/doctype/web_form/templates/web_form.html 查看文件

@@ -82,7 +82,8 @@ frappe.boot = {
time_zone: { time_zone: {
system: "{{ frappe.utils.get_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() }}" 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 // for backward compatibility of some libs
frappe.sys_defaults = frappe.boot.sysdefaults; frappe.sys_defaults = frappe.boot.sysdefaults;


+ 16
- 5
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 break


if doctype_validated: if doctype_validated:
link_options = []
link_options, filters = [], {}

if limited_to_user: 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: else:
raise frappe.PermissionError('Not Allowed, {0}'.format(doctype)) raise frappe.PermissionError('Not Allowed, {0}'.format(doctype))

+ 1
- 2
frappe/website/doctype/web_page/web_page.json 查看文件

@@ -338,8 +338,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_published_field": "published", "is_published_field": "published",
"links": [], "links": [],
"max_attachments": 20,
"modified": "2022-01-03 13:01:48.182645",
"modified": "2022-03-09 01:45:28.548671",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Website", "module": "Website",
"name": "Web Page", "name": "Web Page",


+ 1
- 2
frappe/website/doctype/website_settings/website_settings.json 查看文件

@@ -420,8 +420,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"max_attachments": 10,
"modified": "2022-02-24 15:37:22.360138",
"modified": "2022-03-09 01:47:31.094462",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Website", "module": "Website",
"name": "Website Settings", "name": "Website Settings",


+ 1
- 1
frappe/www/error.py 查看文件

@@ -8,5 +8,5 @@ def get_context(context):
if frappe.flags.in_migrate: return if frappe.flags.in_migrate: return
context.http_status_code = 500 context.http_status_code = 500


print(frappe.get_traceback().encode("utf-8"))
print(frappe.get_traceback())
return {"error": frappe.get_traceback().replace("<", "&lt;").replace(">", "&gt;") } return {"error": frappe.get_traceback().replace("<", "&lt;").replace(">", "&gt;") }

+ 4
- 2
package.json 查看文件

@@ -24,7 +24,6 @@
"@editorjs/editorjs": "2.20.0", "@editorjs/editorjs": "2.20.0",
"ace-builds": "^1.4.8", "ace-builds": "^1.4.8",
"air-datepicker": "github:frappe/air-datepicker", "air-datepicker": "github:frappe/air-datepicker",
"autoprefixer": "^9.8.6",
"awesomplete": "^1.1.5", "awesomplete": "^1.1.5",
"bootstrap": "4.5.0", "bootstrap": "4.5.0",
"cliui": "^7.0.4", "cliui": "^7.0.4",
@@ -66,14 +65,17 @@
"vuedraggable": "^2.24.3" "vuedraggable": "^2.24.3"
}, },
"devDependencies": { "devDependencies": {
"@frappe/esbuild-plugin-postcss2": "^0.1.3",
"autoprefixer": "10",
"chalk": "^2.3.2", "chalk": "^2.3.2",
"esbuild": "^0.11.21", "esbuild": "^0.11.21",
"esbuild-plugin-postcss2": "^0.0.9",
"esbuild-vue": "^0.2.0", "esbuild-vue": "^0.2.0",
"fast-glob": "^3.2.5", "fast-glob": "^3.2.5",
"launch-editor": "^2.2.1", "launch-editor": "^2.2.1",
"md5": "^2.3.0", "md5": "^2.3.0",
"postcss": "8",
"rtlcss": "^3.2.1", "rtlcss": "^3.2.1",
"sass": "^1.49.9",
"yargs": "^16.2.0" "yargs": "^16.2.0"
}, },
"snyk": true, "snyk": true,


+ 1
- 0
requirements.txt 查看文件

@@ -29,6 +29,7 @@ maxminddb-geolite2==2018.703
num2words~=0.5.10 num2words~=0.5.10
oauthlib~=3.1.0 oauthlib~=3.1.0
openpyxl~=3.0.7 openpyxl~=3.0.7
parse~=1.19.0
passlib~=1.7.4 passlib~=1.7.4
paytmchecksum~=1.7.0 paytmchecksum~=1.7.0
pdfkit~=0.6.1 pdfkit~=0.6.1


+ 102
- 53
yarn.lock 查看文件

@@ -36,6 +36,20 @@
codex-notifier "^1.1.2" codex-notifier "^1.1.2"
codex-tooltip "^1.0.1" 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": "@nodelib/fs.scandir@2.1.4":
version "2.1.4" version "2.1.4"
resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz#d4b3549a5db5de2683e0c1071ab4f140904bbf69" 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" resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9"
integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== 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: autoprefixer@^10.2.5:
version "10.2.5" version "10.2.5"
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.2.5.tgz#096a0337dbc96c0873526d7fef5de4428d05382d" 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" normalize-range "^0.1.2"
postcss-value-parser "^4.1.0" 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: available-typed-arrays@^1.0.0, available-typed-arrays@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.2.tgz#6b098ca9d8039079ee3f77f7b783c4480ba513f5" 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: dependencies:
fill-range "^7.0.1" 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" version "4.16.6"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.6.tgz#d7901277a5a88e554ed305b183ec9b0c08f66fa2" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.6.tgz#d7901277a5a88e554ed305b183ec9b0c08f66fa2"
integrity sha512-Wspk/PqO+4W9qp5iUTJsa1B/QrYn1keNCcEP5OvP7WBwT4KaDly0uONYmC6Xa3Z5IqnUgS0KcgLYu1l74x0ZXQ== 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" escalade "^3.1.1"
node-releases "^1.1.71" 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: bytes@3.1.0:
version "3.1.0" version "3.1.0"
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6" 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.memoize "^4.1.2"
lodash.uniq "^4.5.0" 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" version "1.0.30001296"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001296.tgz" resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001296.tgz"
integrity sha512-WfrtPEoNSoeATDlf4y3QvkwiELl9GyPLISV5GejTbbQRtQx4LhsXmc9IQ6XCL2d7UxCyEzToEZNMeqR79OUw8Q== 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: caseless@~0.12.0:
version "0.12.0" version "0.12.0"
resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" 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" resolved "https://registry.yarnpkg.com/colord/-/colord-2.0.0.tgz#f8c19f2526b7dc5b22d6e57ef102f03a2a43a3d8"
integrity sha512-WMDFJfoY3wqPZNpKUFdse3HhD5BHCbE9JCdxRzoVH+ywRITGOeWAHNkGEmyxLlErEpN9OLMWgdM9dWQtDk5dog== integrity sha512-WMDFJfoY3wqPZNpKUFdse3HhD5BHCbE9JCdxRzoVH+ywRITGOeWAHNkGEmyxLlErEpN9OLMWgdM9dWQtDk5dog==


colorette@^1.2.1, colorette@^1.2.2:
colorette@^1.2.2:
version "1.2.2" version "1.2.2"
resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94" resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94"
integrity sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w== 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" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.736.tgz#f632d900a1f788dab22fec9c62ec5c9c8f0c4052"
integrity sha512-DY8dA7gR51MSo66DqitEQoUMQ0Z+A2DSXFi7tK304bdTVqczCAfUuyQw6Wdg8hIoo5zIxkU1L24RQtUce1Ioig== 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: emoji-regex@^7.0.1:
version "7.0.3" version "7.0.3"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" 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-date-object "^1.0.1"
is-symbol "^1.0.2" 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: esbuild-vue@^0.2.0:
version "0.2.0" version "0.2.0"
resolved "https://registry.yarnpkg.com/esbuild-vue/-/esbuild-vue-0.2.0.tgz#8a3fde404bda57fe32b80e24917d14036e242bd3" 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" resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.0.13.tgz#3c1c315fa16b35c85fffa95725a36fa729c69dfe"
integrity sha512-E1fz2Xs9ltlUp+qbiyx9wmt2n9dRzPsS11Jtdb8D2o+cC7wr9xkkKsVKJuBX0ST+LVS+LhLO+SbLJNtfWcJvXA== 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: frappe-charts@^2.0.0-rc13:
version "2.0.0-rc13" version "2.0.0-rc13"
resolved "https://registry.yarnpkg.com/frappe-charts/-/frappe-charts-2.0.0-rc13.tgz#fdb251d7ae311c41e38f90a3ae108070ec6b9072" 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" resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps= 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: import-fresh@^3.2.1:
version "3.3.0" version "3.3.0"
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" 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" resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01"
integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw== integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw==


nanoid@^3.1.22, nanoid@^3.1.23:
nanoid@^3.1.23:
version "3.2.0" version "3.2.0"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.2.0.tgz#62667522da6673971cca916a6d3eff3f415ff80c" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.2.0.tgz#62667522da6673971cca916a6d3eff3f415ff80c"
integrity sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA== 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: native-request@^1.0.5:
version "1.0.8" version "1.0.8"
resolved "https://registry.yarnpkg.com/native-request/-/native-request-1.0.8.tgz#8f66bf606e0f7ea27c0e5995eb2f5d03e33ae6fb" 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" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.72.tgz#14802ab6b1039a79a0c7d662b610a5bbd76eacbe"
integrity sha512-LLUo+PpH3dU6XizX3iVoubUNheF/owjXCZZ5yACDxNnPtgFuludV1ZL3ayK1kVep42Rmm0+R9/Y60NQbZ2bifw== 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: node-sass@^7.0.0:
version "7.0.0" version "7.0.0"
resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-7.0.0.tgz#33ee7c2df299d51f682f13d79f3d2a562225788e" resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-7.0.0.tgz#33ee7c2df299d51f682f13d79f3d2a562225788e"
@@ -3059,11 +3098,6 @@ nth-check@^2.0.0:
dependencies: dependencies:
boolbase "^1.0.0" 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: number-is-nan@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" 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" resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= 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: picomatch@^2.0.4:
version "2.2.3" version "2.2.3"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.3.tgz#465547f359ccc206d3c48e46a1bcb89bf7ee619d" 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" resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz#443f6a20ced6481a2bda4fa8532a6e55d789a2cb"
integrity sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ== 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: 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: postcss@^5.2.5:
version "5.2.18" version "5.2.18"
@@ -3664,15 +3708,6 @@ postcss@^7.0.14:
source-map "^0.6.1" source-map "^0.6.1"
supports-color "^6.1.0" 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: postcss@^8.2.4:
version "8.3.5" version "8.3.5"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.3.5.tgz#982216b113412bc20a86289e91eb994952a5b709" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.3.5.tgz#982216b113412bc20a86289e91eb994952a5b709"
@@ -4242,6 +4277,15 @@ sass@^1.18.0, sass@^1.x:
dependencies: dependencies:
chokidar ">=3.0.0 <4.0.0" 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: sax@^1.2.4, sax@~1.2.4:
version "1.2.4" version "1.2.4"
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" 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" resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.8.3.tgz#5ae908ef96300966e95440a143340f5dd565a0df"
integrity sha512-AftvD4hdKcR5QlGi7L/JST506zGNGrysE8/QohDpwKXJarHWqCt+TUlrtoMk/wkECB607Q019/OZlJViyWiD6A== 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: source-map-js@^0.6.2:
version "0.6.2" version "0.6.2"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-0.6.2.tgz#0bb5de631b41cfbda6cfba8bd05a80efdfd2385e" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-0.6.2.tgz#0bb5de631b41cfbda6cfba8bd05a80efdfd2385e"


Loading…
取消
儲存